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
156 changed files with 6546 additions and 3643 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/
+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,
+13 -1
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",
@@ -137,9 +146,11 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@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",
@@ -158,6 +169,7 @@
"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",
-9
View File
@@ -110,15 +110,6 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
"media_player",
]);
/** Domains that use a timestamp for state. */
export const TIMESTAMP_STATE_DOMAINS = [
"button",
"infrared",
"input_button",
"radio_frequency",
"scene",
];
/** Temperature units. */
export const UNIT_C = "°C";
export const UNIT_F = "°F";
+4 -5
View File
@@ -3,20 +3,19 @@ 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",
@@ -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);
+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(" ");
};
-48
View File
@@ -1,48 +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 { 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 { 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,
climate: ClimateEntityFeature,
conversation: ConversationEntityFeature,
cover: CoverEntityFeature,
fan: FanEntityFeature,
humidifier: HumidifierEntityFeature,
lawn_mower: LawnMowerEntityFeature,
light: LightEntityFeature,
lock: LockEntityFeature,
media_player: MediaPlayerEntityFeature,
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;
}
+72 -84
View File
@@ -290,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,
@@ -377,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)];
+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}`
@@ -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);
}
`;
}
+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) {
+20 -46
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"
)}
>
@@ -417,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>`,
}
@@ -469,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 =
@@ -565,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) {
@@ -576,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 = () => {
@@ -596,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);
@@ -619,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;
+6 -24
View File
@@ -1,20 +1,16 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
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;
@@ -23,18 +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>;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -44,14 +28,12 @@ export class HaAttributeIcon extends LitElement {
return nothing;
}
if (!this._config || !this._connection || !this._entities) {
if (!this.hass) {
return nothing;
}
const icon = attributeIcon(
this._config.config,
this._connection.connection,
this._entities,
this.hass,
this.stateObj,
this.attribute,
this.attributeValue
-22
View File
@@ -4,8 +4,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { getValueAttribute } from "../common/entity/get_states";
import { formattersContext } from "../data/context";
@customElement("ha-attribute-value")
@@ -58,26 +56,6 @@ class HaAttributeValue extends LitElement {
return html`<pre>${until(yaml, "")}</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 (this.hideUnit) {
const parts = this._formatters!.formatEntityAttributeValueToParts(
this.stateObj!,
+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";
+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;
}
@@ -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"]);
-67
View File
@@ -1,67 +0,0 @@
import memoizeOne from "memoize-one";
import { html, LitElement } 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 { TIMESTAMP_RENDERING_FORMATS } from "../panels/lovelace/components/types";
@customElement("ha-time-format-picker")
export class HaTimeFormatPicker extends LitElement {
@property() public value?: string;
@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,
}))
)
);
protected render() {
return html`
<ha-select
.label=${this.label ?? ""}
.value=${this.value || "auto"}
.helper=${this.helper ?? ""}
.disabled=${this.disabled}
@selected=${this._selectChanged}
.options=${this._options(this._localize)}
>
</ha-select>
`;
}
private _selectChanged(ev) {
ev.stopPropagation();
if (ev.detail?.value === "auto" && this.value !== undefined) {
fireEvent(this, "value-changed", {
value: undefined,
});
return;
}
fireEvent(this, "value-changed", {
value: ev.detail.value,
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-time-format-picker": HaTimeFormatPicker;
}
}
+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}
@@ -388,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}
+2 -2
View File
@@ -18,7 +18,7 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerSource } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -333,7 +333,7 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: localizeTriggerSource(
trigger: localizeTriggerDescription(
this.hass.localize,
this.trace.trigger
),
+1 -1
View File
@@ -1,7 +1,7 @@
import type { HomeAssistant } from "../types";
import type { Selector } from "./selector";
export enum AITaskEntityFeature {
export const enum AITaskEntityFeature {
GENERATE_DATA = 1,
SUPPORT_ATTACHMENTS = 2,
GENERATE_IMAGE = 4,
+1 -1
View File
@@ -18,7 +18,7 @@ import { getExtendedEntityRegistryEntry } from "./entity/entity_registry";
export const FORMAT_TEXT = "text";
export const FORMAT_NUMBER = "number";
export enum AlarmControlPanelEntityFeature {
export const enum AlarmControlPanelEntityFeature {
ARM_HOME = 1,
ARM_AWAY = 2,
ARM_NIGHT = 4,
+2 -5
View File
@@ -338,7 +338,7 @@ export const runDebugAssistPipeline = (
};
export const runAssistPipeline = (
hass: Pick<HomeAssistant, "connection">,
hass: HomeAssistant,
callback: (event: PipelineRunEvent) => void,
options: PipelineRunOptions
) =>
@@ -379,10 +379,7 @@ export const listAssistPipelines = (hass: HomeAssistant) =>
type: "assist_pipeline/pipeline/list",
});
export const getAssistPipeline = (
hass: Pick<HomeAssistant, "callWS">,
pipeline_id?: string
) =>
export const getAssistPipeline = (hass: HomeAssistant, pipeline_id?: string) =>
hass.callWS<AssistPipeline>({
type: "assist_pipeline/pipeline/get",
pipeline_id,
+1 -1
View File
@@ -3,7 +3,7 @@ import { supportsFeature } from "../common/entity/supports-feature";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
export enum AssistSatelliteEntityFeature {
export const enum AssistSatelliteEntityFeature {
ANNOUNCE = 1,
}
+1 -1
View File
@@ -377,7 +377,7 @@ export const expandConditionWithShorthand = (
};
export const triggerAutomationActions = (
hass: Pick<HomeAssistant, "callService">,
hass: HomeAssistant,
entityId: string
) => {
hass.callService("automation", "trigger", {
+1 -1
View File
@@ -54,7 +54,7 @@ export enum RecurrenceRange {
THISANDFUTURE = "THISANDFUTURE",
}
export enum CalendarEntityFeature {
export const enum CalendarEntityFeature {
CREATE_EVENT = 1,
DELETE_EVENT = 2,
UPDATE_EVENT = 4,
+1 -1
View File
@@ -68,7 +68,7 @@ export type ClimateEntity = HassEntityBase & {
};
};
export enum ClimateEntityFeature {
export const enum ClimateEntityFeature {
TARGET_TEMPERATURE = 1,
TARGET_TEMPERATURE_RANGE = 2,
TARGET_HUMIDITY = 4,
+1 -1
View File
@@ -1,7 +1,7 @@
import { ensureArray } from "../common/array/ensure-array";
import type { HomeAssistant } from "../types";
export enum ConversationEntityFeature {
export const enum ConversationEntityFeature {
CONTROL = 1,
}
+4 -4
View File
@@ -4,10 +4,10 @@ import type {
} from "home-assistant-js-websocket";
import { stateActive } from "../common/entity/state_active";
import { supportsFeature } from "../common/entity/supports-feature";
import type { HomeAssistantFormatters } from "../types";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
export enum CoverEntityFeature {
export const enum CoverEntityFeature {
OPEN = 1,
CLOSE = 2,
SET_POSITION = 4,
@@ -122,7 +122,7 @@ export interface CoverEntity extends HassEntityBase {
export function computeCoverPositionStateDisplay(
stateObj: CoverEntity,
formatEntityAttributeValue: HomeAssistantFormatters["formatEntityAttributeValue"],
hass: HomeAssistant,
position?: number
) {
const statePosition = stateActive(stateObj)
@@ -133,7 +133,7 @@ export function computeCoverPositionStateDisplay(
const currentPosition = position ?? statePosition;
return currentPosition && currentPosition !== 100
? formatEntityAttributeValue(
? hass.formatEntityAttributeValue(
stateObj,
// Always use position as it's the same formatting as tilt position
"current_position",
+3 -3
View File
@@ -1,14 +1,14 @@
import type { HassEntityBase } from "home-assistant-js-websocket";
import type { HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export const stateToIsoDateString = (entityState: HassEntityBase) =>
`${entityState}T00:00:00`;
export const setDateValue = (
callService: HomeAssistantApi["callService"],
hass: HomeAssistant,
entityId: string,
date: string | undefined = undefined
) => {
const param = { entity_id: entityId, date };
callService("date", "set_value", param);
hass.callService("date", "set_value", param);
};
+3 -3
View File
@@ -1,11 +1,11 @@
import type { HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export const setDateTimeValue = (
callService: HomeAssistantApi["callService"],
hass: HomeAssistant,
entityId: string,
datetime: Date
) => {
callService("datetime", "set_value", {
hass.callService("datetime", "set_value", {
entity_id: entityId,
datetime: datetime.toISOString(),
});
+1 -1
View File
@@ -277,7 +277,7 @@ export const getExtendedEntityRegistryEntries = (
});
export const updateEntityRegistryEntry = (
hass: Pick<HomeAssistant, "callWS">,
hass: HomeAssistant,
entityId: string,
updates: Partial<EntityRegistryEntryUpdateParams>
): Promise<UpdateEntityRegistryEntryResult> =>
+1 -1
View File
@@ -12,7 +12,7 @@ import type {
import { stateActive } from "../common/entity/state_active";
import type { HomeAssistant } from "../types";
export enum FanEntityFeature {
export const enum FanEntityFeature {
SET_SPEED = 1,
OSCILLATE = 2,
DIRECTION = 4,
+1 -1
View File
@@ -20,7 +20,7 @@ export type HumidifierEntity = HassEntityBase & {
};
};
export enum HumidifierEntityFeature {
export const enum HumidifierEntityFeature {
MODES = 1,
}
+6 -8
View File
@@ -548,9 +548,7 @@ const getEntityIcon = async (
};
export const attributeIcon = async (
hassConfig: HomeAssistant["config"],
hassConnection: HomeAssistant["connection"],
entities: HomeAssistant["entities"],
hass: HomeAssistant,
state: HassEntity,
attribute: string,
attributeValue?: string
@@ -558,7 +556,7 @@ export const attributeIcon = async (
let icon: string | undefined;
const domain = computeStateDomain(state);
const deviceClass = state.attributes.device_class;
const entity = entities[state.entity_id] as
const entity = hass.entities?.[state.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
const platform = entity?.platform;
@@ -569,8 +567,8 @@ export const attributeIcon = async (
if (translation_key && platform) {
const platformIcons = await getPlatformIcons(
hassConfig,
hassConnection,
hass.config,
hass.connection,
platform
);
if (platformIcons) {
@@ -582,8 +580,8 @@ export const attributeIcon = async (
}
if (!icon) {
const entityComponentIcons = await getComponentIcons(
hassConnection,
hassConfig,
hass.connection,
hass.config,
domain
);
if (entityComponentIcons) {
+3 -3
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export interface InputDateTime {
id: string;
@@ -32,13 +32,13 @@ export const stateToIsoDateString = (entityState: HassEntity) =>
)}`;
export const setInputDateTimeValue = (
callService: HomeAssistantApi["callService"],
hass: HomeAssistant,
entityId: string,
time: string | undefined = undefined,
date: string | undefined = undefined
) => {
const param = { entity_id: entityId, time, date };
callService("input_datetime", "set_datetime", param);
hass.callService("input_datetime", "set_datetime", param);
};
export const fetchInputDateTime = (hass: HomeAssistant) =>
+1 -1
View File
@@ -11,7 +11,7 @@ export type LawnMowerEntityState =
| "docked"
| "error";
export enum LawnMowerEntityFeature {
export const enum LawnMowerEntityFeature {
START_MOWING = 1,
PAUSE = 2,
DOCK = 4,
+1 -1
View File
@@ -4,7 +4,7 @@ import type {
} from "home-assistant-js-websocket";
import { temperature2rgb } from "../common/color/convert-light-color";
export enum LightEntityFeature {
export const enum LightEntityFeature {
EFFECT = 4,
FLASH = 8,
TRANSITION = 32,
+1 -1
View File
@@ -7,7 +7,7 @@ import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
import { getExtendedEntityRegistryEntry } from "./entity/entity_registry";
export enum LockEntityFeature {
export const enum LockEntityFeature {
OPEN = 1,
}
+195 -78
View File
@@ -1,9 +1,15 @@
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import {
BINARY_STATE_OFF,
BINARY_STATE_ON,
DOMAINS_WITH_DYNAMIC_PICTURE,
} from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { autoCaseNoun } from "../common/translations/auto_case_noun";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE, UNKNOWN } from "./entity/entity";
import { isNumericEntity } from "./history";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
@@ -23,7 +29,7 @@ export interface LogbookEntry {
message?: string;
entity_id?: string;
icon?: string;
source?: string; // The trigger source (English phrase, parsed for the cause)
source?: string; // The trigger source
domain?: string;
state?: string; // The state of the entity
// Context data
@@ -44,27 +50,23 @@ export interface LogbookEntry {
// Localization mapping for all the triggers in core
// in homeassistant.components.homeassistant.triggers
//
// Keys are the bare translation keys under `ui.components.logbook`.
//
type TriggerPhraseKey =
| "numeric_state_of"
| "state_of"
| "event"
| "time_pattern"
| "time"
| "homeassistant_stopping"
| "homeassistant_starting";
type TriggerPhraseKeys =
| "triggered_by_numeric_state_of"
| "triggered_by_state_of"
| "triggered_by_event"
| "triggered_by_time"
| "triggered_by_time_pattern"
| "triggered_by_homeassistant_stopping"
| "triggered_by_homeassistant_starting";
// Order matters: "time pattern" must be tested before "time" because the
// source phrase is matched with `startsWith`.
const triggerPhrases: Record<TriggerPhraseKey, string> = {
numeric_state_of: "numeric state of", // number state trigger
state_of: "state of", // state trigger
event: "event", // event trigger
time_pattern: "time pattern", // time trigger
time: "time", // time trigger
homeassistant_stopping: "Home Assistant stopping", // stop event
homeassistant_starting: "Home Assistant starting", // start event
const triggerPhrases: Record<TriggerPhraseKeys, string> = {
triggered_by_numeric_state_of: "numeric state of", // number state trigger
triggered_by_state_of: "state of", // state trigger
triggered_by_event: "event", // event trigger
triggered_by_time_pattern: "time pattern", // time trigger
triggered_by_time: "time", // time trigger
triggered_by_homeassistant_stopping: "Home Assistant stopping", // stop event
triggered_by_homeassistant_starting: "Home Assistant starting", // start event
};
export const getLogbookDataForContext = async (
@@ -156,100 +158,215 @@ export const createHistoricState = (
state: state,
attributes: {
// Rebuild the historical state by copying static attributes only
device_class: currentStateObj.attributes.device_class,
unit_of_measurement: currentStateObj.attributes.unit_of_measurement,
state_class: currentStateObj.attributes.state_class,
options: currentStateObj.attributes.options,
source_type: currentStateObj.attributes.source_type,
has_date: currentStateObj.attributes.has_date,
has_time: currentStateObj.attributes.has_time,
device_class: currentStateObj?.attributes.device_class,
source_type: currentStateObj?.attributes.source_type,
has_date: currentStateObj?.attributes.has_date,
has_time: currentStateObj?.attributes.has_time,
// We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering,
// as they would present a false state in the log (played media right now vs actual historic data).
entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj.attributes.entity_picture_local,
: currentStateObj?.attributes.entity_picture_local,
entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj.attributes.entity_picture,
: currentStateObj?.attributes.entity_picture,
},
}) as unknown as HassEntity;
// Localize a backend trigger `source` phrase (e.g. "state of sensor.x") by
// translating the leading phrase while keeping the entity id. The automation
// trace timeline frames it with its own "triggered by" wording, so we only
// translate the bare description here.
export const localizeTriggerSource = (
localize: LocalizeFunc,
source: string
) => {
for (const key of Object.keys(triggerPhrases) as TriggerPhraseKey[]) {
const phrase = triggerPhrases[key];
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
return source.replace(phrase, localize(`ui.components.logbook.${key}`));
return source.replace(
phrase,
`${localize(`ui.components.logbook.${triggerPhraseKey}`)}`
);
}
}
return source;
};
export type TriggerPlatform =
| "state"
| "numeric_state"
// Mapping from a phrase key to the bare-phrase translation key (without the
// "triggered by" prefix), used by localizeTriggerDescription below.
const triggerDescriptionKeys: Record<
TriggerPhraseKeys,
| "numeric_state_of"
| "state_of"
| "event"
| "time"
| "time_pattern"
| "event"
| "homeassistant";
// Maps the English `triggerPhrases` to automation trigger platforms, so the
// feed can reuse the editor's trigger-type labels instead of dedicated strings.
const triggerPlatform: Record<TriggerPhraseKey, TriggerPlatform> = {
numeric_state_of: "numeric_state",
state_of: "state",
event: "event",
time_pattern: "time_pattern",
time: "time",
homeassistant_stopping: "homeassistant",
homeassistant_starting: "homeassistant",
| "homeassistant_stopping"
| "homeassistant_starting"
> = {
triggered_by_numeric_state_of: "numeric_state_of",
triggered_by_state_of: "state_of",
triggered_by_event: "event",
triggered_by_time_pattern: "time_pattern",
triggered_by_time: "time",
triggered_by_homeassistant_stopping: "homeassistant_stopping",
triggered_by_homeassistant_starting: "homeassistant_starting",
};
export interface ParsedTriggerSource {
platform?: TriggerPlatform;
entityId?: string;
}
// Best-effort parse of the backend's English trigger `source` (e.g. "numeric
// state of sensor.x", "time pattern") into a platform + triggering entity.
// Temporary bridge until the backend sends the trigger structurally.
export const parseTriggerSource = (source: string): ParsedTriggerSource => {
for (const key of Object.keys(triggerPhrases) as TriggerPhraseKey[]) {
const phrase = triggerPhrases[key];
if (!source.startsWith(phrase)) {
continue;
// Like localizeTriggerSource, but returns just the bare localized trigger
// description (without the "triggered by" prefix). Used where the surrounding
// template already supplies its own "triggered by" wording.
export const localizeTriggerDescription = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
return source.replace(
phrase,
`${localize(`ui.components.logbook.${bareKey}`)}`
);
}
const rest = source.slice(phrase.length).trim();
const entityId = /^[a-z_]+\.[a-z0-9_]+$/.test(rest) ? rest : undefined;
return { platform: triggerPlatform[key], entityId };
}
return {};
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
state: string,
stateObj: HassEntity,
domain: string
): string => {
// Events expose a timestamp as their state, which has no meaningful display
// value, so keep a dedicated phrase.
if (domain === "event") {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
switch (domain) {
case "device_tracker":
case "person":
if (state === "not_home") {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_away`);
}
if (state === "home") {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_home`);
}
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_state`, { state });
case "sun":
return state === "above_horizon"
? localize(`${LOGBOOK_LOCALIZE_PATH}.rose`)
: localize(`${LOGBOOK_LOCALIZE_PATH}.set`);
case "binary_sensor": {
const isOn = state === BINARY_STATE_ON;
const isOff = state === BINARY_STATE_OFF;
const device_class = stateObj.attributes.device_class;
if (device_class && (isOn || isOff)) {
return (
localize(
`${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_classes" : "cleared_device_classes"}.${device_class}`,
{
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
) || device_class,
hass.language
),
}
) ||
// If there's no key for a specific device class, fallback to generic string
localize(
`${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_class" : "cleared_device_class"}`,
{
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
) || device_class,
hass.language
),
}
)
);
}
break;
}
case "cover":
switch (state) {
case "open":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_opened`);
case "opening":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_opening`);
case "closing":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_closing`);
case "closed":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_closed`);
}
break;
case "event": {
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
// TODO: This is not working yet, as we don't get historic attribute values
const event_type = hass
.formatEntityAttributeValue(stateObj, "event_type")
?.toString();
if (!event_type) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_unknown_event`);
}
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event`, {
event_type: autoCaseNoun(event_type, hass.language),
});
}
case "lock":
switch (state) {
case "unlocked":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unlocked`);
case "locking":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_locking`);
case "unlocking":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_unlocking`);
case "opening":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_opening`);
case "open":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_opened`);
case "locked":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_locked`);
case "jammed":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_jammed`);
}
break;
}
// Every other domain reuses the backend state translation, so the logbook
// speaks the same vocabulary as the rest of the UI.
return hass.formatEntityState(stateObj, state);
if (state === BINARY_STATE_ON) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.turned_on`);
}
if (state === BINARY_STATE_OFF) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.turned_off`);
}
if (state === UNKNOWN) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.became_unknown`);
}
if (state === UNAVAILABLE) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.became_unavailable`);
}
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.changed_to_state`, {
state: stateObj ? hass.formatEntityState(stateObj, state) : state,
});
};
export const filterLogbookCompatibleEntities = (entity) => {
+1 -1
View File
@@ -82,7 +82,7 @@ export interface MediaPlayerEntity extends HassEntityBase {
| "buffering";
}
export enum MediaPlayerEntityFeature {
export const enum MediaPlayerEntityFeature {
PAUSE = 1,
SEEK = 2,
VOLUME_SET = 4,
+9 -30
View File
@@ -1,7 +1,6 @@
import type {
HassEntityAttributeBase,
HassEntityBase,
HassServices,
HassServiceTarget,
} from "home-assistant-js-websocket";
import type { Describe } from "superstruct";
@@ -105,9 +104,6 @@ export interface Field {
selector?: any;
}
const getScriptFields = (services: HassServices, entityId: string) =>
services.script[computeObjectId(entityId)]?.fields;
interface BaseAction {
alias?: string;
note?: string;
@@ -395,41 +391,31 @@ export const getActionType = (action: Action): ActionType => {
export const isAction = (value: unknown): value is Action =>
getActionType(value as Action) !== "unknown";
export const hasScriptFieldsForServices = (
services: HassServices,
entityId: string
): boolean => {
const fields = getScriptFields(services, entityId);
return fields !== undefined && Object.keys(fields).length > 0;
};
export const hasScriptFields = (
hass: HomeAssistant,
entityId: string
): boolean => hasScriptFieldsForServices(hass.services, entityId);
): boolean => {
const fields = hass.services.script[computeObjectId(entityId)]?.fields;
return fields !== undefined && Object.keys(fields).length > 0;
};
export const hasRequiredScriptFieldsForServices = (
services: HassServices,
export const hasRequiredScriptFields = (
hass: HomeAssistant,
entityId: string
): boolean => {
const fields = getScriptFields(services, entityId);
const fields = hass.services.script[computeObjectId(entityId)]?.fields;
return (
fields !== undefined &&
Object.values(fields).some((field) => field.required)
);
};
export const hasRequiredScriptFields = (
export const requiredScriptFieldsFilled = (
hass: HomeAssistant,
entityId: string
): boolean => hasRequiredScriptFieldsForServices(hass.services, entityId);
export const requiredScriptFieldsFilledForServices = (
services: HassServices,
entityId: string,
data?: Record<string, any>
): boolean => {
const fields = getScriptFields(services, entityId);
const fields = hass.services.script[computeObjectId(entityId)]?.fields;
if (fields === undefined || Object.keys(fields).length === 0) {
return true;
}
@@ -444,13 +430,6 @@ export const requiredScriptFieldsFilledForServices = (
});
};
export const requiredScriptFieldsFilled = (
hass: HomeAssistant,
entityId: string,
data?: Record<string, any>
): boolean =>
requiredScriptFieldsFilledForServices(hass.services, entityId, data);
export const migrateAutomationAction = (
action: Action | Action[]
): Action | Action[] => {
-5
View File
@@ -82,7 +82,6 @@ export type Selector =
| UiActionSelector
| UiColorSelector
| UiStateContentSelector
| UiTimeFormatSelector
| BackupLocationSelector;
export interface ActionSelector {
@@ -602,10 +601,6 @@ export interface UiStateContentSelector {
} | null;
}
export interface UiTimeFormatSelector {
ui_time_format: {} | null;
}
export interface EntityNameSelector {
entity_name: {
entity_id?: string;
+3 -3
View File
@@ -1,10 +1,10 @@
import type { HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export const setTimeValue = (
callService: HomeAssistantApi["callService"],
hass: HomeAssistant,
entityId: string,
time: string | undefined = undefined
) => {
const param = { entity_id: entityId, time: time };
callService("time", "set_value", param);
hass.callService("time", "set_value", param);
};
+1 -1
View File
@@ -31,7 +31,7 @@ export interface TodoItem {
completed?: string | null;
}
export enum TodoListEntityFeature {
export const enum TodoListEntityFeature {
CREATE_TODO_ITEM = 1,
DELETE_TODO_ITEM = 2,
UPDATE_TODO_ITEM = 4,
+1 -1
View File
@@ -15,7 +15,7 @@ export type VacuumEntityState =
| "returning"
| "error";
export enum VacuumEntityFeature {
export const enum VacuumEntityFeature {
TURN_ON = 1,
TURN_OFF = 2,
PAUSE = 4,
+4 -4
View File
@@ -4,10 +4,10 @@ import type {
} from "home-assistant-js-websocket";
import { stateActive } from "../common/entity/state_active";
import { supportsFeature } from "../common/entity/supports-feature";
import type { HomeAssistantFormatters } from "../types";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
export enum ValveEntityFeature {
export const enum ValveEntityFeature {
OPEN = 1,
CLOSE = 2,
SET_POSITION = 4,
@@ -78,7 +78,7 @@ export interface ValveEntity extends HassEntityBase {
export function computeValvePositionStateDisplay(
stateObj: ValveEntity,
formatEntityAttributeValue: HomeAssistantFormatters["formatEntityAttributeValue"],
hass: HomeAssistant,
position?: number
) {
const statePosition = stateActive(stateObj)
@@ -88,7 +88,7 @@ export function computeValvePositionStateDisplay(
const currentPosition = position ?? statePosition;
return currentPosition && currentPosition !== 100
? formatEntityAttributeValue(
? hass.formatEntityAttributeValue(
stateObj,
"current_position",
Math.round(currentPosition)
+1 -1
View File
@@ -3,7 +3,7 @@ import type {
HassEntityBase,
} from "home-assistant-js-websocket";
export enum WaterHeaterEntityFeature {
export const enum WaterHeaterEntityFeature {
TARGET_TEMPERATURE = 1,
OPERATION_MODE = 2,
AWAY_MODE = 4,
+1 -1
View File
@@ -41,7 +41,7 @@ import { round } from "../common/number/round";
import "../components/ha-svg-icon";
import type { HomeAssistant } from "../types";
export enum WeatherEntityFeature {
export const enum WeatherEntityFeature {
FORECAST_DAILY = 1,
FORECAST_HOURLY = 2,
FORECAST_TWICE_DAILY = 4,
@@ -1,16 +1,11 @@
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { transform } from "../../../../common/decorators/transform";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-control-button";
import { apiContext, configContext } from "../../../../data/context";
import type { CoverEntity } from "../../../../data/cover";
import {
DEFAULT_COVER_FAVORITE_POSITIONS,
@@ -25,11 +20,7 @@ import type {
ExtEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
} from "../../../../types";
import type { HomeAssistant } from "../../../../types";
import {
showConfirmationDialog,
showPromptDialog,
@@ -55,20 +46,7 @@ const favoriteKindFromEvent = (ev: Event): FavoriteKind =>
@customElement("ha-more-info-cover-favorite-positions")
export class HaMoreInfoCoverFavoritePositions extends LitElement {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HomeAssistant["user"]>({
transformer: ({ user }) => user,
})
private _user!: HomeAssistant["user"];
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: CoverEntity;
@@ -107,7 +85,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
key: FavoriteLocalizeKey,
values?: Record<string, string | number>
): string {
return this._localize(
return this.hass.localize(
`ui.dialogs.more_info_control.cover.${kind === "position" ? "favorite_position" : "favorite_tilt_position"}.${key}`,
values
);
@@ -146,7 +124,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
}
const result = await updateEntityRegistryEntry(
this._api,
this.hass,
this.entry.entity_id,
{
options_domain: "cover",
@@ -191,14 +169,14 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
}
if (kind === "position") {
this._api.callService("cover", "set_cover_position", {
this.hass.callService("cover", "set_cover_position", {
entity_id: this.stateObj.entity_id,
position: favorite,
});
return;
}
this._api.callService("cover", "set_cover_tilt_position", {
this.hass.callService("cover", "set_cover_tilt_position", {
entity_id: this.stateObj.entity_id,
tilt_position: favorite,
});
@@ -213,7 +191,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
kind,
value === undefined ? "add_title" : "edit_title"
),
inputLabel: this._localize(
inputLabel: this.hass.localize(
kind === "position"
? "ui.card.cover.position"
: "ui.card.cover.tilt_position"
@@ -333,7 +311,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
const { action, index } = ev.detail;
if (action === "hold" && this._user?.is_admin) {
if (action === "hold" && this.hass.user?.is_admin) {
fireEvent(this, "toggle-edit-mode", true);
return;
}
@@ -398,10 +376,10 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
.deleteLabel=${this._deleteLabel(kind)}
.editMode=${this.editMode ?? false}
.disabled=${this.stateObj.state === UNAVAILABLE}
.isAdmin=${Boolean(this._user?.is_admin)}
.isAdmin=${Boolean(this.hass.user?.is_admin)}
.showDone=${showDone}
.addLabel=${this._localizeFavorite(kind, "add")}
.doneLabel=${this._localize(
.doneLabel=${this.hass.localize(
"ui.dialogs.more_info_control.exit_edit_mode"
)}
@favorite-item-action=${this._handleFavoriteAction}
@@ -437,7 +415,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
${supportsPosition
? this._renderKindSection(
"position",
this._localize("ui.card.cover.position"),
this.hass.localize("ui.card.cover.position"),
this._favoritePositions,
showDoneOnPosition,
showLabels
@@ -446,7 +424,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
${supportsTiltPosition
? this._renderKindSection(
"tilt",
this._localize("ui.card.cover.tilt_position"),
this.hass.localize("ui.card.cover.tilt_position"),
this._favoriteTiltPositions,
true,
showLabels
@@ -1,18 +1,18 @@
import { consume } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-absolute-time";
import "../../../components/ha-relative-time";
import type { HomeAssistantFormatters } from "../../../types";
import { formattersContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { LightEntity } from "../../../data/light";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
import "../../../panels/lovelace/components/hui-timestamp-display";
import type { HomeAssistant } from "../../../types";
@customElement("ha-more-info-state-header")
export class HaMoreInfoStateHeader extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: LightEntity;
@property({ attribute: false }) public stateOverride?: string;
@@ -21,10 +21,6 @@ export class HaMoreInfoStateHeader extends LitElement {
@state() private _absoluteTime = false;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
private _localizeState(): TemplateResult | string {
if (
this.stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP &&
@@ -33,6 +29,7 @@ export class HaMoreInfoStateHeader extends LitElement {
) {
return html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(this.stateObj.state)}
format="relative"
capitalize
@@ -40,7 +37,7 @@ export class HaMoreInfoStateHeader extends LitElement {
`;
}
return this._formatters?.formatEntityState(this.stateObj) ?? "";
return this.hass.formatEntityState(this.stateObj);
}
private _toggleAbsolute() {
@@ -1,16 +1,11 @@
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { transform } from "../../../../common/decorators/transform";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-control-button";
import { apiContext, configContext } from "../../../../data/context";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity/entity_attributes";
import type {
@@ -18,11 +13,7 @@ import type {
ValveEntityOptions,
} from "../../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
} from "../../../../types";
import type { HomeAssistant } from "../../../../types";
import type { ValveEntity } from "../../../../data/valve";
import { DEFAULT_VALVE_FAVORITE_POSITIONS } from "../../../../data/valve";
import { normalizeFavoritePositions } from "../../../../data/favorite_positions";
@@ -46,20 +37,7 @@ type FavoriteLocalizeKey =
@customElement("ha-more-info-valve-favorite-positions")
export class HaMoreInfoValveFavoritePositions extends LitElement {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HomeAssistant["user"]>({
transformer: ({ user }) => user,
})
private _user!: HomeAssistant["user"];
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: ValveEntity;
@@ -86,7 +64,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
key: FavoriteLocalizeKey,
values?: Record<string, string | number>
): string {
return this._localize(
return this.hass.localize(
`ui.dialogs.more_info_control.valve.favorite_position.${key}`,
values
);
@@ -110,7 +88,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
currentOptions.favorite_positions = this._favoritePositions;
const result = await updateEntityRegistryEntry(
this._api,
this.hass,
this.entry.entity_id,
{
options_domain: "valve",
@@ -144,7 +122,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
return;
}
this._api.callService("valve", "set_valve_position", {
this.hass.callService("valve", "set_valve_position", {
entity_id: this.stateObj.entity_id,
position: favorite,
});
@@ -157,7 +135,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
title: this._localizeFavorite(
value === undefined ? "add_title" : "edit_title"
),
inputLabel: this._localize("ui.card.valve.position"),
inputLabel: this.hass.localize("ui.card.valve.position"),
inputType: "number",
inputMin: "0",
inputMax: "100",
@@ -264,7 +242,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
const { action, index } = ev.detail;
if (action === "hold" && this._user?.is_admin) {
if (action === "hold" && this.hass.user?.is_admin) {
fireEvent(this, "toggle-edit-mode", true);
return;
}
@@ -318,10 +296,10 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
.deleteLabel=${this._deleteLabel}
.editMode=${this.editMode ?? false}
.disabled=${this.stateObj.state === UNAVAILABLE}
.isAdmin=${Boolean(this._user?.is_admin)}
.isAdmin=${Boolean(this.hass.user?.is_admin)}
.showDone=${true}
.addLabel=${this._localizeFavorite("add")}
.doneLabel=${this._localize(
.doneLabel=${this.hass.localize(
"ui.dialogs.more_info_control.exit_edit_mode"
)}
@favorite-item-action=${this._handleFavoriteAction}
@@ -1,37 +1,27 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-button";
import "../../../components/ha-relative-time";
import { apiContext } from "../../../data/context";
import { triggerAutomationActions } from "../../../data/automation";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistantApi } from "../../../types";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-automation")
class MoreInfoAutomation extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected render() {
if (!this._localize || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
return html`
<hr />
<div class="flex">
<div>${this._localize("ui.card.automation.last_triggered")}:</div>
<div>${this.hass.localize("ui.card.automation.last_triggered")}:</div>
<ha-relative-time
.datetime=${this.stateObj.attributes.last_triggered}
capitalize
@@ -45,14 +35,14 @@ class MoreInfoAutomation extends LitElement {
@click=${this._runActions}
.disabled=${this.stateObj!.state === UNAVAILABLE}
>
${this._localize("ui.card.automation.trigger")}
${this.hass.localize("ui.card.automation.trigger")}
</ha-button>
</div>
`;
}
private _runActions() {
triggerAutomationActions(this._api, this.stateObj!.entity_id);
triggerAutomationActions(this.hass, this.stateObj!.entity_id);
}
static styles = css`
@@ -1,5 +1,3 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import {
mdiArrowOscillating,
mdiFan,
@@ -10,9 +8,7 @@ import {
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-attribute-icon";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-icon-button-group";
@@ -26,10 +22,10 @@ import {
climateHvacModeIcon,
compareClimateHvacModes,
} from "../../../data/climate";
import { apiContext, formattersContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import "../../../state-control/climate/ha-state-control-climate-humidity";
import "../../../state-control/climate/ha-state-control-climate-temperature";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-control-select-container";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -38,24 +34,15 @@ type MainControl = "temperature" | "humidity";
@customElement("more-info-climate")
class MoreInfoClimate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: ClimateEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state() private _mainControl: MainControl = "temperature";
private _renderPresetModeIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="preset_mode"
.attributeValue=${value}
@@ -63,6 +50,7 @@ class MoreInfoClimate extends LitElement {
private _renderFanModeIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="fan_mode"
.attributeValue=${value}
@@ -70,6 +58,7 @@ class MoreInfoClimate extends LitElement {
private _renderSwingModeIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="swing_mode"
.attributeValue=${value}
@@ -77,6 +66,7 @@ class MoreInfoClimate extends LitElement {
private _renderSwingHorizontalModeIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="swing_horizontal_mode"
.attributeValue=${value}
@@ -130,13 +120,13 @@ class MoreInfoClimate extends LitElement {
? html`
<div>
<p class="label">
${this._formatters.formatEntityAttributeName(
${this.hass.formatEntityAttributeName(
this.stateObj,
"current_temperature"
)}
</p>
<p class="value">
${this._formatters.formatEntityAttributeValue(
${this.hass.formatEntityAttributeValue(
this.stateObj,
"current_temperature"
)}
@@ -148,13 +138,13 @@ class MoreInfoClimate extends LitElement {
? html`
<div>
<p class="label">
${this._formatters.formatEntityAttributeName(
${this.hass.formatEntityAttributeName(
this.stateObj,
"current_humidity"
)}
</p>
<p class="value">
${this._formatters.formatEntityAttributeValue(
${this.hass.formatEntityAttributeValue(
this.stateObj,
"current_humidity"
)}
@@ -167,6 +157,7 @@ class MoreInfoClimate extends LitElement {
${this._mainControl === "temperature"
? html`
<ha-state-control-climate-temperature
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-state-control-climate-temperature>
`
@@ -174,6 +165,7 @@ class MoreInfoClimate extends LitElement {
${this._mainControl === "humidity"
? html`
<ha-state-control-climate-humidity
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-state-control-climate-humidity>
`
@@ -184,7 +176,7 @@ class MoreInfoClimate extends LitElement {
<ha-icon-button-toggle
.selected=${this._mainControl === "temperature"}
.disabled=${this.stateObj!.state === UNAVAILABLE}
.label=${this._localize(
.label=${this.hass.localize(
"ui.dialogs.more_info_control.climate.temperature"
)}
.control=${"temperature"}
@@ -195,7 +187,7 @@ class MoreInfoClimate extends LitElement {
<ha-icon-button-toggle
.selected=${this._mainControl === "humidity"}
.disabled=${this.stateObj!.state === UNAVAILABLE}
.label=${this._localize(
.label=${this.hass.localize(
"ui.dialogs.more_info_control.climate.humidity"
)}
.control=${"humidity"}
@@ -209,7 +201,8 @@ class MoreInfoClimate extends LitElement {
</div>
<ha-more-info-control-select-container>
<ha-control-select-menu
.label=${this._localize("ui.card.climate.mode")}
.hass=${this.hass}
.label=${this.hass.localize("ui.card.climate.mode")}
.value=${stateObj.state}
.disabled=${this.stateObj.state === UNAVAILABLE}
.options=${stateObj.attributes.hvac_modes
@@ -218,7 +211,7 @@ class MoreInfoClimate extends LitElement {
.map((mode) => ({
value: mode,
iconPath: climateHvacModeIcon(mode),
label: this._formatters.formatEntityState(stateObj, mode),
label: this.hass.formatEntityState(stateObj, mode),
}))}
@wa-select=${this._handleOperationModeChanged}
>
@@ -230,7 +223,8 @@ class MoreInfoClimate extends LitElement {
${supportPresetMode && stateObj.attributes.preset_modes
? html`
<ha-control-select-menu
.label=${this._formatters.formatEntityAttributeName(
.hass=${this.hass}
.label=${this.hass.formatEntityAttributeName(
stateObj,
"preset_mode"
)}
@@ -239,7 +233,7 @@ class MoreInfoClimate extends LitElement {
@wa-select=${this._handlePresetmodeChanged}
.options=${stateObj.attributes.preset_modes.map((mode) => ({
value: mode,
label: this._formatters.formatEntityAttributeValue(
label: this.hass.formatEntityAttributeValue(
stateObj,
"preset_mode",
mode
@@ -254,7 +248,8 @@ class MoreInfoClimate extends LitElement {
${supportFanMode && stateObj.attributes.fan_modes
? html`
<ha-control-select-menu
.label=${this._formatters.formatEntityAttributeName(
.hass=${this.hass}
.label=${this.hass.formatEntityAttributeName(
stateObj,
"fan_mode"
)}
@@ -263,7 +258,7 @@ class MoreInfoClimate extends LitElement {
@wa-select=${this._handleFanModeChanged}
.options=${stateObj.attributes.fan_modes.map((mode) => ({
value: mode,
label: this._formatters.formatEntityAttributeValue(
label: this.hass.formatEntityAttributeValue(
stateObj,
"fan_mode",
mode
@@ -278,7 +273,8 @@ class MoreInfoClimate extends LitElement {
${supportSwingMode && stateObj.attributes.swing_modes
? html`
<ha-control-select-menu
.label=${this._formatters.formatEntityAttributeName(
.hass=${this.hass}
.label=${this.hass.formatEntityAttributeName(
stateObj,
"swing_mode"
)}
@@ -287,7 +283,7 @@ class MoreInfoClimate extends LitElement {
@wa-select=${this._handleSwingmodeChanged}
.options=${stateObj.attributes.swing_modes.map((mode) => ({
value: mode,
label: this._formatters.formatEntityAttributeValue(
label: this.hass.formatEntityAttributeValue(
stateObj,
"swing_mode",
mode
@@ -306,7 +302,8 @@ class MoreInfoClimate extends LitElement {
stateObj.attributes.swing_horizontal_modes
? html`
<ha-control-select-menu
.label=${this._formatters.formatEntityAttributeName(
.hass=${this.hass}
.label=${this.hass.formatEntityAttributeName(
stateObj,
"swing_horizontal_mode"
)}
@@ -316,7 +313,7 @@ class MoreInfoClimate extends LitElement {
.options=${stateObj.attributes.swing_horizontal_modes.map(
(mode) => ({
value: mode,
label: this._formatters.formatEntityAttributeValue(
label: this.hass.formatEntityAttributeValue(
stateObj,
"swing_horizontal_mode",
mode
@@ -406,7 +403,7 @@ class MoreInfoClimate extends LitElement {
data.entity_id = this.stateObj!.entity_id;
const curState = this.stateObj;
await this._api.callService("climate", service, data);
await this.hass.callService("climate", service, data);
// We reset stateObj to re-sync the inputs with the state. It will be out
// of sync if our service call did not result in the entity to be turned
@@ -1,4 +1,3 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -6,20 +5,17 @@ import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import "../../../components/input/ha-input";
import { apiContext } from "../../../data/context";
import type { HomeAssistantApi } from "../../../types";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-configurator")
export class MoreInfoConfigurator extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state() private _isConfiguring = false;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
private _fieldInput: Record<string, unknown> = {};
private _fieldInput = {};
protected render() {
if (this.stateObj?.state !== "configure") {
@@ -75,7 +71,7 @@ export class MoreInfoConfigurator extends LitElement {
this._isConfiguring = true;
this._api.callService("configurator", "configure", data).then(
this.hass.callService("configurator", "configure", data).then(
() => {
this._isConfiguring = false;
},
@@ -1,12 +1,8 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { apiContext } from "../../../data/context";
import type { HomeAssistantApi } from "../../../types";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-assist-chat";
import "../../../components/ha-spinner";
import "../../../components/ha-alert";
@@ -15,20 +11,14 @@ import { getAssistPipeline } from "../../../data/assist_pipeline";
@customElement("more-info-conversation")
class MoreInfoConversation extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state() public _pipeline?: AssistPipeline;
@state() private _errorLoadAssist?: "not_found" | "unknown";
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
@@ -50,7 +40,7 @@ class MoreInfoConversation extends LitElement {
this._errorLoadAssist = undefined;
const pipelineId = this.stateObj!.entity_id;
try {
const pipeline = await getAssistPipeline(this._api, pipelineId);
const pipeline = await getAssistPipeline(this.hass, pipelineId);
// Verify the pipeline is still the same.
if (this.stateObj && pipelineId === this.stateObj.entity_id) {
this._pipeline = pipeline;
@@ -71,20 +61,21 @@ class MoreInfoConversation extends LitElement {
}
protected render() {
if (!this._localize || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
return html`
${this._errorLoadAssist
? html`<ha-alert alert-type="error">
${this._localize(
${this.hass.localize(
`ui.dialogs.voice_command.${this._errorLoadAssist}_error_load_assist`
)}
</ha-alert>`
: this._pipeline
? html`
<ha-assist-chat
.hass=${this.hass}
.pipeline=${this._pipeline}
disable-speech
></ha-assist-chat>
@@ -1,28 +1,18 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-button";
import { apiContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistantApi } from "../../../types";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-counter")
class MoreInfoCounter extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected render() {
if (!this._localize || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
@@ -38,7 +28,7 @@ class MoreInfoCounter extends LitElement {
.disabled=${disabled ||
Number(this.stateObj.state) === this.stateObj.attributes.maximum}
>
${this._localize("ui.card.counter.actions.increment")}
${this.hass!.localize("ui.card.counter.actions.increment")}
</ha-button>
<ha-button
appearance="plain"
@@ -48,7 +38,7 @@ class MoreInfoCounter extends LitElement {
.disabled=${disabled ||
Number(this.stateObj.state) === this.stateObj.attributes.minimum}
>
${this._localize("ui.card.counter.actions.decrement")}
${this.hass!.localize("ui.card.counter.actions.decrement")}
</ha-button>
<ha-button
appearance="plain"
@@ -57,7 +47,7 @@ class MoreInfoCounter extends LitElement {
@click=${this._handleActionClick}
.disabled=${disabled}
>
${this._localize("ui.card.counter.actions.reset")}
${this.hass!.localize("ui.card.counter.actions.reset")}
</ha-button>
</div>
`;
@@ -65,7 +55,7 @@ class MoreInfoCounter extends LitElement {
private _handleActionClick(e: MouseEvent): void {
const action = (e.currentTarget as any).action;
this._api.callService("counter", action, {
this.hass.callService("counter", action, {
entity_id: this.stateObj!.entity_id,
});
}
@@ -1,14 +1,10 @@
import { consume } from "@lit/context";
import { mdiMenu, mdiSwapVertical } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-icon-button-group";
import "../../../components/ha-icon-button-toggle";
import { formattersContext } from "../../../data/context";
import {
shouldShowFavoriteOptions,
type ExtEntityRegistryEntry,
@@ -25,7 +21,7 @@ import "../../../state-control/cover/ha-state-control-cover-buttons";
import "../../../state-control/cover/ha-state-control-cover-position";
import "../../../state-control/cover/ha-state-control-cover-tilt-position";
import "../../../state-control/cover/ha-state-control-cover-toggle";
import type { HomeAssistantFormatters } from "../../../types";
import type { HomeAssistant } from "../../../types";
import "../components/covers/ha-more-info-cover-favorite-positions";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@@ -34,13 +30,7 @@ type Mode = "position" | "button";
@customElement("more-info-cover")
class MoreInfoCover extends LitElement {
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: CoverEntity;
@@ -68,11 +58,11 @@ class MoreInfoCover extends LitElement {
}
private get _stateOverride() {
const stateDisplay = this._formatters.formatEntityState(this.stateObj!);
const stateDisplay = this.hass.formatEntityState(this.stateObj!);
const positionStateDisplay = computeCoverPositionStateDisplay(
this.stateObj!,
this._formatters.formatEntityAttributeValue
this.hass
);
if (positionStateDisplay) {
@@ -82,7 +72,7 @@ class MoreInfoCover extends LitElement {
}
protected render() {
if (!this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
@@ -123,6 +113,7 @@ class MoreInfoCover extends LitElement {
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateOverride=${this._stateOverride}
></ha-more-info-state-header>
@@ -135,6 +126,7 @@ class MoreInfoCover extends LitElement {
? html`
<ha-state-control-cover-position
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-cover-position>
`
: nothing}
@@ -142,6 +134,7 @@ class MoreInfoCover extends LitElement {
? html`
<ha-state-control-cover-tilt-position
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-cover-tilt-position>
`
: nothing}
@@ -155,12 +148,14 @@ class MoreInfoCover extends LitElement {
? html`
<ha-state-control-cover-toggle
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-cover-toggle>
`
: supportsOpenClose || supportsTilt
? html`
<ha-state-control-cover-buttons
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-cover-buttons>
`
: nothing}
@@ -174,7 +169,7 @@ class MoreInfoCover extends LitElement {
? html`
<ha-icon-button-group>
<ha-icon-button-toggle
.label=${this._localize(
.label=${this.hass.localize(
`ui.dialogs.more_info_control.cover.switch_mode.position`
)}
.selected=${this._mode === "position"}
@@ -183,7 +178,7 @@ class MoreInfoCover extends LitElement {
@click=${this._setMode}
></ha-icon-button-toggle>
<ha-icon-button-toggle
.label=${this._localize(
.label=${this.hass.localize(
`ui.dialogs.more_info_control.cover.switch_mode.button`
)}
.selected=${this._mode === "button"}
@@ -200,6 +195,7 @@ class MoreInfoCover extends LitElement {
showFavoriteControls
? html`
<ha-more-info-cover-favorite-positions
.hass=${this.hass}
.stateObj=${this.stateObj}
.entry=${this.entry}
.editMode=${this.editMode}
@@ -1,35 +1,18 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { setDateValue } from "../../../data/date";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { FrontendLocaleData } from "../../../data/translation";
import type {
HomeAssistantApi,
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@customElement("more-info-date")
class MoreInfoDate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected render() {
if (!this.stateObj || this.stateObj.state === UNAVAILABLE) {
return nothing;
@@ -37,7 +20,7 @@ class MoreInfoDate extends LitElement {
return html`
<ha-date-input
.locale=${this._locale}
.locale=${this.hass.locale}
.value=${this.stateObj.state === UNKNOWN
? undefined
: this.stateObj.state}
@@ -49,11 +32,7 @@ class MoreInfoDate extends LitElement {
private _dateChanged(ev: ValueChangedEvent<string>): void {
if (ev.detail.value) {
setDateValue(
this._api.callService,
this.stateObj!.entity_id,
ev.detail.value
);
setDateValue(this.hass!, this.stateObj!.entity_id, ev.detail.value);
}
}
@@ -1,36 +1,19 @@
import { consume } from "@lit/context";
import { format } from "date-fns";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { setDateTimeValue } from "../../../data/datetime";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { FrontendLocaleData } from "../../../data/translation";
import type {
HomeAssistantApi,
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@customElement("more-info-datetime")
class MoreInfoDatetime extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected render() {
if (!this.stateObj || this.stateObj.state === UNAVAILABLE) {
return nothing;
@@ -44,14 +27,14 @@ class MoreInfoDatetime extends LitElement {
const date = dateObj ? format(dateObj, "yyyy-MM-dd") : undefined;
return html`<ha-date-input
.locale=${this._locale}
.locale=${this.hass.locale}
.value=${date}
@value-changed=${this._dateChanged}
>
</ha-date-input>
<ha-time-input
.value=${time}
.locale=${this._locale}
.locale=${this.hass.locale}
@value-changed=${this._timeChanged}
@click=${this._stopEventPropagation}
></ha-time-input>`;
@@ -67,11 +50,7 @@ class MoreInfoDatetime extends LitElement {
const newTime = ev.detail.value.split(":").map(Number);
dateObj.setHours(newTime[0], newTime[1], newTime[2]);
setDateTimeValue(
this._api.callService,
this.stateObj!.entity_id,
dateObj
);
setDateTimeValue(this.hass!, this.stateObj!.entity_id, dateObj);
}
}
@@ -81,11 +60,7 @@ class MoreInfoDatetime extends LitElement {
const newDate = ev.detail.value.split("-").map(Number);
dateObj.setFullYear(newDate[0], newDate[1] - 1, newDate[2]);
setDateTimeValue(
this._api.callService,
this.stateObj!.entity_id,
dateObj
);
setDateTimeValue(this.hass!, this.stateObj!.entity_id, dateObj);
}
}
@@ -42,6 +42,7 @@ class MoreInfoFan extends LitElement {
private _renderPresetModeIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="preset_mode"
.attributeValue=${value}
@@ -49,6 +50,7 @@ class MoreInfoFan extends LitElement {
private _renderDirectionIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="direction"
.attributeValue=${value}
@@ -239,6 +241,7 @@ class MoreInfoFan extends LitElement {
>
<ha-attribute-icon
slot="icon"
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="direction"
.attributeValue=${this.stateObj.attributes.direction}
@@ -1,44 +1,31 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { mdiPower, mdiTuneVariant } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-attribute-icon";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-list-item";
import { UNAVAILABLE } from "../../../data/entity/entity";
import { apiContext, formattersContext } from "../../../data/context";
import type { HumidifierEntity } from "../../../data/humidifier";
import { HumidifierEntityFeature } from "../../../data/humidifier";
import "../../../state-control/humidifier/ha-state-control-humidifier-humidity";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-control-select-container";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@customElement("more-info-humidifier")
class MoreInfoHumidifier extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HumidifierEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state() public _mode?: string;
private _renderModeIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="mode"
.attributeValue=${value}
@@ -56,6 +43,7 @@ class MoreInfoHumidifier extends LitElement {
return nothing;
}
const hass = this.hass;
const stateObj = this.stateObj;
const supportModes = supportsFeature(
@@ -69,13 +57,13 @@ class MoreInfoHumidifier extends LitElement {
? html`
<div>
<p class="label">
${this._formatters.formatEntityAttributeName(
${this.hass.formatEntityAttributeName(
this.stateObj,
"current_humidity"
)}
</p>
<p class="value">
${this._formatters.formatEntityAttributeValue(
${this.hass.formatEntityAttributeValue(
this.stateObj,
"current_humidity"
)}
@@ -87,20 +75,22 @@ class MoreInfoHumidifier extends LitElement {
<div class="controls">
<ha-state-control-humidifier-humidity
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-state-control-humidifier-humidity>
</div>
<ha-more-info-control-select-container>
<ha-control-select-menu
.label=${this._localize("ui.card.humidifier.state")}
.hass=${hass}
.label=${this.hass.localize("ui.card.humidifier.state")}
.value=${this.stateObj.state}
.disabled=${this.stateObj.state === UNAVAILABLE}
@wa-select=${this._handleStateChanged}
.options=${["off", "on"].map((fanState) => ({
value: fanState,
label: this.stateObj
? this._formatters.formatEntityState(this.stateObj, fanState)
? this.hass.formatEntityState(this.stateObj, fanState)
: fanState,
}))}
>
@@ -110,14 +100,15 @@ class MoreInfoHumidifier extends LitElement {
${supportModes
? html`
<ha-control-select-menu
.label=${this._localize("ui.card.humidifier.mode")}
.hass=${hass}
.label=${hass.localize("ui.card.humidifier.mode")}
.value=${stateObj.attributes.mode}
.disabled=${this.stateObj.state === UNAVAILABLE}
@wa-select=${this._handleModeChanged}
.options=${stateObj.attributes.available_modes?.map((mode) => ({
value: mode,
label: stateObj
? this._formatters.formatEntityAttributeValue(
? this.hass.formatEntityAttributeValue(
stateObj,
"mode",
mode
@@ -173,7 +164,7 @@ class MoreInfoHumidifier extends LitElement {
data.entity_id = this.stateObj!.entity_id;
const curState = this.stateObj;
await this._api.callService("humidifier", service, data);
await this.hass.callService("humidifier", service, data);
// We reset stateObj to re-sync the inputs with the state. It will be out
// of sync if our service call did not result in the entity to be turned
@@ -2,31 +2,32 @@ import { mdiPower, mdiPowerOff } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { customElement, property } from "lit/decorators";
import "../../../state-control/ha-state-control-toggle";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@customElement("more-info-input_boolean")
class MoreInfoInputBoolean extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
protected render() {
if (!this._localize || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-state-header>
<div class="controls">
<ha-state-control-toggle
.stateObj=${this.stateObj}
.hass=${this.hass}
.iconPathOn=${mdiPower}
.iconPathOff=${mdiPowerOff}
></ha-state-control-toggle>
@@ -1,38 +1,21 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import {
setInputDateTimeValue,
stateToIsoDateString,
} from "../../../data/input_datetime";
import type { FrontendLocaleData } from "../../../data/translation";
import type {
HomeAssistantApi,
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@customElement("more-info-input_datetime")
class MoreInfoInputDatetime extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected render() {
if (!this.stateObj) {
return nothing;
@@ -42,7 +25,7 @@ class MoreInfoInputDatetime extends LitElement {
${this.stateObj.attributes.has_date
? html`
<ha-date-input
.locale=${this._locale}
.locale=${this.hass.locale}
.value=${stateToIsoDateString(this.stateObj)}
.disabled=${this.stateObj.state === UNAVAILABLE}
@value-changed=${this._dateChanged}
@@ -58,7 +41,7 @@ class MoreInfoInputDatetime extends LitElement {
: this.stateObj.attributes.has_date
? this.stateObj.state.split(" ")[1]
: this.stateObj.state}
.locale=${this._locale}
.locale=${this.hass.locale}
.disabled=${this.stateObj.state === UNAVAILABLE}
@value-changed=${this._timeChanged}
@click=${this._stopEventPropagation}
@@ -74,7 +57,7 @@ class MoreInfoInputDatetime extends LitElement {
private _timeChanged(ev: ValueChangedEvent<string>): void {
setInputDateTimeValue(
this._api.callService,
this.hass!,
this.stateObj!.entity_id,
ev.detail.value,
this.stateObj!.attributes.has_date
@@ -85,7 +68,7 @@ class MoreInfoInputDatetime extends LitElement {
private _dateChanged(ev: ValueChangedEvent<string>): void {
setInputDateTimeValue(
this._api.callService,
this.hass!,
this.stateObj!.entity_id,
this.stateObj!.attributes.has_time
? this.stateObj!.state.split(" ")[1]
@@ -60,6 +60,7 @@ class MoreInfoLight extends LitElement {
private _renderEffectIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="effect"
.attributeValue=${value}
@@ -1,34 +1,20 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } 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 type { HaSelectSelectEvent } from "../../../components/ha-select";
import "../../../components/ha-select";
import { apiContext, formattersContext } from "../../../data/context";
import type { RemoteEntity } from "../../../data/remote";
import { REMOTE_SUPPORT_ACTIVITY } from "../../../data/remote";
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-remote")
class MoreInfoRemote extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: RemoteEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected render() {
if (!this._localize || !this._formatters || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
@@ -38,14 +24,14 @@ class MoreInfoRemote extends LitElement {
${supportsFeature(stateObj, REMOTE_SUPPORT_ACTIVITY)
? html`
<ha-select
.label=${this._localize(
.label=${this.hass!.localize(
"ui.dialogs.more_info_control.remote.activity"
)}
.value=${stateObj.attributes.current_activity || ""}
@selected=${this._handleActivityChanged}
.options=${stateObj.attributes.activity_list?.map((activity) => ({
value: activity,
label: this._formatters.formatEntityAttributeValue(
label: this.hass!.formatEntityAttributeValue(
stateObj,
"activity",
activity
@@ -66,7 +52,7 @@ class MoreInfoRemote extends LitElement {
return;
}
this._api.callService("remote", "turn_on", {
this.hass.callService("remote", "turn_on", {
entity_id: this.stateObj!.entity_id,
activity: newVal,
});
@@ -2,11 +2,10 @@ import { mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { customElement, property } from "lit/decorators";
import "../../../state-control/ha-state-control-toggle";
import "../../../components/ha-button";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import { supportsFeature } from "../../../common/entity/supports-feature";
@@ -15,12 +14,12 @@ import { showSirenAdvancedControlsView } from "../components/siren/show-dialog-s
@customElement("more-info-siren")
class MoreInfoSiren extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
protected render() {
if (!this._localize || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
@@ -40,11 +39,13 @@ class MoreInfoSiren extends LitElement {
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-state-header>
<div class="controls">
<ha-state-control-toggle
.stateObj=${this.stateObj}
.hass=${this.hass}
.iconPathOn=${mdiVolumeHigh}
.iconPathOff=${mdiVolumeOff}
></ha-state-control-toggle>
@@ -54,7 +55,7 @@ class MoreInfoSiren extends LitElement {
size="s"
@click=${this._showAdvancedControlsDialog}
>
${this._localize("ui.components.siren.advanced_controls")}
${this.hass.localize("ui.components.siren.advanced_controls")}
</ha-button>`
: nothing}
</div>
@@ -2,31 +2,32 @@ import { mdiPower, mdiPowerOff } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { customElement, property } from "lit/decorators";
import "../../../state-control/ha-state-control-toggle";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@customElement("more-info-switch")
class MoreInfoSwitch extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
protected render() {
if (!this._localize || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-state-header>
<div class="controls">
<ha-state-control-toggle
.stateObj=${this.stateObj}
.hass=${this.hass}
.iconPathOn=${mdiPower}
.iconPathOff=${mdiPowerOff}
></ha-state-control-toggle>
@@ -1,35 +1,18 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import { setTimeValue } from "../../../data/time";
import type { FrontendLocaleData } from "../../../data/translation";
import type {
HomeAssistantApi,
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@customElement("more-info-time")
class MoreInfoTime extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
protected render() {
if (!this.stateObj || this.stateObj.state === UNAVAILABLE) {
return nothing;
@@ -40,7 +23,7 @@ class MoreInfoTime extends LitElement {
.value=${this.stateObj.state === UNKNOWN
? undefined
: this.stateObj.state}
.locale=${this._locale}
.locale=${this.hass.locale}
@value-changed=${this._timeChanged}
@click=${this._stopEventPropagation}
></ha-time-input>
@@ -53,11 +36,7 @@ class MoreInfoTime extends LitElement {
private _timeChanged(ev: ValueChangedEvent<string>): void {
if (ev.detail.value) {
setTimeValue(
this._api.callService,
this.stateObj!.entity_id,
ev.detail.value
);
setTimeValue(this.hass!, this.stateObj!.entity_id, ev.detail.value);
}
}
@@ -1,29 +1,19 @@
import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-button";
import "../../../components/ha-duration-input";
import type { HaDurationData } from "../../../components/ha-duration-input";
import { apiContext } from "../../../data/context";
import type { TimerEntity } from "../../../data/timer";
import { timerDurationData } from "../../../data/timer";
import type { HomeAssistantApi, ValueChangedEvent } from "../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
@customElement("more-info-timer")
class MoreInfoTimer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: TimerEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state() private _duration?: HaDurationData;
protected willUpdate(changedProps: PropertyValues<this>): void {
@@ -36,7 +26,7 @@ class MoreInfoTimer extends LitElement {
}
protected render() {
if (!this._localize || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
@@ -52,14 +42,14 @@ class MoreInfoTimer extends LitElement {
${timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.start")}
${this.hass.localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing}
${timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.set")}
${this.hass.localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing}
@@ -71,7 +61,7 @@ class MoreInfoTimer extends LitElement {
.action=${"pause"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.pause")}
${this.hass.localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing}
@@ -83,7 +73,7 @@ class MoreInfoTimer extends LitElement {
.action=${"start"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.start")}
${this.hass.localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing}
@@ -95,7 +85,7 @@ class MoreInfoTimer extends LitElement {
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.cancel")}
${this.hass.localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
@@ -103,7 +93,7 @@ class MoreInfoTimer extends LitElement {
.action=${"finish"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.finish")}
${this.hass.localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing}
@@ -121,7 +111,7 @@ class MoreInfoTimer extends LitElement {
// entered duration. timer.start has no upper bound, so values beyond the
// configured duration are accepted.
private _start(): void {
this._api.callService("timer", "start", {
this.hass.callService("timer", "start", {
entity_id: this.stateObj!.entity_id,
...(this._duration ? { duration: this._duration } : {}),
});
@@ -129,7 +119,7 @@ class MoreInfoTimer extends LitElement {
private _handleActionClick(e: MouseEvent): void {
const action = (e.currentTarget as any).action;
this._api.callService("timer", action, {
this.hass.callService("timer", action, {
entity_id: this.stateObj!.entity_id,
});
}
@@ -1,14 +1,10 @@
import { consume } from "@lit/context";
import { mdiMenu, mdiSwapVertical } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-icon-button-group";
import "../../../components/ha-icon-button-toggle";
import { formattersContext } from "../../../data/context";
import {
shouldShowFavoriteOptions,
type ExtEntityRegistryEntry,
@@ -22,7 +18,7 @@ import {
import "../../../state-control/valve/ha-state-control-valve-buttons";
import "../../../state-control/valve/ha-state-control-valve-position";
import "../../../state-control/valve/ha-state-control-valve-toggle";
import type { HomeAssistantFormatters } from "../../../types";
import type { HomeAssistant } from "../../../types";
import "../components/valves/ha-more-info-valve-favorite-positions";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@@ -31,13 +27,7 @@ type Mode = "position" | "button";
@customElement("more-info-valve")
class MoreInfoValve extends LitElement {
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: ValveEntity;
@@ -65,11 +55,11 @@ class MoreInfoValve extends LitElement {
}
private get _stateOverride() {
const stateDisplay = this._formatters.formatEntityState(this.stateObj!);
const stateDisplay = this.hass.formatEntityState(this.stateObj!);
const positionStateDisplay = computeValvePositionStateDisplay(
this.stateObj!,
this._formatters.formatEntityAttributeValue
this.hass
);
if (positionStateDisplay) {
@@ -79,7 +69,7 @@ class MoreInfoValve extends LitElement {
}
protected render() {
if (!this.stateObj) {
if (!this.hass || !this.stateObj) {
return nothing;
}
@@ -107,6 +97,7 @@ class MoreInfoValve extends LitElement {
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateOverride=${this._stateOverride}
></ha-more-info-state-header>
@@ -119,6 +110,7 @@ class MoreInfoValve extends LitElement {
? html`
<ha-state-control-valve-position
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-valve-position>
`
: nothing}
@@ -132,12 +124,14 @@ class MoreInfoValve extends LitElement {
? html`
<ha-state-control-valve-toggle
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-valve-toggle>
`
: supportsOpenClose
? html`
<ha-state-control-valve-buttons
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-valve-buttons>
`
: nothing}
@@ -150,7 +144,7 @@ class MoreInfoValve extends LitElement {
? html`
<ha-icon-button-group>
<ha-icon-button-toggle
.label=${this._localize(
.label=${this.hass.localize(
`ui.dialogs.more_info_control.valve.switch_mode.position`
)}
.selected=${this._mode === "position"}
@@ -159,7 +153,7 @@ class MoreInfoValve extends LitElement {
@click=${this._setMode}
></ha-icon-button-toggle>
<ha-icon-button-toggle
.label=${this._localize(
.label=${this.hass.localize(
`ui.dialogs.more_info_control.valve.switch_mode.button`
)}
.selected=${this._mode === "button"}
@@ -176,6 +170,7 @@ class MoreInfoValve extends LitElement {
showFavoriteControls
? html`
<ha-more-info-valve-favorite-positions
.hass=${this.hass}
.stateObj=${this.stateObj}
.entry=${this.entry}
.editMode=${this.editMode}
@@ -1,12 +1,8 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { mdiAccount, mdiAccountArrowRight, mdiWaterBoiler } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } 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 "../../../components/ha-attribute-icon";
import "../../../components/ha-control-select-menu";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -17,29 +13,20 @@ import {
WaterHeaterEntityFeature,
compareWaterHeaterOperationMode,
} from "../../../data/water_heater";
import { apiContext, formattersContext } from "../../../data/context";
import "../../../state-control/water_heater/ha-state-control-water_heater-temperature";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-control-select-container";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@customElement("more-info-water_heater")
class MoreInfoWaterHeater extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: WaterHeaterEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
private _renderOperationModeIcon = (value: string) =>
html`<ha-attribute-icon
.hass=${this.hass}
.stateObj=${this.stateObj}
attribute="operation_mode"
.attributeValue=${value}
@@ -70,13 +57,13 @@ class MoreInfoWaterHeater extends LitElement {
? html`
<div>
<p class="label">
${this._formatters.formatEntityAttributeName(
${this.hass.formatEntityAttributeName(
this.stateObj,
"current_temperature"
)}
</p>
<p class="value">
${this._formatters.formatEntityAttributeValue(
${this.hass.formatEntityAttributeValue(
this.stateObj,
"current_temperature"
)}
@@ -87,6 +74,7 @@ class MoreInfoWaterHeater extends LitElement {
</div>
<div class="controls">
<ha-state-control-water_heater-temperature
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-state-control-water_heater-temperature>
</div>
@@ -94,7 +82,8 @@ class MoreInfoWaterHeater extends LitElement {
${supportOperationMode && stateObj.attributes.operation_list
? html`
<ha-control-select-menu
.label=${this._localize("ui.card.water_heater.mode")}
.hass=${this.hass}
.label=${this.hass.localize("ui.card.water_heater.mode")}
.value=${stateObj.state}
.disabled=${stateObj.state === UNAVAILABLE}
@wa-select=${this._handleOperationModeChanged}
@@ -103,7 +92,7 @@ class MoreInfoWaterHeater extends LitElement {
.sort(compareWaterHeaterOperationMode)
.map((mode) => ({
value: mode,
label: this._formatters.formatEntityState(stateObj, mode),
label: this.hass.formatEntityState(stateObj, mode),
}))}
.renderIcon=${this._renderOperationModeIcon}
>
@@ -114,7 +103,7 @@ class MoreInfoWaterHeater extends LitElement {
${supportAwayMode
? html`
<ha-control-select-menu
.label=${this._formatters.formatEntityAttributeName(
.label=${this.hass.formatEntityAttributeName(
stateObj,
"away_mode"
)}
@@ -123,7 +112,7 @@ class MoreInfoWaterHeater extends LitElement {
@wa-select=${this._handleAwayModeChanged}
.options=${["on", "off"].map((mode) => ({
value: mode,
label: this._formatters.formatEntityAttributeValue(
label: this.hass.formatEntityAttributeValue(
stateObj,
"away_mode",
mode
@@ -176,7 +165,7 @@ class MoreInfoWaterHeater extends LitElement {
data.entity_id = this.stateObj!.entity_id;
const curState = this.stateObj;
await this._api.callService("water_heater", service, data);
await this.hass.callService("water_heater", service, data);
// We reset stateObj to re-sync the inputs with the state. It will be out
// of sync if our service call did not result in the entity to be turned
+4 -36
View File
@@ -13,11 +13,6 @@ import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import "../../components/ha-yaml-editor";
import { computeDomain } from "../../common/entity/compute_domain";
import type { FeatureEnum } from "../../common/entity/get_domain_features";
import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
interface DetailsViewParams {
entityId: string;
@@ -182,12 +177,6 @@ class HaMoreInfoDetails extends LitElement {
</div>`;
}
let featureEnum: FeatureEnum | undefined;
if (this._stateObj?.attributes.supported_features !== undefined) {
const domain = computeDomain(this.params!.entityId);
featureEnum = getFeatures(domain);
}
return attributes.map(
(attribute) => html`
<div class="data-entry">
@@ -200,37 +189,16 @@ class HaMoreInfoDetails extends LitElement {
)}
</div>
<div class="value">
${attribute === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
`}
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
);
}
private _renderFeatures(
featureEnum: FeatureEnum,
stateObj: HassEntity
): string {
return (
Object.entries(featureEnum)
.filter(([_key, value]) => typeof value === "number")
.map(([key, value]) =>
supportsFeature(stateObj, value as number)
? titleCase(key.replaceAll("_", "\u00A0").toLowerCase())
: undefined
)
.filter(Boolean)
.join(", ") || this.hass.localize("ui.common.none")
);
}
static styles: CSSResultGroup = css`
:host {
display: flex;
@@ -42,10 +42,11 @@ export class MoreInfoLogbook extends LitElement {
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._entityIdAsList(this.entityId)}
.scope=${"entity"}
narrow
no-icon
graph-color
no-name
show-indicator
relative-time
></ha-logbook>
`;
}
+4
View File
@@ -457,6 +457,10 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
+1 -38
View File
@@ -1,7 +1,5 @@
import { startOfYesterday } from "date-fns";
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiDelete,
mdiDevices,
mdiDotsVertical,
@@ -32,7 +30,6 @@ import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack, navigate } from "../../../common/navigate";
import { createSearchParam } from "../../../common/url/search-params";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
@@ -599,30 +596,12 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
const logbookColumn = html`
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<div class="card-header logbook-header">
<span>${this.hass.localize("panel.logbook")}</span>
<a
href="/logbook?${createSearchParam({
area_id: this.areaId,
start_date: startOfYesterday().toISOString(),
back: "1",
})}"
>
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button>
</a>
</div>
<ha-card outlined .header=${this.hass.localize("panel.logbook")}>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
.scope=${"area"}
virtualize
narrow
no-icon
@@ -1000,22 +979,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
opacity: 0.5;
border-radius: var(--ha-border-radius-circle);
}
.logbook-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--ha-space-4) var(--ha-space-4) 0;
}
.logbook-header a {
display: flex;
align-items: center;
color: var(--primary-text-color);
margin-right: calc(var(--ha-space-2) * -1);
margin-inline-end: calc(var(--ha-space-2) * -1);
margin-inline-start: initial;
}
ha-logbook {
height: 400px;
}
@@ -52,7 +52,6 @@ export class HaWaitForTriggerAction
{
name: "continue_on_timeout",
selector: { boolean: {} },
default: true,
},
] as const satisfies readonly HaFormSchema[]
);
@@ -21,7 +21,7 @@ export class HaWaitAction extends LitElement implements ActionElement {
@property({ type: Boolean }) public disabled = false;
public static get defaultConfig(): WaitAction {
return { wait_template: "" };
return { wait_template: "", continue_on_timeout: true };
}
private _schema = memoizeOne(
@@ -44,7 +44,6 @@ export class HaWaitAction extends LitElement implements ActionElement {
{
name: "continue_on_timeout",
selector: { boolean: {} },
default: true,
},
] as const satisfies readonly HaFormSchema[]
);
@@ -1,9 +1,7 @@
import { startOfYesterday } from "date-fns";
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiCog,
mdiChevronRight,
mdiDelete,
mdiDotsVertical,
mdiDownload,
@@ -100,7 +98,6 @@ import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
@@ -903,29 +900,12 @@ export class HaConfigDevicePage extends LitElement {
const logbookColumn = isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<div class="card-header">
<span>${this.hass.localize("panel.logbook")}</span>
<a
href="/logbook?${createSearchParam({
device_id: this.deviceId,
start_date: startOfYesterday().toISOString(),
back: "1",
})}"
>
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button>
</a>
</div>
<h1 class="card-header">${this.hass.localize("panel.logbook")}</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
.scope=${"device"}
virtualize
narrow
no-icon
@@ -1802,22 +1782,6 @@ export class HaConfigDevicePage extends LitElement {
display: block;
}
ha-card:has(ha-logbook) .card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--ha-space-4) var(--ha-space-4) 0;
}
ha-card:has(ha-logbook) .card-header a {
display: flex;
align-items: center;
color: var(--primary-text-color);
margin-right: calc(var(--ha-space-2) * -1);
margin-inline-end: calc(var(--ha-space-2) * -1);
margin-inline-start: initial;
}
ha-card:has(ha-logbook) {
padding-bottom: var(
--ha-card-border-radius,
@@ -44,7 +44,7 @@ class DialogScheduleBlockInfo extends DirtyStateProviderMixin<ScheduleBlockInfo>
selector: { time: { no_second: true } },
},
{
name: "more_options",
name: "advanced_settings",
type: "expandable" as const,
flatten: true,
expanded: expand,
@@ -157,9 +157,9 @@ class DialogScheduleBlockInfo extends DirtyStateProviderMixin<ScheduleBlockInfo>
return this.hass!.localize("ui.dialogs.helper_settings.schedule.end");
case "data":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.data");
case "more_options":
case "advanced_settings":
return this.hass!.localize(
"ui.dialogs.helper_settings.generic.more_options"
"ui.dialogs.helper_settings.generic.advanced_settings"
);
}
return "";
@@ -125,7 +125,7 @@ class HaCounterForm extends LitElement {
></ha-input>
<ha-expansion-panel
header=${this.hass.localize(
"ui.dialogs.helper_settings.generic.more_options"
"ui.dialogs.helper_settings.generic.advanced_settings"
)}
outlined
>

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