Compare commits

..

2 Commits

Author SHA1 Message Date
Bram Kragten 30f29dbeab Add BrowserStack cross-browser/device e2e runs
Layers BrowserStack on top of the local Playwright e2e suites:
- browserstack.yml (Windows Chrome, macOS Firefox, iPad/iPhone WebKit,
  Galaxy S23) driven by the BrowserStack Node SDK and Local tunnel
- :browserstack package scripts and the gated E2E (BrowserStack) CI job
  (runs on manual dispatch or the e2e-browserstack PR label)
- tunnel/iOS-WebKit resilience in the specs (bs-local.com host, single
  shared mobile context, dynamic-import + CDP "Internal error" skips)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:50:27 +02:00
Bram Kragten 2c8d6c1a02 Add Playwright e2e tests for demo, test app, and gallery
Adds Playwright end-to-end tests covering three targets:
- the demo build
- a new lightweight test app exercising several scenarios (theming,
  admin/non-admin sidebar, panel navigation, more-info dialog)
- the component gallery

Includes the gulp/rspack build infra for the test app and an "E2E Tests"
GitHub Actions workflow that builds each target once, shares it via
artifacts, and runs the suites on Chromium and mobile Chrome. Browser
install is cached and retried to avoid intermittent download stalls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:49:08 +02:00
381 changed files with 9357 additions and 15021 deletions
+308
View File
@@ -0,0 +1,308 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
workflow_dispatch:
inputs:
run-browserstack:
description: "Run BrowserStack suite"
type: boolean
default: true
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
#
# Gated on:
# - manual dispatch with the run-browserstack input enabled, OR
# - a PR with the `e2e-browserstack` label applied.
# This keeps CI fast on normal PRs while still allowing on-demand runs.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
# Only depends on the local job — BrowserStack reports live on the
# BrowserStack Automate dashboard and don't feed into the local blob report.
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+8
View File
@@ -54,8 +54,16 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.16.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.17.0.cjs
yarnPath: .yarn/releases/yarn-4.16.0.cjs
+53
View File
@@ -0,0 +1,53 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+18 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -321,4 +320,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
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-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21 -1
View File
@@ -1,4 +1,3 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -268,3 +267,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+69 -29
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access } from "fs/promises";
import { readFile, access, readdir } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,58 +11,98 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
licenseFile: "license-mit",
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
if (!packageDir) {
throw new Error(
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -231,3 +232,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+6 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +349,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+6
View File
@@ -234,6 +234,12 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
@@ -1,75 +0,0 @@
import type { DemoTrace } from "./types";
export const notTriggeredTrace: DemoTrace = {
trace: {
last_step: "trigger/0",
run_id: "788767ce152d3d4475134bf1107986d4",
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
timestamp: {
start: "2021-03-25T04:36:51.223337+00:00",
finish: "2021-03-25T04:36:51.223341+00:00",
},
// Not-triggered traces have no trigger description.
trigger: null,
domain: "automation",
item_id: "1781703842452",
trace: {
"trigger/0": [
{
path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223340+00:00",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: {
entity_id: "light.bed_light",
to_state: "off",
},
},
},
],
},
config: {
id: "1781703842452",
alias: "Light Turned On Notification",
description: "Send a notification when a specific light is turned on.",
triggers: [
{
trigger: "light.turned_on",
target: {
floor_id: "test",
},
options: {
for: "00:00:00",
behavior: "each",
},
},
],
conditions: [],
actions: [
{
action: "notify.notify",
data: {
message: "A light was turned on.",
},
},
],
mode: "single",
},
context: {
id: "01KVAX7CG7XBDYGJYAGA4XJHGX",
parent_id: "01KVAX7CG631JRX4H3JS5JJ11Q",
user_id: null,
},
},
logbookEntries: [],
};
@@ -24,33 +24,6 @@ const traces: DemoTrace[] = [
error: 'Variable "beer" cannot be None',
}),
mockDemoTrace({ state: "stopped", script_execution: "cancelled" }),
mockDemoTrace({
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
// Not-triggered traces have no trigger description.
trigger: null,
trace: {
"trigger/0": [
{
path: "trigger/0",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: { entity_id: "light.bed_light", to_state: "off" },
},
timestamp: "2021-03-25T04:36:51.223693+00:00",
},
],
},
}),
];
@customElement("demo-automation-trace-timeline")
+8 -28
View File
@@ -2,20 +2,17 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/trace/ha-trace-path-details";
import type { HatScriptGraph } from "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-trace-timeline";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { basicTrace } from "../../data/traces/basic_trace";
import { motionLightTrace } from "../../data/traces/motion-light-trace";
import { notTriggeredTrace } from "../../data/traces/not-triggered-trace";
import type { DemoTrace } from "../../data/traces/types";
const traces: DemoTrace[] = [basicTrace, motionLightTrace, notTriggeredTrace];
const traces: DemoTrace[] = [basicTrace, motionLightTrace];
@customElement("demo-automation-trace")
export class DemoAutomationTrace extends LitElement {
@@ -23,25 +20,18 @@ export class DemoAutomationTrace extends LitElement {
@state() private _selected = {};
@queryAll("hat-script-graph") private _graphs!: NodeListOf<HatScriptGraph>;
protected render() {
if (!this.hass) {
return nothing;
}
return html`
${traces.map((trace, idx) => {
const graph = this._graphs?.[idx];
const selectedPath = this._selected[idx];
const selectedNode = selectedPath
? graph?.renderedNodes[selectedPath]
: undefined;
return html`
${traces.map(
(trace, idx) => html`
<ha-card .header=${trace.trace.config.alias}>
<div class="card-content">
<hat-script-graph
.trace=${trace.trace}
.selected=${selectedPath}
.selected=${this._selected[idx]}
@graph-node-selected=${this._handleGraphNodeSelected}
.sampleIdx=${idx}
></hat-script-graph>
@@ -50,25 +40,15 @@ export class DemoAutomationTrace extends LitElement {
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
.selectedPath=${selectedPath}
.selectedPath=${this._selected[idx]}
@value-changed=${this._handleTimelineValueChanged}
.sampleIdx=${idx}
></hat-trace-timeline>
${selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing}
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
`;
})}
`
)}
`;
}
+1 -32
View File
@@ -1,5 +1,4 @@
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -15,11 +14,6 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -502,10 +496,6 @@ const SCHEMAS: {
},
},
},
password: {
label: "Password",
selector: { text: { type: "password" } },
},
},
},
},
@@ -528,17 +518,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -560,16 +539,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
+1
View File
@@ -353,6 +353,7 @@ export class DemoEntityState extends LitElement {
title: "Icon",
template: (entry) => html`
<state-badge
.hass=${hass}
.stateObj=${entry.stateObj}
.stateColor=${true}
></state-badge>
+31 -20
View File
@@ -22,7 +22,16 @@
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -36,26 +45,26 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
@@ -63,7 +72,6 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
@@ -72,8 +80,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@tsparticles/engine": "4.1.3",
"@tsparticles/preset-links": "4.1.3",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -128,7 +136,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
@@ -138,9 +146,11 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.15",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -155,10 +165,11 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"@vitest/coverage-v8": "4.1.8",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
@@ -196,9 +207,9 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.1",
"typescript-eslint": "8.61.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -208,14 +219,14 @@
"lit-html": "3.3.3",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/daygrid": "6.1.20",
"globals": "17.6.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",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.17.0",
"packageManager": "yarn@4.16.0",
"volta": {
"node": "24.17.0"
"node": "24.16.0"
}
}
+1 -2
View File
@@ -4,8 +4,7 @@ import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
(isCore(page) || isLoadedIntegration(hass, page)) &&
(!page.filter || page.filter(hass));
isCore(page) || isLoadedIntegration(hass, page);
export const isLoadedIntegration = (
hass: HomeAssistant,
-19
View File
@@ -110,25 +110,6 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
"media_player",
]);
/** Domains that use a timestamp for state. */
export const TIMESTAMP_STATE_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
/** Temperature units. */
export const UNIT_C = "°C";
export const UNIT_F = "°F";
@@ -1,29 +0,0 @@
import { Task, type TaskConfig } from "@lit/task";
import type { ReactiveControllerHost } from "lit";
/**
* A `@lit/task` Task with a sticky `resolved` flag: false until the task has
* completed once, then true. Lets callers tell "still loading" apart from
* "resolved with an empty value" without a null sentinel, while keeping the
* previous value during a re-run.
*/
export class AsyncValueTask<T extends readonly unknown[], R> extends Task<
T,
R
> {
private _resolved = false;
constructor(host: ReactiveControllerHost, config: TaskConfig<T, R>) {
super(host, {
...config,
onComplete: (value) => {
this._resolved = true;
config.onComplete?.(value);
},
});
}
public get resolved(): boolean {
return this._resolved;
}
}
+5 -6
View File
@@ -3,24 +3,23 @@ import type { FrontendLocaleData } from "../../data/translation";
import { selectUnit } from "../util/select-unit";
const formatRelTimeMem = memoizeOne(
(locale: FrontendLocaleData, style: Intl.RelativeTimeFormatStyle) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto", style })
(locale: FrontendLocaleData) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto" })
);
export const relativeTime = (
from: Date,
locale: FrontendLocaleData,
to?: Date,
includeTense = true,
style: Intl.RelativeTimeFormatStyle = "long"
includeTense = true
): string => {
const diff = selectUnit(from, to, locale);
if (includeTense) {
return formatRelTimeMem(locale, style).format(diff.value, diff.unit);
return formatRelTimeMem(locale).format(diff.value, diff.unit);
}
return Intl.NumberFormat(locale.language, {
style: "unit",
unit: diff.unit,
unitDisplay: style,
unitDisplay: "long",
}).format(Math.abs(diff.value));
};
@@ -60,17 +60,6 @@ export const computeAttributeValueToParts = (
return [{ type: "value", value: localize("state.default.unknown") }];
}
// Device class attribute, return the integration's translated name
if (attribute === "device_class" && typeof attributeValue === "string") {
const domain = computeStateDomain(stateObj);
const deviceClassName = localize(
`component.${domain}.entity_component.${attributeValue}.name`
);
if (deviceClassName) {
return [{ type: "value", value: deviceClassName }];
}
}
// Number value, return formatted number
if (typeof attributeValue === "number") {
const domain = computeStateDomain(stateObj);
@@ -1,7 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { unitFromParts } from "./value_parts";
interface EntityUnitStubConfig {
entity: string;
@@ -41,5 +40,5 @@ export const computeEntityUnitDisplay = (
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return unitFromParts(parts);
return parts.find((part) => part.type === "unit")?.value ?? "";
};
+21 -4
View File
@@ -21,11 +21,29 @@ import {
isNumericSensorDeviceClass,
SENSOR_TIMESTAMP_DEVICE_CLASSES,
} from "../../data/sensor";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// Domains whose state is a timestamp.
const TIMESTAMP_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
@@ -160,8 +178,7 @@ const computeStateToPartsFromEntityAttributes = (
const type = MONETARY_TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts so the number stays a single part
// (e.g. "-" + "12" + "." + "00" → "-12.00")
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
@@ -256,7 +273,7 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
TIMESTAMP_STATE_DOMAINS.has(domain) ||
TIMESTAMP_DOMAINS.has(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
+23
View File
@@ -0,0 +1,23 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { supportsFeature } from "./supports-feature";
export type FeatureClassNames<T extends number = number> = Partial<
Record<T, string>
>;
// Expects classNames to be an object mapping feature-bit -> className
export const featureClassNames = (
stateObj: HassEntity,
classNames: FeatureClassNames
) => {
if (!stateObj || !stateObj.attributes.supported_features) {
return "";
}
return Object.keys(classNames)
.map((feature) =>
supportsFeature(stateObj, Number(feature)) ? classNames[feature] : ""
)
.filter((attr) => attr !== "")
.join(" ");
};
-56
View File
@@ -1,56 +0,0 @@
import { AITaskEntityFeature } from "../../data/ai_task";
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
import { CalendarEntityFeature } from "../../data/calendar";
import { CameraEntityFeature } from "../../data/camera";
import { ClimateEntityFeature } from "../../data/climate";
import { ConversationEntityFeature } from "../../data/conversation";
import { CoverEntityFeature } from "../../data/cover";
import { FanEntityFeature } from "../../data/fan";
import { HumidifierEntityFeature } from "../../data/humidifier";
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
import { LightEntityFeature } from "../../data/light";
import { LockEntityFeature } from "../../data/lock";
import { MediaPlayerEntityFeature } from "../../data/media-player";
import { NotifyEntityFeature } from "../../data/notify";
import { RemoteEntityFeature } from "../../data/remote";
import { SirenEntityFeature } from "../../data/siren";
import { TodoListEntityFeature } from "../../data/todo";
import { UpdateEntityFeature } from "../../data/update";
import { VacuumEntityFeature } from "../../data/vacuum";
import { ValveEntityFeature } from "../../data/valve";
import { WaterHeaterEntityFeature } from "../../data/water_heater";
import { WeatherEntityFeature } from "../../data/weather";
export type FeatureEnum = Record<string | number, string | number>;
const DOMAIN_ENUMS = {
ai_task: AITaskEntityFeature,
alarm_control_panel: AlarmControlPanelEntityFeature,
assist_satellite: AssistSatelliteEntityFeature,
calendar: CalendarEntityFeature,
camera: CameraEntityFeature,
climate: ClimateEntityFeature,
conversation: ConversationEntityFeature,
cover: CoverEntityFeature,
fan: FanEntityFeature,
humidifier: HumidifierEntityFeature,
lawn_mower: LawnMowerEntityFeature,
light: LightEntityFeature,
lock: LockEntityFeature,
media_player: MediaPlayerEntityFeature,
notify: NotifyEntityFeature,
remote: RemoteEntityFeature,
siren: SirenEntityFeature,
todo: TodoListEntityFeature,
update: UpdateEntityFeature,
vacuum: VacuumEntityFeature,
valve: ValveEntityFeature,
water_heater: WaterHeaterEntityFeature,
weather: WeatherEntityFeature,
};
export function getFeatures(domain: string): FeatureEnum | undefined {
const enumObj = DOMAIN_ENUMS[domain] as FeatureEnum;
return enumObj;
}
+76 -84
View File
@@ -22,13 +22,16 @@ export const FIXED_DOMAIN_STATES = {
assist_satellite: ["idle", "listening", "responding", "processing"],
automation: ["on", "off"],
binary_sensor: ["on", "off"],
button: [],
calendar: ["on", "off"],
camera: ["idle", "recording", "streaming"],
cover: ["closed", "closing", "open", "opening"],
device_tracker: ["home", "not_home"],
fan: ["on", "off"],
humidifier: ["on", "off"],
infrared: [],
input_boolean: ["on", "off"],
input_button: [],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
light: ["on", "off"],
lock: [
@@ -53,6 +56,7 @@ export const FIXED_DOMAIN_STATES = {
plant: ["ok", "problem"],
radio_frequency: [],
remote: ["on", "off"],
scene: [],
schedule: ["on", "off"],
script: ["on", "off"],
siren: ["on", "off"],
@@ -286,81 +290,6 @@ export const getStatesDomain = (
return result;
};
// Maps a value attribute (or the main state, keyed `_`) to the attribute listing
// its options. Naming is irregular per domain, so it's mapped explicitly.
export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
string,
Record<string, string>
> = {
climate: {
_: "hvac_modes",
fan_mode: "fan_modes",
preset_mode: "preset_modes",
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
event: {
event_type: "event_types",
},
fan: {
preset_mode: "preset_modes",
},
humidifier: {
mode: "available_modes",
},
input_select: {
_: "options",
},
select: {
_: "options",
},
light: {
effect: "effect_list",
color_mode: "supported_color_modes",
},
media_player: {
sound_mode: "sound_mode_list",
source: "source_list",
},
remote: {
current_activity: "activity_list",
},
sensor: {
_: "options",
},
vacuum: {
fan_speed: "fan_speed_list",
},
water_heater: {
_: "operation_list",
operation_mode: "operation_list",
},
};
const DOMAIN_VALUE_ATTRIBUTES: Record<
string,
Record<string, string>
> = Object.fromEntries(
Object.entries(DOMAIN_OPTIONS_ATTRIBUTES).map(([domain, mapping]) => [
domain,
Object.fromEntries(
Object.entries(mapping).map(([value, list]) => [list, value])
),
])
);
// value attribute (or main state) → its options-list attribute
export const getOptionsAttribute = (
domain: string,
attribute?: string
): string | undefined => DOMAIN_OPTIONS_ATTRIBUTES[domain]?.[attribute ?? "_"];
// options-list attribute → its value attribute (`_` = main state)
export const getValueAttribute = (
domain: string,
optionsAttribute: string
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
@@ -373,15 +302,78 @@ export const getStates = (
result.push(...getStatesDomain(hass, domain, attribute));
// Dynamic values based on the entities
const optionsAttribute = getOptionsAttribute(domain, attribute);
if (optionsAttribute) {
const options = state.attributes[optionsAttribute];
// Sensors only expose their options when their device class is `enum`.
const enumSensor =
domain !== "sensor" || state.attributes.device_class === "enum";
if (enumSensor && Array.isArray(options)) {
result.push(...options);
}
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "event":
if (attribute === "event_type") {
result.push(...state.attributes.event_types);
}
break;
case "fan":
if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
}
break;
case "humidifier":
if (attribute === "mode") {
result.push(...state.attributes.available_modes);
}
break;
case "input_select":
case "select":
if (!attribute) {
result.push(...state.attributes.options);
}
break;
case "light":
if (attribute === "effect" && state.attributes.effect_list) {
result.push(...state.attributes.effect_list);
} else if (
attribute === "color_mode" &&
state.attributes.supported_color_modes
) {
result.push(...state.attributes.supported_color_modes);
}
break;
case "media_player":
if (attribute === "sound_mode") {
result.push(...state.attributes.sound_mode_list);
} else if (attribute === "source") {
result.push(...state.attributes.source_list);
}
break;
case "remote":
if (attribute === "current_activity") {
result.push(...state.attributes.activity_list);
}
break;
case "sensor":
if (!attribute && state.attributes.device_class === "enum") {
result.push(...state.attributes.options);
}
break;
case "vacuum":
if (attribute === "fan_speed") {
result.push(...state.attributes.fan_speed_list);
}
break;
case "water_heater":
if (!attribute || attribute === "operation_mode") {
result.push(...state.attributes.operation_list);
}
break;
}
return [...new Set(result)];
+10 -2
View File
@@ -1,13 +1,21 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { computeDomain } from "./compute_domain";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
export function stateActive(stateObj: HassEntity, state?: string): boolean {
const domain = computeDomain(stateObj.entity_id);
const compareState = state !== undefined ? state : stateObj?.state;
if (TIMESTAMP_STATE_DOMAINS.has(domain)) {
if (
[
"button",
"event",
"infrared",
"input_button",
"radio_frequency",
"scene",
].includes(domain)
) {
return compareState !== UNAVAILABLE;
}
-29
View File
@@ -1,29 +0,0 @@
import type { ValuePart } from "../../types";
// Joins every part except the unit, keeping native order so the sign and
// grouping stay with the value (e.g. "-2,548.14").
export const valueFromParts = (parts: ValuePart[]): string =>
parts
.filter((part) => part.type !== "unit")
.map((part) => part.value)
.join("")
.trim();
export const unitFromParts = (parts: ValuePart[]): string =>
parts.find((part) => part.type === "unit")?.value ?? "";
export type UnitPosition = "before" | "after";
// Whether the unit sits before or after the value in the locale's native order
// (e.g. "$5" / "€ 5" → "before", "5 €" / "5 %" → "after").
export const unitPosition = (parts: ValuePart[]): UnitPosition => {
const unitIndex = parts.findIndex((part) => part.type === "unit");
if (unitIndex === -1) {
return "after";
}
const lastValueIndex = parts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
return unitIndex < lastValueIndex ? "before" : "after";
};
+2
View File
@@ -17,6 +17,8 @@ export type LocalizeKeys =
| `ui.common.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
| `ui.components.logbook.messages.detected_device_classes.${string}`
| `ui.components.logbook.messages.cleared_device_classes.${string}`
| `ui.dialogs.entity_registry.editor.${string}`
| `ui.dialogs.more_info_control.lawn_mower.${string}`
| `ui.dialogs.more_info_control.vacuum.${string}`
-26
View File
@@ -1,26 +0,0 @@
/**
* Return a shallow copy of an object with every key removed whose value is
* `undefined` or equals that key's default, so a key left at its default
* (whether absent or explicit) does not count as a difference. A key's default
* comes from `defaults` when present, otherwise `false`.
*
* Non-plain-object values are returned unchanged; only top-level keys are
* compared.
*/
export const stripDefaults = <T>(
value: T,
defaults?: Record<string, unknown>
): T => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const defaultValue = defaults && key in defaults ? defaults[key] : false;
if (val === undefined || val === defaultValue) {
continue;
}
result[key] = val;
}
return result as T;
};
@@ -4,10 +4,10 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
/**
* Call a function with result caching per entity.
* @param cacheKey key to namespace the cache
* @param cacheKey key to store the cache on hass object
* @param cacheTime time to cache the results
* @param func function to fetch the data
* @param hass Home Assistant object (or slice) the cache is keyed on
* @param hass Home Assistant object
* @param entityId entity to fetch data for
* @param args extra arguments to pass to the function to fetch the data
* @returns
@@ -15,12 +15,8 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
export const timeCacheEntityPromiseFunc = async <T>(
cacheKey: string,
cacheTime: number,
func: (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
...args: any[]
) => Promise<T>,
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
func: (hass: HomeAssistant, entityId: string, ...args: any[]) => Promise<T>,
hass: HomeAssistant,
entityId: string,
...args: any[]
): Promise<T> => {
@@ -43,11 +39,11 @@ export const timeCacheEntityPromiseFunc = async <T>(
// When successful, set timer to clear cache
() =>
setTimeout(() => {
cache[entityId] = undefined;
cache![entityId] = undefined;
}, cacheTime),
// On failure, clear cache right away
() => {
cache[entityId] = undefined;
cache![entityId] = undefined;
}
);
@@ -1,12 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-svg-icon";
import {
mdiAlertCircle,
mdiCheckCircle,
mdiCloseCircle,
mdiHelpCircle,
} from "@mdi/js";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -26,59 +19,46 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
private get _iconPath() {
switch (this.state) {
case "pass":
return mdiCheckCircle;
case "fail":
return mdiCloseCircle;
case "invalid":
return mdiAlertCircle;
default:
return mdiHelpCircle;
}
}
protected render() {
return html`
<div id="indicator" role="status" tabindex="0" aria-label=${this.label}>
<ha-svg-icon .path=${this._iconPath}></ha-svg-icon>
</div>
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
`;
}
static styles = css`
:host {
position: absolute;
top: -8px;
inset-inline-end: -8px;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 16px;
height: 16px;
display: grid;
place-items: center;
width: 10px;
height: 10px;
border-radius: var(--ha-border-radius-circle);
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
#indicator ha-svg-icon {
width: 16px;
height: 16px;
--mdc-icon-size: 16px;
}
:host([state="pass"]) #indicator {
color: var(--ha-color-green-60);
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
color: var(--ha-color-orange-60);
border-color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
color: var(--ha-color-red-60);
border-color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
color: var(--ha-color-neutral-60);
border-color: var(--ha-color-neutral-60);
}
`;
}
@@ -1,18 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "./ha-progress-button";
import { apiContext } from "../../data/context";
import type { HomeAssistant } from "../../types";
import { fireEvent } from "../../common/dom/fire_event";
import type { Appearance } from "../ha-button";
@customElement("ha-call-service-button")
class HaCallServiceButton extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -58,7 +56,7 @@ class HaCallServiceButton extends LitElement {
this.shadowRoot!.querySelector("ha-progress-button")!;
try {
await this._api.callService(
await this.hass.callService(
this.domain,
this.service,
this.data,
@@ -445,7 +445,6 @@ export class StateHistoryChartLine extends LitElement {
private _formatYAxisLabel = (value: number) => {
const label = formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
-1
View File
@@ -552,7 +552,6 @@ export class StatisticsChart extends LitElement {
private _formatYAxisLabel = (value: number) =>
formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
+3 -5
View File
@@ -79,11 +79,9 @@ function computeTimelineEnumColor(
const domain = computeStateDomain(stateObj);
const states =
FIXED_DOMAIN_STATES[domain] ||
((domain === "sensor" && stateObj.attributes.device_class === "enum") ||
domain === "select" ||
domain === "input_select"
? stateObj.attributes.options
: undefined) ||
(domain === "sensor" &&
stateObj.attributes.device_class === "enum" &&
stateObj.attributes.options) ||
[];
const idx = states.indexOf(state);
if (idx === -1) {
+11 -2
View File
@@ -161,7 +161,11 @@ export class HaEntityPicker extends LitElement {
: undefined;
if (stateObj) {
return html`
<state-badge slot="start" .stateObj=${stateObj}></state-badge>
<state-badge
slot="start"
.stateObj=${stateObj}
.hass=${this.hass}
></state-badge>
`;
}
if (extraOption.icon_path) {
@@ -212,7 +216,11 @@ export class HaEntityPicker extends LitElement {
);
return html`
<state-badge .stateObj=${stateObj} slot="start"></state-badge>
<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
slot="start"
></state-badge>
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
@@ -242,6 +250,7 @@ export class HaEntityPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
+5 -6
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFlash, mdiFlashOff } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -7,9 +6,9 @@ import { customElement, property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { apiContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
@@ -30,8 +29,8 @@ const isOn = (stateObj?: HassEntity) =>
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
// hass is not a property so that we only re-render on stateObj changes
public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@@ -119,7 +118,7 @@ export class HaEntityToggle extends LitElement {
// result in the entity to be turned on. Since the state is not changing,
// the resync is not called automatic.
private async _callService(turnOn): Promise<void> {
if (!this._api || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return;
}
forwardHaptic(this, "light");
@@ -150,7 +149,7 @@ export class HaEntityToggle extends LitElement {
this._isOn = turnOn;
try {
await this._api.callService(serviceDomain, service, {
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
} finally {
+20 -39
View File
@@ -1,5 +1,3 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -8,19 +6,13 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration";
import {
consumeEntityRegistryEntry,
consumeLocalize,
} from "../../common/decorators/consume-context-entry";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { unitFromParts, valueFromParts } from "../../common/entity/value_parts";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import type { LocalizeFunc } from "../../common/translations/localize";
import { formattersContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
import "../ha-label-badge";
import "../ha-state-icon";
@@ -48,15 +40,7 @@ const getTruncatedKey = (domainKey: string, stateKey: string) => {
@customElement("ha-state-label-badge")
export class HaStateLabelBadge extends LitElement {
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["state", "entity_id"] })
private _entry?: EntityRegistryDisplayEntry;
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public state?: HassEntity;
@@ -93,8 +77,10 @@ export class HaStateLabelBadge extends LitElement {
return html`
<ha-label-badge
class="warning"
label=${this._localize("state_badge.default.error")}
description=${this._localize("state_badge.default.entity_not_found")}
label=${this.hass!.localize("state_badge.default.error")}
description=${this.hass!.localize(
"state_badge.default.entity_not_found"
)}
>
<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>
</ha-label-badge>
@@ -108,7 +94,7 @@ export class HaStateLabelBadge extends LitElement {
// 4. Icon determined via entity state
// 5. Value string as fallback
const domain = computeStateDomain(entityState);
const entry = this._entry;
const entry = this.hass?.entities[entityState.entity_id];
const showIcon =
this.icon || this._computeShowIcon(domain, entityState, entry);
@@ -177,23 +163,20 @@ export class HaStateLabelBadge extends LitElement {
case "sun":
case "timer":
return null;
// @ts-expect-error we don't break and go to default
case "sensor":
if (entry?.platform === "moon") {
return null;
}
break;
// eslint-disable-next-line: disable=no-fallthrough
default:
break;
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
)?.value;
}
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return "—";
}
if (!this._formatters) {
return null;
}
return valueFromParts(
this._formatters.formatEntityStateToParts(entityState)
);
}
private _computeShowIcon(
@@ -228,11 +211,11 @@ export class HaStateLabelBadge extends LitElement {
) {
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return this._localize(`state_badge.default.${entityState.state}`);
return this.hass!.localize(`state_badge.default.${entityState.state}`);
}
const domainStateKey = getTruncatedKey(domain, entityState.state);
if (domainStateKey) {
return this._localize(`state_badge.${domainStateKey}`);
return this.hass!.localize(`state_badge.${domainStateKey}`);
}
// Person and device tracker state can be zone name
if (domain === "person" || domain === "device_tracker") {
@@ -241,12 +224,10 @@ export class HaStateLabelBadge extends LitElement {
if (domain === "timer") {
return secondsToDuration(_timerTimeRemaining);
}
if (!this._formatters) {
return null;
}
return (
unitFromParts(this._formatters.formatEntityStateToParts(entityState)) ||
null
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
);
}
+6 -1
View File
@@ -343,7 +343,11 @@ export class HaStatisticPicker extends LitElement {
return html`
${item.stateObj
? html`
<state-badge .stateObj=${item.stateObj} slot="start"></state-badge>
<state-badge
.hass=${this.hass}
.stateObj=${item.stateObj}
slot="start"
></state-badge>
`
: item.icon_path
? html`
@@ -484,6 +488,7 @@ export class HaStatisticPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
+15 -28
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -15,12 +14,13 @@ import {
import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { connectionContext } from "../../data/context";
import { isBrandUrl } from "../../util/brands-url";
import type { HomeAssistant } from "../../types";
import "../ha-state-icon";
@customElement("state-badge")
export class StateBadge extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public overrideIcon?: string;
@@ -36,10 +36,6 @@ export class StateBadge extends LitElement {
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public icon = true;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state() private _iconStyle: Record<string, string | undefined> = {};
connectedCallback(): void {
@@ -110,15 +106,14 @@ export class StateBadge extends LitElement {
></ha-state-icon>`;
}
public willUpdate(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (
!changedProps.has("stateObj") &&
!changedProps.has("overrideImage") &&
!changedProps.has("overrideIcon") &&
!changedProps.has("stateColor") &&
!changedProps.has("color") &&
!changedProps.has("_connection")
!changedProps.has("color")
) {
return;
}
@@ -138,10 +133,12 @@ export class StateBadge extends LitElement {
stateObj.attributes.entity_picture) &&
!this.overrideIcon
) {
let imageUrl = this._resolveImageUrl(
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture
);
stateObj.attributes.entity_picture;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
@@ -182,7 +179,11 @@ export class StateBadge extends LitElement {
}
}
} else if (this.overrideImage) {
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
let imageUrl = this.overrideImage;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
}
}
@@ -191,20 +192,6 @@ export class StateBadge extends LitElement {
this.style.backgroundImage = backgroundImage;
}
// Sign the image URL via the connection context so brand images
// (/api/brands/...) get their access token. Without a way to sign, a brands
// request would be rejected (and logged/blocked by core), so skip it until
// we can sign.
private _resolveImageUrl(url: string | undefined): string {
if (!url) {
return "";
}
if (this._connection) {
return this._connection.hassUrl(url);
}
return isBrandUrl(url) ? "" : url;
}
protected getClass() {
const cls = new Map(
["has-no-radius", "has-media-image", "has-image"].map((_cls) => [
+1
View File
@@ -24,6 +24,7 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
return html`<state-badge
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateColor=${true}
.color=${this.color}
+24 -52
View File
@@ -1,4 +1,3 @@
import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiChevronDown,
@@ -11,9 +10,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
runAssistPipeline,
type AssistPipeline,
@@ -21,19 +18,10 @@ import {
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import {
configContext,
connectionContext,
statesContext,
} from "../data/context";
import { ConversationEntityFeature } from "../data/conversation";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { haStyleScrollbar } from "../resources/styles";
import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantConnection,
} from "../types";
import type { HomeAssistant } from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
@@ -59,6 +47,8 @@ interface AssistMessage {
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public pipeline?: AssistPipeline;
@property({ type: Boolean, attribute: "disable-speech" })
@@ -81,22 +71,6 @@ export class HaAssistChat extends LitElement {
@state() private _processing = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@state()
@consume({ context: configContext, subscribe: true })
private _config!: HomeAssistantConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: HomeAssistantConnection;
private _conversationId: string | null = null;
private _audioRecorder?: AudioRecorder;
@@ -112,7 +86,7 @@ export class HaAssistChat extends LitElement {
this._conversation = [
{
who: "hass",
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
@@ -150,9 +124,9 @@ export class HaAssistChat extends LitElement {
const controlHA = !this.pipeline
? false
: this.pipeline.prefer_local_intents ||
(this._states[this.pipeline.conversation_engine]
(this.hass.states[this.pipeline.conversation_engine]
? supportsFeature(
this._states[this.pipeline.conversation_engine],
this.hass.states[this.pipeline.conversation_engine],
ConversationEntityFeature.CONTROL
)
: true);
@@ -165,7 +139,7 @@ export class HaAssistChat extends LitElement {
? nothing
: html`
<ha-alert>
${this._localize(
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
@@ -206,7 +180,7 @@ export class HaAssistChat extends LitElement {
.path=${mdiCommentProcessingOutline}
></ha-svg-icon>
<span class="thinking-label">
${this._localize(
${this.hass.localize(
"ui.dialogs.voice_command.show_details"
)}
</span>
@@ -277,7 +251,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
id="message-input"
@keyup=${this._handleKeyUp}
@input=${this._handleInput}
.label=${this._localize(`ui.dialogs.voice_command.input_label`)}
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
>
<div slot="end">
${this._showSendButton || !supportsSTT
@@ -287,7 +261,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
.path=${mdiSend}
@click=${this._handleSendMessage}
.disabled=${this._processing}
.label=${this._localize(
.label=${this.hass.localize(
"ui.dialogs.voice_command.send_text"
)}
>
@@ -308,7 +282,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
.path=${mdiMicrophone}
@click=${this._handleListeningButton}
.disabled=${this._processing}
.label=${this._localize(
.label=${this.hass.localize(
"ui.dialogs.voice_command.start_listening"
)}
>
@@ -400,12 +374,10 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
private _handleToggleThinking(ev: Event) {
const index = (ev.currentTarget as any).index;
// Mutate the message in place rather than replacing it. The streaming
// processor keeps a reference to this same object and mutates it as deltas
// arrive; swapping in a new object would detach the in-flight message from
// the processor and freeze the chat (see #52501).
const message = this._conversation[index];
message.thinking_expanded = !message.thinking_expanded;
this._conversation[index] = {
...this._conversation[index],
thinking_expanded: !this._conversation[index].thinking_expanded,
};
this.requestUpdate("_conversation");
}
@@ -419,21 +391,21 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
text:
// New lines matter for messages
// prettier-ignore
html`${this._localize(
html`${this.hass.localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this._localize(
${this.hass.localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this._config,
this.hass,
"/docs/configuration/securing/#remote-access"
)}
>${this._localize(
>${this.hass.localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
@@ -471,7 +443,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
try {
const unsub = await runAssistPipeline(
this._connection,
this.hass,
(event: PipelineRunEvent) => {
if (event.type === "run-start") {
this._stt_binary_handler_id =
@@ -567,7 +539,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
}
private _sendAudioChunk(chunk: Int16Array) {
this._connection.connection.socket!.binaryType = "arraybuffer";
this.hass.connection.socket!.binaryType = "arraybuffer";
// eslint-disable-next-line eqeqeq
if (this._stt_binary_handler_id == undefined) {
@@ -578,7 +550,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
data[0] = this._stt_binary_handler_id;
data.set(new Uint8Array(chunk.buffer), 1);
this._connection.connection.socket!.send(data);
this.hass.connection.socket!.send(data);
}
private _unloadAudio = () => {
@@ -598,7 +570,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
hassMessageProcesser.addMessage();
try {
const unsub = await runAssistPipeline(
this._connection,
this.hass,
(event) => {
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
@@ -621,7 +593,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
);
} catch {
hassMessageProcesser.setError(
this._localize("ui.dialogs.voice_command.error")
this.hass.localize("ui.dialogs.voice_command.error")
);
} finally {
this._processing = false;
+19 -67
View File
@@ -1,21 +1,16 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { attributeIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-attribute-icon")
export class HaAttributeIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property() public attribute?: string;
@@ -24,59 +19,6 @@ export class HaAttributeIcon extends LitElement {
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([
icon,
config,
connection,
entities,
stateObj,
attribute,
attributeValue,
]) => {
if (
icon ||
!config ||
!connection ||
!entities ||
!stateObj ||
!attribute
) {
return initialState;
}
return attributeIcon(
config.config,
connection.connection,
entities,
stateObj,
attribute,
attributeValue
);
},
args: () =>
[
this.icon,
this._config,
this._connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -86,13 +28,23 @@ export class HaAttributeIcon extends LitElement {
return nothing;
}
if (!this._config || !this._connection || !this._entities) {
if (!this.hass) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: nothing;
const icon = attributeIcon(
this.hass,
this.stateObj,
this.attribute,
this.attributeValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return nothing;
});
return html`${until(icon)}`;
}
}
+12 -43
View File
@@ -1,19 +1,11 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { getValueAttribute } from "../common/entity/get_states";
import { valueFromParts } from "../common/entity/value_parts";
import { until } from "lit/directives/until";
import { formattersContext } from "../data/context";
const isObjectValue = (value: unknown): boolean =>
(Array.isArray(value) && value.some((val) => val instanceof Object)) ||
(!Array.isArray(value) && value instanceof Object);
@customElement("ha-attribute-value")
class HaAttributeValue extends LitElement {
@state()
@@ -26,17 +18,6 @@ class HaAttributeValue extends LitElement {
@property({ type: Boolean, attribute: "hide-unit" }) public hideUnit = false;
private _yamlTask = new AsyncValueTask(this, {
task: async ([attributeValue]) => {
if (!isObjectValue(attributeValue)) {
return initialState;
}
const { dump } = await import("js-yaml");
return dump(attributeValue);
},
args: () => [this.stateObj?.attributes[this.attribute]] as const,
});
protected render() {
if (!this.stateObj) {
return nothing;
@@ -66,28 +47,13 @@ class HaAttributeValue extends LitElement {
}
}
if (isObjectValue(attributeValue)) {
return html`<pre>${this._yamlTask.value ?? ""}</pre>`;
}
// Options-list attributes (effect_list, preset_modes, …) translated through
// their value attribute, or the main state for lists like hvac_modes.
if (Array.isArray(attributeValue)) {
const domain = computeStateDomain(this.stateObj);
const valueAttribute = getValueAttribute(domain, this.attribute);
if (valueAttribute) {
return attributeValue
.map((item) =>
valueAttribute === "_"
? this._formatters!.formatEntityState(this.stateObj!, item)
: this._formatters!.formatEntityAttributeValue(
this.stateObj!,
valueAttribute,
item
)
)
.join(", ");
}
if (
(Array.isArray(attributeValue) &&
attributeValue.some((val) => val instanceof Object)) ||
(!Array.isArray(attributeValue) && attributeValue instanceof Object)
) {
const yaml = import("js-yaml").then(({ dump }) => dump(attributeValue));
return html`<pre>${until(yaml, "")}</pre>`;
}
if (this.hideUnit) {
@@ -95,7 +61,10 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return valueFromParts(parts);
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
}
return this._formatters!.formatEntityAttributeValue(
+2 -8
View File
@@ -153,16 +153,10 @@ export class HaBaseTimeInput extends LitElement {
protected render(): TemplateResult {
return html`
${this.label
? html`<label id="label"
>${this.label}${this.required ? " *" : ""}</label
>`
? html`<label>${this.label}${this.required ? " *" : ""}</label>`
: nothing}
<div class="time-input-wrap-wrap">
<div
class="time-input-wrap"
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
<div class="time-input-wrap">
${this.enableDay
? html`
<ha-input
+12 -11
View File
@@ -1,12 +1,10 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import { internationalizationContext } from "../data/context";
import type { HomeAssistant } from "../types";
@customElement("ha-big-number")
export class HaBigNumber extends LitElement {
@@ -17,16 +15,17 @@ export class HaBigNumber extends LitElement {
@property({ attribute: "unit-position" })
public unitPosition: "top" | "bottom" = "top";
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false })
public formatOptions: Intl.NumberFormatOptions = {};
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
protected render() {
const locale = this._i18n!.locale;
const formatted = formatNumber(this.value, locale, this.formatOptions);
const formatted = formatNumber(
this.value,
this.hass?.locale,
this.formatOptions
);
const [integer] = formatted.includes(".")
? formatted.split(".")
: formatted.split(",");
@@ -34,7 +33,9 @@ export class HaBigNumber extends LitElement {
const temperatureDecimal = formatted.replace(integer, "");
const formattedValue = `${this.value}${
this.unit ? `${blankBeforeUnit(this.unit, locale)}${this.unit}` : ""
this.unit
? `${blankBeforeUnit(this.unit, this.hass?.locale)}${this.unit}`
: ""
}`;
const unitBottom = this.unitPosition === "bottom";
+15 -36
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
@@ -8,7 +7,7 @@ import memoizeOne from "memoize-one";
import { computeStateName } from "../common/entity/compute_state_name";
import { supportsFeature } from "../common/entity/supports-feature";
import {
CameraEntityFeature,
CAMERA_SUPPORT_STREAM,
type CameraCapabilities,
type CameraEntity,
computeMJPEGStreamUrl,
@@ -18,7 +17,7 @@ import {
STREAM_TYPE_WEB_RTC,
type StreamType,
} from "../data/camera";
import { apiContext, configContext, connectionContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-hls-player";
import "./ha-web-rtc-player";
@@ -31,17 +30,7 @@ interface Stream {
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: CameraEntity;
@@ -69,33 +58,21 @@ export class HaCameraStream extends LitElement {
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
private _thumbnailApi = memoizeOne(
(
api: ContextType<typeof apiContext>,
connection: ContextType<typeof connectionContext>
) => ({
callWS: api.callWS,
hassUrl: connection.hassUrl,
})
);
public willUpdate(changedProps: PropertyValues): void {
public willUpdate(changedProps: PropertyValues<this>): void {
const entityChanged =
changedProps.has("stateObj") &&
this.stateObj &&
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
this.stateObj.entity_id;
const oldConfig = changedProps.get("_config") as
| ContextType<typeof configContext>
| undefined;
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const backendStarted =
changedProps.has("_config") &&
this._config &&
changedProps.has("hass") &&
this.hass &&
this.stateObj &&
oldConfig &&
this._config.config.state === STATE_RUNNING &&
oldConfig.config?.state !== STATE_RUNNING;
oldHass &&
this.hass.config.state === STATE_RUNNING &&
oldHass.config?.state !== STATE_RUNNING;
if (entityChanged || backendStarted) {
this._getCapabilities();
@@ -160,6 +137,7 @@ export class HaCameraStream extends LitElement {
.allowExoPlayer=${this.allowExoPlayer}
.muted=${this.muted}
.controls=${this.controls}
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@streams=${this._handleHlsStreams}
@@ -175,6 +153,7 @@ export class HaCameraStream extends LitElement {
playsinline
.muted=${this.muted}
.controls=${this.controls}
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@streams=${this._handleWebRtcStreams}
@@ -191,12 +170,12 @@ export class HaCameraStream extends LitElement {
this._capabilities = undefined;
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
if (!supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM)) {
this._capabilities = { frontend_stream_types: [] };
return;
}
this._capabilities = await fetchCameraCapabilities(
this._api,
this.hass!,
this.stateObj!.entity_id
);
}
@@ -204,7 +183,7 @@ export class HaCameraStream extends LitElement {
private async _getPosterUrl(): Promise<void> {
try {
this._posterUrl = await fetchThumbnailUrlWithCache(
this._thumbnailApi(this._api, this._connection),
this.hass!,
this.stateObj!.entity_id,
this.clientWidth,
this.clientHeight
+13 -19
View File
@@ -12,11 +12,10 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -58,17 +57,6 @@ export class HaConditionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, condition]) => {
if (icon || !connection || !config || !condition) {
return initialState;
}
return conditionIcon(connection, config, condition);
},
args: () =>
[this.icon, this._connection, this._config, this.condition] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -82,12 +70,18 @@ export class HaConditionIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = conditionIcon(
this._connection,
this._config,
this.condition
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+1 -4
View File
@@ -388,10 +388,7 @@ export class HaControlSlider extends LitElement {
private _isVisuallyInverted() {
let inverted = this.inverted;
// RTL only mirrors the horizontal axis. A vertical slider always fills
// bottom-to-top regardless of text direction, so it must not be flipped,
// otherwise its value mapping ends up upside down in RTL languages.
if (!this.vertical && mainWindow.document.dir === "rtl") {
if (mainWindow.document.dir === "rtl") {
inverted = !inverted;
}
+16 -32
View File
@@ -1,8 +1,7 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
@@ -37,30 +36,6 @@ export class HaDomainIcon extends LitElement {
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, domain, deviceClass, domainState]) => {
if (icon || !connection || !config || !domain) {
return initialState;
}
return domainIcon(
connection.connection,
config.config,
domain,
deviceClass,
domainState
);
},
args: () =>
[
this.icon,
this._connection,
this._hassConfig,
this.domain,
this.deviceClass,
this.state,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -74,12 +49,21 @@ export class HaDomainIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = domainIcon(
this._connection.connection,
this._hassConfig.config,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+6 -18
View File
@@ -1,18 +1,13 @@
import { consume } from "@lit/context";
import { mdiDelete, mdiFileUpload } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { fireEvent } from "../common/dom/fire_event";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import type { HomeAssistantInternationalization } from "../types";
import type { HomeAssistant } from "../types";
import { bytesToString } from "../util/bytes-to-string";
import "./ha-button";
import "./ha-icon-button";
@@ -27,17 +22,10 @@ declare global {
@customElement("ha-file-upload")
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@property() public accept!: string;
@property() public icon?: string;
@@ -92,7 +80,7 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this._localize!;
const localize = this.localize || this.hass!.localize;
return html`
${this.uploading
? html`<div class="container">
@@ -107,8 +95,8 @@ export class HaFileUpload extends LitElement {
>
${this.progress
? html`<div class="progress">
${this.progress}${this._locale &&
blankBeforePercent(this._locale)}%
${this.progress}${this.hass &&
blankBeforePercent(this.hass!.locale)}%
</div>`
: nothing}
</div>
+18 -57
View File
@@ -1,23 +1,15 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { deepEqual } from "../common/util/deep-equal";
import {
apiContext,
devicesContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -32,24 +24,7 @@ interface HaFilterDevicesItem extends HaListVirtualizedItem {
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: devicesContext, subscribe: true })
@state()
private _devicesReg!: ContextType<typeof devicesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@@ -100,7 +75,7 @@ export class HaFilterDevices extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.devices.caption")}
${this.hass.localize("ui.panel.config.devices.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -120,13 +95,7 @@ export class HaFilterDevices extends LitElement {
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rows=${this._devices(this.hass.devices, this._filter || "")}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
@@ -152,24 +121,13 @@ export class HaFilterDevices extends LitElement {
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
...(this.value ?? []),
this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)[ev.detail].id,
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
];
}
private _handleRemoved(ev: CustomEvent<number>) {
const id = this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)[ev.detail].id;
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
.id;
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
@@ -195,24 +153,27 @@ export class HaFilterDevices extends LitElement {
private _devices = memoizeOne(
(
devices: ContextType<typeof devicesContext>,
filter: string,
localize: LocalizeFunc,
states: ContextType<typeof statesContext>,
language: string | undefined
devices: HomeAssistant["devices"],
filter: string
): HaFilterDevicesItem[] => {
const values = Object.values(devices);
return values
.map((device) => ({
id: device.id,
interactive: true,
name: computeDeviceNameDisplay(device, localize, states),
name: computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
),
}))
.filter(
({ name }) =>
!filter || name.toLowerCase().includes(filter.toLowerCase())
)
.sort((a, b) => stringCompare(a.name, b.name, language));
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
}
);
@@ -233,7 +194,7 @@ export class HaFilterDevices extends LitElement {
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this._api, "device", deviceId));
relatedPromises.push(findRelated(this.hass, "device", deviceId));
}
}
const results = await Promise.all(relatedPromises);
+25 -58
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -6,14 +5,12 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
@@ -23,17 +20,7 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-domains")
export class HaFilterDomains extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@@ -56,7 +43,7 @@ export class HaFilterDomains extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.domains.caption")}
${this.hass.localize("ui.panel.config.domains.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -78,13 +65,7 @@ export class HaFilterDomains extends LitElement {
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
this._domains(this.hass.states, this._filter, this.value),
(i) => i,
(domain) =>
html`<ha-check-list-item
@@ -97,7 +78,7 @@ export class HaFilterDomains extends LitElement {
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
${domainToName(this.hass.localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
@@ -106,34 +87,26 @@ export class HaFilterDomains extends LitElement {
`;
}
private _domains = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
_value
) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
private _domains = memoizeOne((states, filter, _value) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
return Array.from(domains.values())
.map((domain) => ({
domain,
name: domainToName(localize, domain),
}))
.filter(
(entry) =>
!filter ||
entry.domain.toLowerCase().includes(filter) ||
entry.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
.map((entry) => entry.domain);
}
);
return Array.from(domains.values())
.map((domain) => ({
domain,
name: domainToName(this.hass.localize, domain),
}))
.filter(
(entry) =>
!filter ||
entry.domain.toLowerCase().includes(filter) ||
entry.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, this.hass.locale.language))
.map((entry) => entry.domain);
});
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
@@ -156,13 +129,7 @@ export class HaFilterDomains extends LitElement {
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const domains = this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
);
const domains = this._domains(this.hass.states, this._filter, this.value);
const visibleDomains = new Set(domains);
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
+11 -29
View File
@@ -1,25 +1,18 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { deepEqual } from "../common/util/deep-equal";
import {
apiContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
@@ -29,20 +22,7 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@@ -82,7 +62,7 @@ export class HaFilterEntities extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.entities.caption")}
${this.hass.localize("ui.panel.config.entities.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -102,10 +82,9 @@ export class HaFilterEntities extends LitElement {
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
this._states,
this.hass.states,
this.type,
this._filter || "",
this._i18n.locale.language,
this.value
)}
.keyFunction=${this._keyFunction}
@@ -184,10 +163,9 @@ export class HaFilterEntities extends LitElement {
private _entities = memoizeOne(
(
states: ContextType<typeof statesContext>,
states: HomeAssistant["states"],
type: this["type"],
filter: string,
language: string | undefined,
_value
) => {
const values = Object.values(states);
@@ -202,7 +180,11 @@ export class HaFilterEntities extends LitElement {
.includes(filter))
)
.sort((a, b) =>
stringCompare(computeStateName(a), computeStateName(b), language)
stringCompare(
computeStateName(a),
computeStateName(b),
this.hass.locale.language
)
);
}
);
@@ -221,7 +203,7 @@ export class HaFilterEntities extends LitElement {
for (const entityId of this.value) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "entity", entityId));
relatedPromises.push(findRelated(this.hass, "entity", entityId));
}
}
+12 -38
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -6,21 +5,14 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import { deepEqual } from "../common/util/deep-equal";
import type { LocalizeFunc } from "../common/translations/localize";
import {
apiContext,
areasContext,
floorsContext,
internationalizationContext,
} from "../data/context";
import { getFloorAreaLookup } from "../data/floor_registry";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-icon";
@@ -34,24 +26,7 @@ import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: areasContext, subscribe: true })
@state()
private _areasReg!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floorsReg!: ContextType<typeof floorsContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: {
floors?: string[];
@@ -80,7 +55,7 @@ export class HaFilterFloorAreas extends LitElement {
}
protected render() {
const areas = this._areas(this._areasReg, this._floorsReg);
const areas = this._areas(this.hass.areas, this.hass.floors);
return html`
<ha-expansion-panel
@@ -90,7 +65,7 @@ export class HaFilterFloorAreas extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.areas.caption")}
${this.hass.localize("ui.panel.config.areas.caption")}
${this.value?.areas?.length || this.value?.floors?.length
? html`<div class="badge">
${(this.value?.areas?.length || 0) +
@@ -110,7 +85,9 @@ export class HaFilterFloorAreas extends LitElement {
multi
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
aria-label=${this._localize("ui.panel.config.areas.caption")}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
>
${repeat(
areas?.floors || [],
@@ -164,8 +141,8 @@ export class HaFilterFloorAreas extends LitElement {
.type=${"areas"}
class=${classMap({
rtl: computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
this.hass.language,
this.hass.translationMetadata.translations
),
floor: hasFloor,
})}
@@ -248,10 +225,7 @@ export class HaFilterFloorAreas extends LitElement {
}
private _areas = memoizeOne(
(
areaReg: ContextType<typeof areasContext>,
floorReg: ContextType<typeof floorsContext>
) => {
(areaReg: HomeAssistant["areas"], floorReg: HomeAssistant["floors"]) => {
const areas = Object.values(areaReg);
const floors = Object.values(floorReg);
const floorAreaLookup = getFloorAreaLookup(areas);
@@ -287,7 +261,7 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "area", areaId));
relatedPromises.push(findRelated(this.hass, "area", areaId));
}
}
}
@@ -295,7 +269,7 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "floor", floorId));
relatedPromises.push(findRelated(this.hass, "floor", floorId));
}
}
}
+13 -23
View File
@@ -1,4 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { consume } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -6,14 +6,13 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, labelsContext } from "../data/context";
import { labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -26,20 +25,14 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-labels")
export class HaFilterLabels extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
@@ -52,12 +45,7 @@ export class HaFilterLabels extends LitElement {
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
(
labels: LabelRegistryEntry[],
filter: string | undefined,
language: string | undefined,
_value
) =>
(labels: LabelRegistryEntry[], filter: string | undefined, _value) =>
labels
.filter(
(label) =>
@@ -66,7 +54,11 @@ export class HaFilterLabels extends LitElement {
label.label_id.toLowerCase().includes(filter)
)
.sort((a, b) =>
stringCompare(a.name || a.label_id, b.name || b.label_id, language)
stringCompare(
a.name || a.label_id,
b.name || b.label_id,
this.hass.locale.language
)
)
);
@@ -79,7 +71,7 @@ export class HaFilterLabels extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.labels.caption")}
${this.hass.localize("ui.panel.config.labels.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -104,7 +96,6 @@ export class HaFilterLabels extends LitElement {
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
@@ -138,7 +129,7 @@ export class HaFilterLabels extends LitElement {
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
${this.hass.localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
: nothing}
`;
@@ -178,7 +169,6 @@ export class HaFilterLabels extends LitElement {
const filteredLabels = this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
);
@@ -8,6 +8,7 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -21,6 +22,8 @@ import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@@ -75,6 +78,7 @@ export class HaFilterVoiceAssistants extends LitElement {
<voice-assistant-brand-icon
slot="graphic"
.voiceAssistantId=${voiceAssistantId}
.hass=${this.hass}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
+10 -30
View File
@@ -1,16 +1,13 @@
import { consume, type ContextType } from "@lit/context";
import type HlsType from "hls.js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { nextRender } from "../common/util/render-status";
import { fetchStreamUrl } from "../data/camera";
import { apiContext, configContext, connectionContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-alert";
type HlsLite = Omit<
@@ -20,21 +17,7 @@ type HlsLite = Omit<
@customElement("ha-hls-player")
class HaHLSPlayer extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entityid?: string;
@@ -157,7 +140,7 @@ class HaHLSPlayer extends LitElement {
this._cleanUp();
this._resetError();
if (!isComponentLoaded(this._config.config, "stream")) {
if (!isComponentLoaded(this.hass.config, "stream")) {
this._setFatalError("Streaming component is not loaded.");
return;
}
@@ -166,12 +149,9 @@ class HaHLSPlayer extends LitElement {
return;
}
try {
const { url } = await fetchStreamUrl(
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
this.entityid
);
const { url } = await fetchStreamUrl(this.hass!, this.entityid);
this._url = this._connection.hassUrl(url);
this._url = this.hass.hassUrl(url);
this._cleanUp();
this._resetError();
this._startHls();
@@ -204,13 +184,13 @@ class HaHLSPlayer extends LitElement {
if (!hlsSupported) {
this._setFatalError(
this._localize("ui.components.media-browser.video_not_supported")
this.hass.localize("ui.components.media-browser.video_not_supported")
);
return;
}
const useExoPlayer =
this.allowExoPlayer && this._config.auth.external?.config.hasExoPlayer;
this.allowExoPlayer && this.hass.auth.external?.config.hasExoPlayer;
const masterPlaylist = await (await masterPlaylistPromise).text();
if (!this.isConnected) {
@@ -256,7 +236,7 @@ class HaHLSPlayer extends LitElement {
window.addEventListener("resize", this._resizeExoPlayer);
this.updateComplete.then(() => nextRender()).then(this._resizeExoPlayer);
this._videoEl.style.visibility = "hidden";
await this._config.auth.external!.fireMessage({
await this.hass!.auth.external!.fireMessage({
type: "exoplayer/play_hls",
payload: {
url,
@@ -270,7 +250,7 @@ class HaHLSPlayer extends LitElement {
return;
}
const rect = this._videoEl.getBoundingClientRect();
this._config.auth.external!.fireMessage({
this.hass!.auth.external!.fireMessage({
type: "exoplayer/resize",
payload: {
left: rect.left,
@@ -382,7 +362,7 @@ class HaHLSPlayer extends LitElement {
}
if (this._exoPlayer) {
window.removeEventListener("resize", this._resizeExoPlayer);
this._config.auth.external!.fireMessage({ type: "exoplayer/stop" });
this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" });
this._exoPlayer = false;
}
if (this._videoEl) {
+8 -17
View File
@@ -1,13 +1,10 @@
import { consume, type ContextType } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { customElement, property } from "lit/decorators";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext, formattersContext } from "../data/context";
import "./ha-button";
import type { LawnMowerEntity, LawnMowerEntityState } from "../data/lawn_mower";
import { LawnMowerEntityFeature } from "../data/lawn_mower";
import type { HomeAssistant } from "../types";
interface LawnMowerAction {
action: string;
@@ -42,19 +39,13 @@ const LAWN_MOWER_ACTIONS: Partial<
@customElement("ha-lawn_mower-action-button")
class HaLawnMowerActionButton extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: LawnMowerEntity;
public render() {
const action = LAWN_MOWER_ACTIONS[this.stateObj.state];
const state = this.stateObj.state;
const action = LAWN_MOWER_ACTIONS[state];
if (action && supportsFeature(this.stateObj, action.feature)) {
return html`
@@ -64,14 +55,14 @@ class HaLawnMowerActionButton extends LitElement {
.service=${action.service}
size="s"
>
${this._localize(`ui.card.lawn_mower.actions.${action.action}`)}
${this.hass.localize(`ui.card.lawn_mower.actions.${action.action}`)}
</ha-button>
`;
}
return html`
<ha-button appearance="plain" disabled>
${this._formatters?.formatEntityState(this.stateObj)}
${this.hass.formatEntityState(this.stateObj)}
</ha-button>
`;
}
@@ -80,7 +71,7 @@ class HaLawnMowerActionButton extends LitElement {
ev.stopPropagation();
const stateObj = this.stateObj;
const service = ev.target.service;
this._api.callService("lawn_mower", service, {
this.hass.callService("lawn_mower", service, {
entity_id: stateObj.entity_id,
});
}
+1
View File
@@ -78,6 +78,7 @@ export class HaPictureUpload extends LitElement {
return html`
<ha-file-upload
.hass=${this.hass}
.icon=${mdiImagePlus}
.label=${this.label ||
this.hass.localize("ui.components.picture-upload.label")}
+25 -27
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCamera } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -9,11 +8,9 @@ import { customElement, property, query, state } from "lit/decorators";
// WebAssembly port of ZXing:
import { prepareZXingModule } from "barcode-detector";
import type QrScanner from "qr-scanner";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { configContext } from "../data/context";
import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-button";
import "./ha-dropdown";
@@ -36,13 +33,7 @@ prepareZXingModule({
@customElement("ha-qr-scanner")
class HaQrScanner extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public description?: string;
@@ -115,7 +106,7 @@ class HaQrScanner extends LitElement {
${this._error || this._warning}
${this._error
? html`<ha-button @click=${this._retry} slot="action">
${this._localize("ui.components.qr-scanner.retry")}
${this.hass.localize("ui.components.qr-scanner.retry")}
</ha-button>`
: nothing}
</ha-alert>`
@@ -135,7 +126,7 @@ class HaQrScanner extends LitElement {
? html`<ha-dropdown @wa-select=${this._handleDropdownSelect}>
<ha-icon-button
slot="trigger"
.label=${this._localize(
.label=${this.hass.localize(
"ui.components.qr-scanner.select_camera"
)}
.path=${mdiCamera}
@@ -155,24 +146,28 @@ class HaQrScanner extends LitElement {
</div>`
: html`<ha-alert alert-type="warning">
${!window.isSecureContext
? this._localize("ui.components.qr-scanner.only_https_supported")
: this._localize("ui.components.qr-scanner.not_supported")}
? this.hass.localize(
"ui.components.qr-scanner.only_https_supported"
)
: this.hass.localize("ui.components.qr-scanner.not_supported")}
</ha-alert>
<p>${this._localize("ui.components.qr-scanner.manual_input")}</p>
<p>${this.hass.localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row">
<ha-input
.label=${this._localize("ui.components.qr-scanner.enter_qr_code")}
.label=${this.hass.localize(
"ui.components.qr-scanner.enter_qr_code"
)}
@keyup=${this._manualKeyup}
@paste=${this._manualPaste}
></ha-input>
<ha-button @click=${this._manualSubmit}>
${this._localize("ui.common.submit")}
${this.hass.localize("ui.common.submit")}
</ha-button>
</div>`}`;
}
private get _nativeBarcodeScanner(): boolean {
return Boolean(this._config.auth.external?.config.hasBarCodeScanner);
return Boolean(this.hass.auth.external?.config.hasBarCodeScanner);
}
private async _loadQrScanner() {
@@ -187,7 +182,7 @@ class HaQrScanner extends LitElement {
const QrScanner = (await import("qr-scanner")).default;
if (!(await QrScanner.hasCamera())) {
this._reportError(
this._localize("ui.components.qr-scanner.no_camera_found")
this.hass.localize("ui.components.qr-scanner.no_camera_found")
);
return;
}
@@ -275,7 +270,7 @@ class HaQrScanner extends LitElement {
if (msg.command === "bar_code/scan_result") {
if (msg.payload.format !== "qr_code") {
this._notifyExternalScanner(
this._localize("ui.components.qr-scanner.wrong_code", {
this.hass.localize("ui.components.qr-scanner.wrong_code", {
format: msg.payload.format,
rawValue: msg.payload.rawValue,
})
@@ -293,17 +288,20 @@ class HaQrScanner extends LitElement {
}
return true;
});
this._config.auth.external!.fireMessage({
this.hass.auth.external!.fireMessage({
type: "bar_code/scan",
payload: {
title:
this.title || this._localize("ui.components.qr-scanner.app.title"),
this.title ||
this.hass.localize("ui.components.qr-scanner.app.title"),
description:
this.description ||
this._localize("ui.components.qr-scanner.app.description"),
this.hass.localize("ui.components.qr-scanner.app.description"),
alternative_option_label:
this.alternativeOptionLabel ||
this._localize("ui.components.qr-scanner.app.alternativeOptionLabel"),
this.hass.localize(
"ui.components.qr-scanner.app.alternativeOptionLabel"
),
},
});
}
@@ -311,7 +309,7 @@ class HaQrScanner extends LitElement {
private _closeExternalScanner() {
this._removeListener?.();
this._removeListener = undefined;
this._config.auth.external!.fireMessage({
this.hass.auth.external!.fireMessage({
type: "bar_code/close",
});
}
@@ -320,7 +318,7 @@ class HaQrScanner extends LitElement {
if (!this._nativeBarcodeScanner) {
return;
}
this._config.auth.external!.fireMessage({
this.hass.auth.external!.fireMessage({
type: "bar_code/notify",
payload: {
message,
+8 -20
View File
@@ -12,8 +12,6 @@ import type { HomeAssistantInternationalization } from "../types";
class HaRelativeTime extends ReactiveElement {
@property({ attribute: false }) public datetime?: string | Date;
@property() public format: Intl.RelativeTimeFormatStyle = "long";
@property({ type: Boolean }) public capitalize = false;
@state()
@@ -38,15 +36,13 @@ class HaRelativeTime extends ReactiveElement {
return this;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._updateRelative();
}
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (changedProps.has("datetime")) {
if (this.datetime) {
this._startInterval();
} else {
this._clearInterval();
}
}
this._updateRelative();
}
@@ -70,23 +66,15 @@ class HaRelativeTime extends ReactiveElement {
}
if (!this.datetime) {
this.textContent = this._i18n.localize(
"ui.components.relative_time.never"
);
this.innerHTML = this._i18n.localize("ui.components.relative_time.never");
} else {
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(
date,
this._i18n.locale,
undefined,
true,
this.format
);
this.textContent = this.capitalize
const relTime = relativeTime(date, this._i18n.locale);
this.innerHTML = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
}
@@ -86,10 +86,7 @@ export class HaDateTimeSelector extends LitElement {
static styles = css`
.input {
display: flex;
/* Align the input fields by their top edge so the date field's underline
lines up with the time field, since ha-date-input reserves extra space
below for its hint while ha-time-input does not. */
align-items: flex-start;
align-items: center;
flex-direction: row;
}
@@ -37,6 +37,7 @@ export class HaFileSelector extends LitElement {
protected render() {
return html`
<ha-file-upload
.hass=${this.hass}
.accept=${this.selector.file?.accept}
.icon=${mdiFile}
.label=${this.label}
+13 -37
View File
@@ -1,8 +1,6 @@
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { entityIcon } from "../../data/icons";
import type { IconSelector } from "../../data/selector";
@@ -30,45 +28,23 @@ export class HaIconSelector extends LitElement {
icon_entity?: string;
};
private get _stateObj(): HassEntity | undefined {
const iconEntity = this.context?.icon_entity;
return iconEntity ? this.hass.states[iconEntity] : undefined;
}
private _placeholderTask = new AsyncValueTask(this, {
task: ([
placeholder,
attributeIcon,
entities,
config,
connection,
stateObj,
]) => {
if (placeholder || attributeIcon || !stateObj) {
return initialState;
}
return entityIcon(entities, config, connection, stateObj);
},
args: () => {
const stateObj = this._stateObj;
return [
this.selector.icon?.placeholder,
stateObj?.attributes.icon,
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj,
] as const;
},
});
protected render() {
const stateObj = this._stateObj;
const iconEntity = this.context?.icon_entity;
const stateObj = iconEntity ? this.hass.states[iconEntity] : undefined;
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj && this._placeholderTask.value);
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
return html`
<ha-icon-picker
@@ -23,6 +23,7 @@ export class HaThemeSelector extends LitElement {
protected render() {
return html`
<ha-theme-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -1,32 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-time-format-picker";
@customElement("ha-selector-ui_time_format")
export class HaSelectorUiTimeFormat extends LitElement {
@property() public value?: string;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-time-format-picker
.label=${this.label}
.value=${this.value}
.helper=${this.helper}
.disabled=${this.disabled}
>
</ha-time-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_time_format": HaSelectorUiTimeFormat;
}
}
@@ -67,7 +67,6 @@ const LOAD_ELEMENTS = {
ui_action: () => import("./ha-selector-ui-action"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
};
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+11 -19
View File
@@ -1,9 +1,8 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -35,17 +34,6 @@ export class HaServiceIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service]) => {
if (icon || !connection || !config || !service) {
return initialState;
}
return serviceIcon(connection, config, service);
},
args: () =>
[this.icon, this._connection, this._config, this.service] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -59,12 +47,16 @@ export class HaServiceIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = serviceIcon(this._connection, this._config, this.service).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
}
private _renderFallback() {
+14 -25
View File
@@ -1,9 +1,8 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { serviceSectionIcon } from "../data/icons";
@@ -32,23 +31,6 @@ export class HaServiceSectionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service, section]) => {
if (icon || !connection || !config || !service || !section) {
return initialState;
}
return serviceSectionIcon(connection, config, service, section);
},
args: () =>
[
this.icon,
this._connection,
this._config,
this.service,
this.section,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -62,12 +44,19 @@ export class HaServiceSectionIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = serviceSectionIcon(
this._connection,
this._config,
this.service,
this.section
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+17 -47
View File
@@ -1,9 +1,8 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { until } from "lit/directives/until";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
@@ -38,47 +37,11 @@ export class HaStateIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
private get _overrideIcon(): string | undefined {
return (
protected render() {
const overrideIcon =
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon
);
}
private _iconTask = new AsyncValueTask(this, {
task: ([
overrideIcon,
entities,
config,
connection,
stateObj,
stateValue,
]) => {
if (overrideIcon || !entities || !config || !connection || !stateObj) {
return initialState;
}
return entityIcon(
entities,
config.config,
connection.connection,
stateObj,
stateValue
);
},
args: () =>
[
this._overrideIcon,
this._entities,
this._config,
this._connection,
this.stateObj,
this.stateValue,
] as const,
});
protected render() {
const overrideIcon = this._overrideIcon;
this.stateObj?.attributes.icon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
}
@@ -88,12 +51,19 @@ export class HaStateIcon extends LitElement {
if (!this._config || !this._connection || !this._entities) {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
+1
View File
@@ -1233,6 +1233,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: type === "device" && (item as DevicePickerItem).domain
+7 -15
View File
@@ -1,12 +1,10 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { internationalizationContext, uiContext } from "../data/context";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
@@ -25,13 +23,7 @@ export class HaThemePicker extends LitElement {
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui?: ContextType<typeof uiContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -64,8 +56,8 @@ export class HaThemePicker extends LitElement {
private _getItems = () =>
this._getThemeOptions(
this._ui?.themes.themes || {},
this._i18n?.locale.language || "en",
this.hass?.themes.themes || {},
this.hass?.locale.language || "en",
this.includeDefault
);
@@ -78,10 +70,10 @@ export class HaThemePicker extends LitElement {
return html`
<ha-generic-picker
.label=${this.label ??
this._i18n?.localize("ui.components.theme-picker.theme") ??
this.hass?.localize("ui.components.theme-picker.theme") ??
"Theme"}
.placeholder=${this.noThemeLabel ??
this._i18n?.localize("ui.components.theme-picker.no_theme")}
this.hass?.localize("ui.components.theme-picker.no_theme")}
.helper=${this.helper}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
+1
View File
@@ -73,6 +73,7 @@ export class HaThemeSettings extends LitElement {
${this.showThemePicker
? html`
<ha-theme-picker
.hass=${this.hass}
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
-136
View File
@@ -1,136 +0,0 @@
import memoizeOne from "memoize-one";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import "./ha-select";
import type { TimestampRenderingFormat } from "../panels/lovelace/components/types";
import { TIMESTAMP_RENDERING_FORMATS } from "../panels/lovelace/components/types";
@customElement("ha-time-format-picker")
export class HaTimeFormatPicker extends LitElement {
@property() public value?: TimestampRenderingFormat;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
private _options = memoizeOne((localize: LocalizeFunc) =>
[{ label: localize("ui.common.auto"), value: "auto" }].concat(
TIMESTAMP_RENDERING_FORMATS.map((format) => ({
label:
localize(`ui.components.time-format-picker.formats.${format}`) ||
format,
value: format,
}))
)
);
private _styleOptions = memoizeOne((localize: LocalizeFunc) => [
{ label: localize("ui.common.auto"), value: "auto" },
{
label: localize("ui.components.time-format-picker.styles.short"),
value: "short",
},
{
label: localize("ui.components.time-format-picker.styles.long"),
value: "long",
},
]);
protected render() {
const type = typeof this.value === "object" ? this.value.type : this.value;
const style = typeof this.value === "object" ? this.value.style : undefined;
return html`
<div class="row">
<ha-select
.label=${this.label ?? ""}
.value=${type || "auto"}
.helper=${this.helper ?? ""}
.disabled=${this.disabled}
@selected=${this._selectChanged}
.options=${this._options(this._localize)}
>
</ha-select>
${this.value
? html`
<ha-select
.label=${this._localize(
"ui.components.time-format-picker.style"
)}
.value=${style || "auto"}
.disabled=${this.disabled}
@selected=${this._styleChanged}
.options=${this._styleOptions(this._localize)}
>
</ha-select>
`
: nothing}
</div>
`;
}
private _selectChanged(ev) {
ev.stopPropagation();
if (ev.detail?.value === "auto" && this.value !== undefined) {
fireEvent(this, "value-changed", {
value: undefined,
});
return;
}
if (this.value && typeof this.value === "object" && this.value.style) {
fireEvent(this, "value-changed", {
value: {
type: ev.detail.value,
style: this.value.style,
},
});
return;
}
fireEvent(this, "value-changed", {
value: ev.detail.value,
});
}
private _styleChanged(ev) {
ev.stopPropagation();
const type = typeof this.value === "object" ? this.value.type : this.value;
if (ev.detail?.value === "auto") {
fireEvent(this, "value-changed", {
value: type,
});
return;
}
fireEvent(this, "value-changed", {
value: {
type: type,
style: ev.detail.value,
},
});
}
static styles = css`
.row {
display: flex;
gap: 12px;
}
.row > * {
flex: 1;
min-width: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-time-format-picker": HaTimeFormatPicker;
}
}
+6 -34
View File
@@ -13,40 +13,12 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
// though both are valid IANA identifiers and common server defaults. Without
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
];
// google-timezones-json also ships an invalid IANA identifier. Correct it so
// the zone can be selected (the backend rejects the invalid id).
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
};
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
const options: PickerComboBoxItem[] = Object.entries(
timezones as Record<string, string>
).map(([key, value]) => {
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
return {
id,
primary: value,
secondary: id,
};
});
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
export const getTimezoneOptions = (): PickerComboBoxItem[] =>
Object.entries(timezones as Record<string, string>).map(([key, value]) => ({
id: key,
primary: value,
secondary: key,
}));
@customElement("ha-timezone-picker")
export class HaTimeZonePicker extends LitElement {
-6
View File
@@ -2,7 +2,6 @@ import {
mdiAlertCircleOutline,
mdiCheckCircleOutline,
mdiChevronDown,
mdiCircleOffOutline,
mdiHelpCircleOutline,
mdiProgressClock,
mdiProgressWrench,
@@ -85,11 +84,6 @@ class HaTracePicker extends LitElement {
"ui.panel.config.automation.trace.picker.debugged"
);
item.icon_path = mdiProgressWrench;
} else if (trace.not_triggered) {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.not_triggered"
);
item.icon_path = mdiCircleOffOutline;
} else if (trace.script_execution === "finished") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.finished",
+11 -19
View File
@@ -18,11 +18,10 @@ import {
mdiWebhook,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -72,17 +71,6 @@ export class HaTriggerIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, trigger]) => {
if (icon || !connection || !config || !trigger) {
return initialState;
}
return triggerIcon(connection, config, trigger);
},
args: () =>
[this.icon, this._connection, this._config, this.trigger] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -96,12 +84,16 @@ export class HaTriggerIcon extends LitElement {
return this._renderFallback();
}
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
const icon = triggerIcon(this._connection, this._config, this.trigger).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
}
private _renderFallback() {
+11 -17
View File
@@ -1,12 +1,9 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext } from "../data/context";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-button";
const STATES_INTERCEPTABLE: Record<
@@ -49,10 +46,7 @@ const STATES_INTERCEPTABLE: Record<
@customElement("ha-vacuum-state")
export class HaVacuumState extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity;
@@ -74,19 +68,19 @@ export class HaVacuumState extends LitElement {
}
private _computeInterceptable(
stateString: string,
state: string,
supportedFeatures: number | undefined
) {
return stateString in STATES_INTERCEPTABLE && supportedFeatures !== 0;
return state in STATES_INTERCEPTABLE && supportedFeatures !== 0;
}
private _computeLabel(stateString: string, interceptable: boolean) {
private _computeLabel(state: string, interceptable: boolean) {
return interceptable
? this._localize(
`ui.card.vacuum.actions.${STATES_INTERCEPTABLE[stateString].action}`
? this.hass.localize(
`ui.card.vacuum.actions.${STATES_INTERCEPTABLE[state].action}`
)
: this._localize(
`component.vacuum.entity_component._.state.${stateString}`
: this.hass.localize(
`component.vacuum.entity_component._.state.${state}`
);
}
@@ -94,7 +88,7 @@ export class HaVacuumState extends LitElement {
ev.stopPropagation();
const stateObj = this.stateObj;
const service = STATES_INTERCEPTABLE[stateObj.state].service;
await this._api.callService("vacuum", service, {
await this.hass.callService("vacuum", service, {
entity_id: stateObj.entity_id,
});
}
+15 -39
View File
@@ -1,39 +1,14 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { customElement, property } from "lit/decorators";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../common/decorators/transform";
import type { HassEntity } from "home-assistant-js-websocket";
import { formatNumber } from "../common/number/format_number";
import {
configContext,
formattersContext,
internationalizationContext,
} from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import { haStyle } from "../resources/styles";
import type {
HomeAssistantConfig,
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../types";
import type { HomeAssistant } from "../types";
@customElement("ha-water_heater-state")
export class HaWaterHeaterState extends LitElement {
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: HomeAssistantFormatters;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: HomeAssistantConfig;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity;
@@ -41,16 +16,17 @@ export class HaWaterHeaterState extends LitElement {
return html`
<div class="target">
<span class="state-label label">
${this._formatters?.formatEntityState(this.stateObj)}
${this.hass.formatEntityState(this.stateObj)}
</span>
<span class="label">${this._computeTarget()}</span>
<span class="label"
>${this._computeTarget(this.hass, this.stateObj)}</span
>
</div>
`;
}
private _computeTarget() {
if (!this._locale || !this._hassConfig || !this.stateObj) return null;
const stateObj = this.stateObj;
private _computeTarget(hass: HomeAssistant, stateObj: HassEntity) {
if (!hass || !stateObj) return null;
// We're using "!= null" on purpose so that we match both null and undefined.
if (
@@ -59,17 +35,17 @@ export class HaWaterHeaterState extends LitElement {
) {
return `${formatNumber(
stateObj.attributes.target_temp_low,
this._locale
this.hass.locale
)} ${formatNumber(
stateObj.attributes.target_temp_high,
this._locale
)} ${this._hassConfig.config.unit_system.temperature}`;
this.hass.locale
)} ${hass.config.unit_system.temperature}`;
}
if (stateObj.attributes.temperature != null) {
return `${formatNumber(
stateObj.attributes.temperature,
this._locale
)} ${this._hassConfig.config.unit_system.temperature}`;
this.hass.locale
)} ${hass.config.unit_system.temperature}`;
}
return "";
+8 -18
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
@@ -14,7 +13,7 @@ import {
webRtcOffer,
type WebRtcOfferEvent,
} from "../data/camera";
import { apiContext, connectionContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-alert";
/**
@@ -24,13 +23,7 @@ import "./ha-alert";
*/
@customElement("ha-web-rtc-player")
class HaWebRtcPlayer extends LitElement {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entityid?: string;
@@ -137,7 +130,7 @@ class HaWebRtcPlayer extends LitElement {
return;
}
if (!this._api || !this._connection || !this.entityid) {
if (!this.hass || !this.entityid) {
return;
}
@@ -148,7 +141,7 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("start clientConfig");
this._clientConfig = await fetchWebRtcClientConfiguration(
this._api,
this.hass,
this.entityid
);
@@ -237,11 +230,8 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("start webRtcOffer", offer_sdp);
try {
this._unsub = webRtcOffer(
this._connection,
this.entityid,
offer_sdp,
(event) => this._handleOfferEvent(event)
this._unsub = webRtcOffer(this.hass, this.entityid, offer_sdp, (event) =>
this._handleOfferEvent(event)
);
} catch (err: any) {
this._error = "Failed to start WebRTC stream: " + err.message;
@@ -267,7 +257,7 @@ class HaWebRtcPlayer extends LitElement {
this._sessionId = event.session_id;
this._candidatesList.forEach((candidate) =>
addWebRtcCandidate(
this._api,
this.hass,
this.entityid!,
event.session_id,
// toJSON returns RTCIceCandidateInit
@@ -320,7 +310,7 @@ class HaWebRtcPlayer extends LitElement {
if (this._sessionId) {
addWebRtcCandidate(
this._api,
this.hass,
this.entityid,
this._sessionId,
// toJSON returns RTCIceCandidateInit
+7 -8
View File
@@ -1,18 +1,15 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import type { HomeAssistant } from "../../types";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-state-icon";
@customElement("ha-entity-marker")
class HaEntityMarker extends LitElement {
@property({ attribute: "entity-id", reflect: true }) public entityId?: string;
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@property({ attribute: "entity-id", reflect: true }) public entityId?: string;
@property({ attribute: "entity-name" }) public entityName?: string;
@@ -39,7 +36,9 @@ class HaEntityMarker extends LitElement {
})}
></div>`
: this.showIcon && this.entityId
? html`<ha-state-icon .stateObj=${this._stateObj}></ha-state-icon>`
? html`<ha-state-icon
.stateObj=${this.hass?.states[this.entityId]}
></ha-state-icon>`
: !this.entityUnit
? this.entityName
: html`
@@ -128,6 +128,7 @@ export class HaLocationsEditor extends LitElement {
protected render(): TemplateResult {
return html`
<ha-map
.hass=${this.hass}
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
+33 -74
View File
@@ -1,6 +1,4 @@
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type {
Circle,
CircleMarker,
@@ -20,7 +18,6 @@ import {
formatTimeWeekday,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
@@ -29,22 +26,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import {
configContext,
connectionContext,
formattersContext,
internationalizationContext,
statesContext,
uiContext,
} from "../../data/context";
import type {
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantFormatters,
HomeAssistantInternationalization,
HomeAssistantUI,
ThemeMode,
} from "../../types";
import type { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
@@ -94,32 +76,7 @@ export interface HaMapEntity {
@customElement("ha-map")
export class HaMap extends ReactiveElement {
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HassEntities;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui!: HomeAssistantUI;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: HomeAssistantConnection;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entities?: string[] | HaMapEntity[];
@@ -218,16 +175,17 @@ export class HaMap extends ReactiveElement {
return;
}
let autoFitRequired = false;
const oldStates = changedProps.get("_states") as HassEntities | undefined;
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (changedProps.has("_loaded") || changedProps.has("entities")) {
this._drawEntities();
autoFitRequired = !this._pauseAutoFit;
} else if (this._loaded && oldStates && this.entities) {
} else if (this._loaded && oldHass && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
if (
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
oldHass.states[getEntityId(entity)] !==
this.hass!.states[getEntityId(entity)]
) {
this._drawEntities();
autoFitRequired = !this._pauseAutoFit;
@@ -261,11 +219,10 @@ export class HaMap extends ReactiveElement {
}, PROGRAMMITIC_FIT_DELAY);
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") ||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
(!changedProps.has("hass") ||
(oldHass && oldHass.themes?.darkMode === this.hass.themes?.darkMode))
) {
return;
}
@@ -276,7 +233,7 @@ export class HaMap extends ReactiveElement {
private get _darkMode() {
return (
this.themeMode === "dark" ||
(this.themeMode === "auto" && Boolean(this._ui?.themes.darkMode))
(this.themeMode === "auto" && Boolean(this.hass.themes.darkMode))
);
}
@@ -301,8 +258,8 @@ export class HaMap extends ReactiveElement {
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
latitude: this.hass?.config.latitude ?? 52.3731339,
longitude: this.hass?.config.longitude ?? 4.8903147,
zoom: this.zoom,
});
this._updateMapStyle();
@@ -343,7 +300,7 @@ export class HaMap extends ReactiveElement {
if (options?.unpause_autofit) {
this._pauseAutoFit = false;
}
if (!this.leafletMap || !this.Leaflet || !this._config) {
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
@@ -354,7 +311,10 @@ export class HaMap extends ReactiveElement {
) {
this._isProgrammaticFit = true;
this.leafletMap.setView(
new this.Leaflet.LatLng(this._config.latitude, this._config.longitude),
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
options?.zoom || this.zoom
);
setTimeout(() => {
@@ -391,7 +351,7 @@ export class HaMap extends ReactiveElement {
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
if (!this.leafletMap || !this.Leaflet) {
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
@@ -422,31 +382,32 @@ export class HaMap extends ReactiveElement {
if (path.fullDatetime) {
formattedTime = formatDateTime(
point.timestamp,
this._i18n.locale,
this._config
this.hass.locale,
this.hass.config
);
} else if (isToday(point.timestamp)) {
formattedTime = formatTimeWithSeconds(
point.timestamp,
this._i18n.locale,
this._config
this.hass.locale,
this.hass.config
);
} else {
formattedTime = formatTimeWeekday(
point.timestamp,
this._i18n.locale,
this._config
this.hass.locale,
this.hass.config
);
}
return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
}
private _drawPaths(): void {
const hass = this.hass;
const map = this.leafletMap;
// eslint-disable-next-line @typescript-eslint/naming-convention
const Leaflet = this.Leaflet;
if (!this._i18n || !this._config || !map || !Leaflet) {
if (!hass || !map || !Leaflet) {
return;
}
if (this._mapPaths.length) {
@@ -574,12 +535,12 @@ export class HaMap extends ReactiveElement {
}
private _drawEntities(): void {
const states = this._states;
const hass = this.hass;
const map = this.leafletMap;
// eslint-disable-next-line @typescript-eslint/naming-convention
const Leaflet = this.Leaflet;
if (!states || !map || !Leaflet) {
if (!hass || !map || !Leaflet) {
return;
}
@@ -617,7 +578,7 @@ export class HaMap extends ReactiveElement {
const className = this._darkMode ? "dark" : "light";
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
const stateObj = hass.states[getEntityId(entity)];
if (!stateObj) {
continue;
}
@@ -630,7 +591,7 @@ export class HaMap extends ReactiveElement {
entity_picture: entityPicture,
} = stateObj.attributes;
const location = getEntityLocation(stateObj, states);
const location = getEntityLocation(stateObj, hass.states);
if (!location) {
continue;
}
@@ -687,14 +648,11 @@ export class HaMap extends ReactiveElement {
// create icon
const entityName =
typeof entity !== "string" && entity.label_mode === "state"
? this._formatters.formatEntityState(stateObj)
? this.hass.formatEntityState(stateObj)
: typeof entity !== "string" &&
entity.label_mode === "attribute" &&
entity.attribute !== undefined
? this._formatters.formatEntityAttributeValue(
stateObj,
entity.attribute
)
? this.hass.formatEntityAttributeValue(stateObj, entity.attribute)
: (customTitle ??
title
.split(" ")
@@ -703,6 +661,7 @@ export class HaMap extends ReactiveElement {
.substr(0, 3));
const entityMarker = document.createElement("ha-entity-marker");
entityMarker.hass = this.hass;
entityMarker.showIcon =
typeof entity !== "string" && entity.label_mode === "icon";
entityMarker.entityId = getEntityId(entity);
@@ -715,7 +674,7 @@ export class HaMap extends ReactiveElement {
: "";
entityMarker.entityPicture =
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this._connection.hassUrl(entityPicture)
? this.hass.hassUrl(entityPicture)
: "";
if (typeof entity !== "string") {
entityMarker.entityColor = entity.color;
@@ -101,6 +101,7 @@ class DialogMediaPlayerBrowse extends LitElement {
</span>
<ha-media-manage-button
slot="actionItems"
.hass=${this.hass}
.currentItem=${this._currentItem}
@media-refresh=${this._refreshMedia}
></ha-media-manage-button>
@@ -1,16 +1,13 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFolderEdit } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext } from "../../data/context";
import type { MediaPlayerItem } from "../../data/media-player";
import {
isLocalMediaSourceContentId,
isImageUploadMediaSourceContentId,
} from "../../data/media_source";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HomeAssistant } from "../../types";
import "../ha-svg-icon";
import "../ha-button";
import { showMediaManageDialog } from "./show-media-manage-dialog";
@@ -23,13 +20,7 @@ declare global {
@customElement("ha-media-manage-button")
class MediaManageButton extends LitElement {
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) currentItem?: MediaPlayerItem;
@@ -40,7 +31,7 @@ class MediaManageButton extends LitElement {
!this.currentItem ||
!(
isLocalMediaSourceContentId(this.currentItem.media_content_id || "") ||
(this._config.user?.is_admin &&
(this.hass!.user?.is_admin &&
isImageUploadMediaSourceContentId(this.currentItem.media_content_id))
)
) {
@@ -49,7 +40,9 @@ class MediaManageButton extends LitElement {
return html`
<ha-button appearance="filled" size="s" @click=${this._manage}>
<ha-svg-icon .path=${mdiFolderEdit} slot="start"></ha-svg-icon>
${this._localize("ui.components.media-browser.file_management.manage")}
${this.hass.localize(
"ui.components.media-browser.file_management.manage"
)}
</ha-button>
`;
}
+1
View File
@@ -26,6 +26,7 @@ export class HaTraceLogbook extends LitElement {
return this.logbookEntries.length
? html`
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
+5 -27
View File
@@ -17,10 +17,9 @@ import type {
ChooseActionTraceStep,
TraceExtended,
} from "../../data/trace";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import { getDataFromPath } from "../../data/trace";
import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-code-editor";
import "../ha-icon-button";
import "../ha-tab-group";
@@ -34,12 +33,6 @@ const TRACE_PATH_TABS = [
"logbook",
] as const;
// A repeat keeps only its last iterations, so the array index is not the real
// one. Use the recorded repeat.index when we have it.
const iterationNumber = (trace: ActionTraceStep, index: number): number =>
(trace.changed_variables?.repeat as { index?: number } | undefined)?.index ??
index + 1;
@customElement("ha-trace-path-details")
export class HaTracePathDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -70,7 +63,7 @@ export class HaTracePathDetails extends LitElement {
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
${this._renderNotTriggeredNotice()} ${this._renderSelectedTraceInfo()}
${this._renderSelectedTraceInfo()}
</div>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
@@ -96,22 +89,6 @@ export class HaTracePathDetails extends LitElement {
`;
}
private _renderNotTriggeredNotice() {
if (
!this.trace.not_triggered ||
!this.selected?.path ||
!isTriggerPath(this.selected.path) ||
!(this.selected.path in this.trace.trace)
) {
return nothing;
}
return html`<ha-alert alert-type="info">
${this.hass!.localize(
"ui.panel.config.automation.trace.path.not_triggered"
)}
</ha-alert>`;
}
private _renderSelectedTraceInfo() {
const paths = this.trace.trace;
@@ -237,7 +214,7 @@ export class HaTracePathDetails extends LitElement {
: html`<h3>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: iterationNumber(trace, idx) }
{ number: idx + 1 }
)}
</h3>`}
${curPath
@@ -341,7 +318,7 @@ export class HaTracePathDetails extends LitElement {
? html`<p>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: iterationNumber(trace, idx) }
{ number: idx + 1 }
)}
</p>`
: ""}
@@ -411,6 +388,7 @@ export class HaTracePathDetails extends LitElement {
return entries.length
? html`
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}

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