20241223.0 (#23392)

This commit is contained in:
Bram Kragten 2024-12-23 12:23:14 +01:00 committed by GitHub
commit a7cacbbbe6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
566 changed files with 17520 additions and 6583 deletions

View File

@ -4,13 +4,12 @@
# - released in the last year + current alpha/beta versions
# - Firefox extended support release (ESR)
# - with global utilization at or above 0.5%
# - must support dynamic import of ES modules
# - exclude browsers no longer being maintained
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
unreleased versions
last 1 year
Firefox ESR
>= 0.5% and supports es6-module-dynamic-import
>= 0.5%
not dead
not KaiOS > 0
not QQAndroid > 0
@ -20,23 +19,18 @@ not UCAndroid > 0
# Legacy builds are served when modern requirements are not met and support browsers:
# - released in the last 7 years + current alpha/beta versionss
# - with global utilization at or above 0.05%
# The lattermost query ensures that support for popular old browsers is not dropped too early
# (e.g. IE 11, Android 4.4, or Samsung 4).
#
# In addition, legacy browsers must support some minimum features that cannot be polyfilled:
# - ES5 (strict mode)
# - web sockets to communicate with backend
# - inline SVG used widely in buttons, widgets, etc.
# - custom events used for most user interactions
# - CSS flexbox used in the majority of the layout
# Nearly all of these are redundant with the above rules.
# As of May 2023, only web sockets must be added to the query.
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude Opera Mini which does not support web sockets
unreleased versions
last 7 years
>= 0.05% and supports websockets
>= 0.05%
not dead
not op_mini all
[legacy-sw]
# Same as legacy plus supports service workers
unreleased versions
last 7 years
>= 0.05% and supports websockets and supports serviceworkers
>= 0.05% and supports serviceworkers
not dead
not op_mini all

View File

@ -26,24 +26,18 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
id: setup-node
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
- uses: actions/cache@v4.1.2
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: "node_modules"
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
- name: Install dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --immutable
- name: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@v4.1.2
uses: actions/cache@v4.2.0
with:
path: |
node_modules/.cache/prettier
@ -66,19 +60,11 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
id: setup-node
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
- uses: actions/cache@v4.1.2
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: "node_modules"
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
- name: Install dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --immutable
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
@ -92,26 +78,18 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
id: setup-node
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
- uses: actions/cache@v4.1.2
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: "node_modules"
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
- name: Install dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --immutable
- name: Build Application
run: ./node_modules/.bin/gulp build-app
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.4.3
uses: actions/upload-artifact@v4.5.0
with:
name: frontend-bundle-stats
path: build/stats/*.json
@ -124,26 +102,18 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
id: setup-node
uses: actions/setup-node@v4.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
- uses: actions/cache@v4.1.2
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: "node_modules"
key: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-${{ hashFiles('.yarnrc.yml') }}-${{ steps.setup-node.outputs.node-version }}
- name: Install dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --immutable
- name: Build Application
run: ./node_modules/.bin/gulp build-hassio
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@v4.4.3
uses: actions/upload-artifact@v4.5.0
with:
name: supervisor-bundle-stats
path: build/stats/*.json

View File

@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@v4.4.3
uses: actions/upload-artifact@v4.5.0
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@v4.4.3
uses: actions/upload-artifact@v4.5.0
with:
name: translations
path: translations.tar.gz

View File

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@v2.1.13
uses: relative-ci/agent-action@v2.1.14
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}

View File

@ -25,14 +25,14 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@v4.1.0
with:
@ -55,7 +55,7 @@ jobs:
script/release
- name: Upload release assets
uses: softprops/action-gh-release@v2.1.0
uses: softprops/action-gh-release@v2.2.0
with:
files: |
dist/*.whl
@ -107,7 +107,7 @@ jobs:
- name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
- name: Upload release asset
uses: softprops/action-gh-release@v2.1.0
uses: softprops/action-gh-release@v2.2.0
with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
@ -136,6 +136,6 @@ jobs:
- name: Tar folder
run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build .
- name: Upload release asset
uses: softprops/action-gh-release@v2.1.0
uses: softprops/action-gh-release@v2.2.0
with:
files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz

File diff suppressed because one or more lines are too long

View File

@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.5.2.cjs
yarnPath: .yarn/releases/yarn-4.5.3.cjs

View File

@ -5,7 +5,7 @@ import paths from "../paths.cjs";
const POLYFILL_DIR = join(paths.polymer_dir, "src/resources/polyfills");
// List of polyfill keys with supported browser targets for the functionality
const PolyfillSupport = {
const polyfillSupport = {
// Note states and shadowRoot properties should be supported.
"element-internals": {
android: 90,
@ -18,17 +18,6 @@ const PolyfillSupport = {
safari: 17.4,
samsung: 15.0,
},
"element-append": {
android: 54,
chrome: 54,
edge: 17,
firefox: 49,
ios: 10.0,
opera: 41,
opera_mobile: 41,
safari: 10.0,
samsung: 6.0,
},
"element-getattributenames": {
android: 61,
chrome: 61,
@ -51,27 +40,18 @@ const PolyfillSupport = {
safari: 12.0,
samsung: 10.0,
},
fetch: {
android: 42,
chrome: 42,
edge: 14,
firefox: 39,
ios: 10.3,
opera: 29,
opera_mobile: 29,
safari: 10.1,
samsung: 4.0,
},
// FormatJS polyfill detects fix for https://bugs.chromium.org/p/v8/issues/detail?id=10682,
// so adjusted to several months after that was marked fixed
"intl-getcanonicallocales": {
android: 54,
chrome: 54,
edge: 16,
android: 90,
chrome: 90,
edge: 90,
firefox: 48,
ios: 10.3,
opera: 41,
opera_mobile: 41,
opera: 76,
opera_mobile: 64,
safari: 10.1,
samsung: 6.0,
samsung: 15.0,
},
"intl-locale": {
android: 74,
@ -87,17 +67,6 @@ const PolyfillSupport = {
"intl-other": {
// Not specified (i.e. always try polyfill) since compatibility depends on supported locales
},
proxy: {
android: 49,
chrome: 49,
edge: 12,
firefox: 18,
ios: 10.0,
opera: 36,
opera_mobile: 36,
safari: 10.0,
samsung: 5.0,
},
"resize-observer": {
android: 64,
chrome: 64,
@ -115,8 +84,6 @@ const PolyfillSupport = {
// corresponding polyfill key and actual module to import
const polyfillMap = {
global: {
fetch: { key: "fetch", module: "unfetch/polyfill" },
Proxy: { key: "proxy", module: "proxy-polyfill" },
ResizeObserver: {
key: "resize-observer",
module: join(POLYFILL_DIR, "resize-observer.ts"),
@ -128,7 +95,7 @@ const polyfillMap = {
module: "element-internals-polyfill",
},
...Object.fromEntries(
["append", "getAttributeNames", "toggleAttribute"].map((prop) => {
["getAttributeNames", "toggleAttribute"].map((prop) => {
const key = `element-${prop.toLowerCase()}`;
return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }];
})
@ -168,7 +135,7 @@ export default defineProvider(
const resolvePolyfill = createMetaResolver(polyfillMap);
return {
name: "custom-polyfill",
polyfills: PolyfillSupport,
polyfills: polyfillSupport,
usageGlobal(meta, utils) {
const polyfill = resolvePolyfill(meta);
if (polyfill && shouldInjectPolyfill(polyfill.desc.key)) {

View File

@ -53,6 +53,11 @@ module.exports.definedVars = ({ isProdBuild, latestBuild, defineOverlay }) => ({
__SUPERVISOR__: false,
__BACKWARDS_COMPAT__: false,
__STATIC_PATH__: "/static/",
__HASS_URL__: `\`${
"HASS_URL" in process.env
? process.env["HASS_URL"]
: "${location.protocol}//${location.host}"
}\``,
"process.env.NODE_ENV": JSON.stringify(
isProdBuild ? "production" : "development"
),

View File

@ -5,9 +5,6 @@ const paths = require("./paths.cjs");
const isTrue = (value) => value === "1" || value?.toLowerCase() === "true";
module.exports = {
useWDS() {
return isTrue(process.env.WDS);
},
isProdBuild() {
return (
process.env.NODE_ENV === "production" || module.exports.isStatsBuild()

View File

@ -8,7 +8,6 @@ import "./gen-icons-json.js";
import "./locale-data.js";
import "./service-worker.js";
import "./translations.js";
import "./wds.js";
import "./rspack.js";
gulp.task(
@ -26,7 +25,7 @@ gulp.task(
"build-locale-data"
),
"copy-static-app",
env.useWDS() ? "wds-watch-app" : "rspack-watch-app"
"rspack-watch-app"
)
);

View File

@ -3,7 +3,6 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import zopfli from "gulp-zopfli-green";
import paths from "../paths.cjs";
const filesGlob = "*.{js,json,css,svg,xml}";
@ -13,56 +12,42 @@ const brotliOptions = {
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
},
};
const zopfliOptions = { threshold: 150 };
const compressDistBrotli = (rootDir, modernDir, compressServiceWorker = true) =>
const compressModern = (rootDir, modernDir) =>
gulp
.src(
[
`${modernDir}/**/${filesGlob}`,
compressServiceWorker ? `${rootDir}/sw-modern.js` : undefined,
].filter(Boolean),
{
base: rootDir,
}
)
.src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], {
base: rootDir,
allowEmpty: true,
})
.pipe(brotli(brotliOptions))
.pipe(gulp.dest(rootDir));
const compressDistZopfli = (rootDir, modernDir, compressModern = false) =>
const compressOther = (rootDir, modernDir) =>
gulp
.src(
[
`${rootDir}/**/${filesGlob}`,
compressModern ? undefined : `!${modernDir}/**/${filesGlob}`,
`!${modernDir}/**/${filesGlob}`,
`!${rootDir}/{sw-modern,service_worker}.js`,
`${rootDir}/{authorize,onboarding}.html`,
].filter(Boolean),
{ base: rootDir }
],
{ base: rootDir, allowEmpty: true }
)
.pipe(zopfli(zopfliOptions))
.pipe(brotli(brotliOptions))
.pipe(gulp.dest(rootDir));
const compressAppBrotli = () =>
compressDistBrotli(paths.app_output_root, paths.app_output_latest);
const compressHassioBrotli = () =>
compressDistBrotli(
paths.hassio_output_root,
paths.hassio_output_latest,
false
);
const compressAppModern = () =>
compressModern(paths.app_output_root, paths.app_output_latest);
const compressHassioModern = () =>
compressModern(paths.hassio_output_root, paths.hassio_output_latest);
const compressAppZopfli = () =>
compressDistZopfli(paths.app_output_root, paths.app_output_latest);
const compressHassioZopfli = () =>
compressDistZopfli(
paths.hassio_output_root,
paths.hassio_output_latest,
true
);
const compressAppOther = () =>
compressOther(paths.app_output_root, paths.app_output_latest);
const compressHassioOther = () =>
compressOther(paths.hassio_output_root, paths.hassio_output_latest);
gulp.task("compress-app", gulp.parallel(compressAppBrotli, compressAppZopfli));
gulp.task("compress-app", gulp.parallel(compressAppModern, compressAppOther));
gulp.task(
"compress-hassio",
gulp.parallel(compressHassioBrotli, compressHassioZopfli)
gulp.parallel(compressHassioModern, compressHassioOther)
);

View File

@ -11,7 +11,6 @@ import { minify } from "html-minifier-terser";
import template from "lodash.template";
import { dirname, extname, resolve } from "node:path";
import { htmlMinifierOptions, terserOptions } from "../bundle.cjs";
import env from "../env.cjs";
import paths from "../paths.cjs";
// macOS companion app has no way to obtain the Safari version used by WKWebView,
@ -56,7 +55,6 @@ const getCommonTemplateVars = () => {
{ ignorePatch: true, allowHigherVersions: true }
);
return {
useWDS: env.useWDS(),
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
};
};
@ -92,13 +90,11 @@ const minifyHtml = (content, ext) => {
};
// Function to generate a dev task for each project's configuration
// Note Currently WDS paths are hard-coded to only work for app
const genPagesDevTask =
(
pageEntries,
inputRoot,
outputRoot,
useWDS = false,
inputSub = "src/html",
publicRoot = ""
) =>
@ -109,17 +105,13 @@ const genPagesDevTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
latestEntryJS: entries.map((entry) =>
useWDS
? `http://localhost:8000/src/entrypoints/${entry}.ts`
: `${publicRoot}/frontend_latest/${entry}.js`
latestEntryJS: entries.map(
(entry) => `${publicRoot}/frontend_latest/${entry}.js`
),
es5EntryJS: entries.map(
(entry) => `${publicRoot}/frontend_es5/${entry}.js`
),
latestCustomPanelJS: useWDS
? "http://localhost:8000/src/entrypoints/custom-panel.ts"
: `${publicRoot}/frontend_latest/custom-panel.js`,
latestCustomPanelJS: `${publicRoot}/frontend_latest/custom-panel.js`,
es5CustomPanelJS: `${publicRoot}/frontend_es5/custom-panel.js`,
}
);
@ -176,12 +168,7 @@ const APP_PAGE_ENTRIES = {
gulp.task(
"gen-pages-app-dev",
genPagesDevTask(
APP_PAGE_ENTRIES,
paths.polymer_dir,
paths.app_output_root,
env.useWDS()
)
genPagesDevTask(APP_PAGE_ENTRIES, paths.polymer_dir, paths.app_output_root)
);
gulp.task(
@ -287,7 +274,6 @@ gulp.task(
HASSIO_PAGE_ENTRIES,
paths.hassio_dir,
paths.hassio_output_root,
undefined,
"src",
paths.hassio_publicPath
)

View File

@ -66,7 +66,7 @@ gulp.task("fetch-nightly-translations", async function () {
tokenAuth = JSON.parse(await readFile(TOKEN_FILE, "utf-8"));
} catch {
if (!allowTokenSetup) {
console.log("No token found so build wil continue with English only");
console.log("No token found so build will continue with English only");
return;
}
const auth = createOAuthDeviceAuth({

View File

@ -67,12 +67,6 @@ function copyPolyfills(staticDir) {
);
}
function copyLoaderJS(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(npmPath("systemjs/dist/s.min.js"), staticPath("js"));
copyFileDir(npmPath("systemjs/dist/s.min.js.map"), staticPath("js"));
}
function copyFonts(staticDir) {
const staticPath = genStaticPath(staticDir);
// Local fonts
@ -140,8 +134,6 @@ gulp.task("copy-static-app", async () => {
const staticDir = paths.app_output_static;
// Basic static files
fs.copySync(polyPath("public"), paths.app_output_root);
copyLoaderJS(staticDir);
copyPolyfills(staticDir);
copyFonts(staticDir);
copyTranslations(staticDir);
@ -164,8 +156,6 @@ gulp.task("copy-static-demo", async () => {
);
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyLoaderJS(paths.demo_output_static);
copyPolyfills(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
@ -179,8 +169,6 @@ gulp.task("copy-static-cast", async () => {
fs.copySync(polyPath("public/static"), paths.cast_output_static);
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyLoaderJS(paths.cast_output_static);
copyPolyfills(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);

View File

@ -1,10 +0,0 @@
import gulp from "gulp";
import { startDevServer } from "@web/dev-server";
gulp.task("wds-watch-app", async () => {
startDevServer({
config: {
watch: true,
},
});
});

View File

@ -14,6 +14,7 @@ import "../../../../src/panels/lovelace/views/hui-view";
import "../../../../src/panels/lovelace/views/hui-view-container";
import type { HomeAssistant } from "../../../../src/types";
import "./hc-launch-screen";
import "../../../../src/panels/lovelace/views/hui-view-background";
(window as any).loadCardHelpers = () =>
import("../../../../src/panels/lovelace/custom-card-helpers");
@ -25,9 +26,9 @@ class HcLovelace extends LitElement {
@property({ attribute: false })
public lovelaceConfig!: LovelaceConfig;
@property() public viewPath?: string | number | null;
@property({ attribute: false }) public viewPath?: string | number | null;
@property() public urlPath: string | null = null;
@property({ attribute: false }) public urlPath: string | null = null;
protected render(): TemplateResult {
const index = this._viewIndex;
@ -57,11 +58,8 @@ class HcLovelace extends LitElement {
const background = viewConfig.background || this.lovelaceConfig.background;
return html`
<hui-view-container
.hass=${this.hass}
.background=${background}
.theme=${viewConfig.theme}
>
<hui-view-container .hass=${this.hass} .theme=${viewConfig.theme}>
<hui-view-background .background=${background}> </hui-view-background>
<hui-view
.hass=${this.hass}
.lovelace=${lovelace}

View File

@ -144,10 +144,10 @@ export class HcMain extends HassElement {
}
if (senderId) {
this.sendMessage(senderId, status);
this._sendMessage(senderId, status);
} else {
for (const sender of castContext.getSenders()) {
this.sendMessage(sender.id, status);
this._sendMessage(sender.id, status);
}
}
}
@ -164,10 +164,10 @@ export class HcMain extends HassElement {
};
if (senderId) {
this.sendMessage(senderId, error);
this._sendMessage(senderId, error);
} else {
for (const sender of castContext.getSenders()) {
this.sendMessage(sender.id, error);
this._sendMessage(sender.id, error);
}
}
}
@ -394,7 +394,7 @@ export class HcMain extends HassElement {
}
}
private sendMessage(senderId: string, response: any) {
private _sendMessage(senderId: string, response: any) {
castContext.sendCustomMessage(CAST_NS, senderId, response);
}
}

View File

@ -46,7 +46,6 @@ class CastDemoRow extends LitElement implements LovelaceRow {
this.requestUpdate();
});
mgr.castContext.addEventListener(
// eslint-disable-next-line no-undef
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
// On Android, opening a new session always results in SESSION_RESUMED.

View File

@ -26,7 +26,7 @@ export class HADemoCard extends LitElement implements LovelaceCard {
@state() private _switching = false;
private _hidden = localStorage.hide_demo_card;
private _hidden = window.localStorage.getItem("hide_demo_card");
public getCardSize() {
return this._hidden ? 0 : 2;

View File

@ -7,10 +7,10 @@ import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
baseDirectory: _dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
@ -114,12 +114,10 @@ export default [
"@typescript-eslint/no-shadow": ["error"],
"@typescript-eslint/naming-convention": [
"off",
"error",
{
selector: "default",
format: ["camelCase", "snake_case"],
leadingUnderscore: "allow",
trailingUnderscore: "allow",
selector: ["objectLiteralProperty", "objectLiteralMethod"],
format: null,
},
{
selector: ["variable"],
@ -127,10 +125,27 @@ export default [
leadingUnderscore: "allow",
trailingUnderscore: "allow",
},
{
selector: ["variable"],
modifiers: ["exported"],
format: ["camelCase", "PascalCase", "UPPER_CASE"],
},
{
selector: "typeLike",
format: ["PascalCase"],
},
{
selector: "method",
modifiers: ["public"],
format: ["camelCase"],
leadingUnderscore: "forbid",
},
{
selector: "method",
modifiers: ["private"],
format: ["camelCase"],
leadingUnderscore: "require",
},
],
"@typescript-eslint/no-unused-vars": "off",
@ -147,16 +162,16 @@ export default [
],
"unused-imports/no-unused-imports": "error",
"lit/attribute-names": "warn",
"lit/attribute-names": "error",
"lit/attribute-value-entities": "off",
"lit/no-template-map": "off",
"lit/no-native-attributes": "warn",
"lit/no-this-assign-in-render": "warn",
"lit/no-native-attributes": "error",
"lit/no-this-assign-in-render": "error",
"lit-a11y/click-events-have-key-events": ["off"],
"lit-a11y/no-autofocus": "off",
"lit-a11y/alt-text": "warn",
"lit-a11y/anchor-is-valid": "warn",
"lit-a11y/role-has-required-aria-attrs": "warn",
"lit-a11y/alt-text": "error",
"lit-a11y/anchor-is-valid": "error",
"lit-a11y/role-has-required-aria-attrs": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-import-type-side-effects": "error",
},

View File

@ -9,6 +9,7 @@ import "../../../src/components/ha-card";
@customElement("demo-black-white-row")
class DemoBlackWhiteRow extends LitElement {
// eslint-disable-next-line lit/no-native-attributes
@property() title!: string;
@property() value?: any;

View File

@ -18,7 +18,8 @@ class DemoCard extends LitElement {
@property({ attribute: false }) public config!: DemoCardConfig;
@property({ type: Boolean }) public showConfig = false;
@property({ attribute: "show-config", type: Boolean })
public showConfig = false;
@state() private _size?: number;

View File

@ -44,11 +44,11 @@ class DemoCards extends LitElement {
`;
}
_showConfigToggled(ev) {
private _showConfigToggled(ev) {
this._showConfig = ev.target.checked;
}
_darkThemeToggled(ev) {
private _darkThemeToggled(ev) {
applyThemesOnElement(this._container, { themes: {} } as any, "default", {
dark: ev.target.checked,
});

View File

@ -10,9 +10,10 @@ import type { HomeAssistant } from "../../../src/types";
class DemoMoreInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entityId!: string;
@property({ attribute: false }) public entityId!: string;
@property({ type: Boolean }) public showConfig = false;
@property({ attribute: "show-config", type: Boolean })
public showConfig = false;
render() {
const state = this._getState(this.entityId, this.hass.states);
@ -23,7 +24,7 @@ class DemoMoreInfo extends LitElement {
<state-card-content
.stateObj=${state}
.hass=${this.hass}
inDialog
in-dialog
></state-card-content>
<more-info-content

View File

@ -58,11 +58,11 @@ class DemoMoreInfos extends LitElement {
}
`;
_showConfigToggled(ev) {
private _showConfigToggled(ev) {
this._showConfig = ev.target.checked;
}
_darkThemeToggled(ev) {
private _darkThemeToggled(ev) {
applyThemesOnElement(
this.shadowRoot!.querySelector("#container"),
{

View File

@ -182,7 +182,7 @@ class HaGallery extends LitElement {
}
}
_menuTapped() {
private _menuTapped() {
this._drawer.open = !this._drawer.open;
}

View File

@ -63,11 +63,6 @@ class DemoHaAutomationEditorAction extends LitElement {
}
protected render(): TemplateResult {
const valueChanged = (ev) => {
const sampleIdx = ev.target.sampleIdx;
this.data[sampleIdx] = ev.detail.value;
this.requestUpdate();
};
return html`
<div class="options">
<ha-formfield label="Disabled">
@ -92,7 +87,7 @@ class DemoHaAutomationEditorAction extends LitElement {
.actions=${this.data[sampleIdx]}
.sampleIdx=${sampleIdx}
.disabled=${this._disabled}
@value-changed=${valueChanged}
@value-changed=${this._handleValueChange}
></ha-automation-action>
`
)}
@ -102,6 +97,12 @@ class DemoHaAutomationEditorAction extends LitElement {
`;
}
private _handleValueChange(ev) {
const sampleIdx = ev.target.sampleIdx;
this.data[sampleIdx] = ev.detail.value;
this.requestUpdate();
}
private _handleOptionChange(ev) {
this[`_${ev.target.name}`] = ev.target.checked;
}

View File

@ -1,4 +1,3 @@
/* eslint-disable lit/no-template-arrow */
import type { TemplateResult } from "lit";
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators";
@ -104,11 +103,6 @@ export class DemoAutomationEditorCondition extends LitElement {
}
protected render(): TemplateResult {
const valueChanged = (ev) => {
const sampleIdx = ev.target.sampleIdx;
this.data[sampleIdx] = ev.detail.value;
this.requestUpdate();
};
return html`
<div class="options">
<ha-formfield label="Disabled">
@ -133,7 +127,7 @@ export class DemoAutomationEditorCondition extends LitElement {
.conditions=${this.data[sampleIdx]}
.sampleIdx=${sampleIdx}
.disabled=${this._disabled}
@value-changed=${valueChanged}
@value-changed=${this._handleValueChange}
></ha-automation-condition>
`
)}
@ -143,6 +137,12 @@ export class DemoAutomationEditorCondition extends LitElement {
`;
}
private _handleValueChange(ev) {
const sampleIdx = ev.target.sampleIdx;
this.data[sampleIdx] = ev.detail.value;
this.requestUpdate();
}
private _handleOptionChange(ev) {
this[`_${ev.target.name}`] = ev.target.checked;
}

View File

@ -149,11 +149,6 @@ export class DemoAutomationEditorTrigger extends LitElement {
}
protected render(): TemplateResult {
const valueChanged = (ev) => {
const sampleIdx = ev.target.sampleIdx;
this.data[sampleIdx] = ev.detail.value;
this.requestUpdate();
};
return html`
<div class="options">
<ha-formfield label="Disabled">
@ -178,7 +173,7 @@ export class DemoAutomationEditorTrigger extends LitElement {
.triggers=${this.data[sampleIdx]}
.sampleIdx=${sampleIdx}
.disabled=${this._disabled}
@value-changed=${valueChanged}
@value-changed=${this._handleValueChange}
></ha-automation-trigger>
`
)}
@ -188,6 +183,12 @@ export class DemoAutomationEditorTrigger extends LitElement {
`;
}
private _handleValueChange(ev) {
const sampleIdx = ev.target.sampleIdx;
this.data[sampleIdx] = ev.detail.value;
this.requestUpdate();
}
private _handleOptionChange(ev) {
this[`_${ev.target.name}`] = ev.target.checked;
}

View File

@ -31,22 +31,17 @@ export class DemoAutomationTrace extends LitElement {
<hat-script-graph
.trace=${trace.trace}
.selected=${this._selected[idx]}
@graph-node-selected=${(ev) => {
this._selected = { ...this._selected, [idx]: ev.detail.path };
}}
@graph-node-selected=${this._handleGraphNodeSelected}
.sampleIdx=${idx}
></hat-script-graph>
<hat-trace-timeline
allowPick
allow-pick
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
.selectedPath=${this._selected[idx]}
@value-changed=${(ev) => {
this._selected = {
...this._selected,
[idx]: ev.detail.value,
};
}}
@value-changed=${this._handleTimelineValueChanged}
.sampleIdx=${idx}
></hat-trace-timeline>
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
@ -63,6 +58,16 @@ export class DemoAutomationTrace extends LitElement {
hass.updateTranslations("config", "en");
}
private _handleTimelineValueChanged(ev) {
const sampleIdx = ev.target.sampleIdx;
this._selected = { ...this._selected, [sampleIdx]: ev.detail.value };
}
private _handleGraphNodeSelected(ev) {
const sampleIdx = ev.target.sampleIdx;
this._selected = { ...this._selected, [sampleIdx]: ev.detail.path };
}
static get styles() {
return css`
ha-card {

View File

@ -489,14 +489,8 @@ class DemoHaForm extends LitElement {
.title=${info.title}
.value=${this.data[idx]}
.disabled=${this.disabled[idx]}
@submitted=${() => {
this.disabled[idx] = true;
this.requestUpdate();
setTimeout(() => {
this.disabled[idx] = false;
this.requestUpdate();
}, 2000);
}}
@submitted=${this._handleSubmit}
.sampleIdx=${idx}
>
${["light", "dark"].map(
(slot) => html`
@ -511,10 +505,8 @@ class DemoHaForm extends LitElement {
.computeLabel=${(schema) =>
translations[schema.name] || schema.name}
.computeHelper=${() => "Helper text"}
@value-changed=${(e) => {
this.data[idx] = e.detail.value;
this.requestUpdate();
}}
@value-changed=${this._handleValueChanged}
.sampleIdx=${idx}
></ha-form>
`
)}
@ -523,6 +515,22 @@ class DemoHaForm extends LitElement {
})}
`;
}
private _handleValueChanged(ev) {
const sampleIdx = ev.target.sampleIdx;
this.data[sampleIdx] = ev.detail.value;
this.requestUpdate();
}
private _handleSubmit(ev) {
const sampleIdx = ev.target.sampleIdx;
this.disabled[sampleIdx] = true;
this.requestUpdate();
setTimeout(() => {
this.disabled[sampleIdx] = false;
this.requestUpdate();
}, 2000);
}
}
declare global {

View File

@ -1,4 +1,3 @@
/* eslint-disable lit/no-template-arrow */
import "@material/mwc-button";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
@ -591,13 +590,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
</div>
${SCHEMAS.map((info, idx) => {
const data = this.data[idx];
const valueChanged = (ev) => {
this.data[idx] = {
...data,
[ev.target.key]: ev.detail.value,
};
this.requestUpdate();
};
return html`
<demo-black-white-row .title=${info.name}>
${["light", "dark"].map((slot) =>
@ -614,7 +606,8 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
.value=${data[key] ?? value!.default}
.disabled=${this._disabled}
.required=${this._required}
@value-changed=${valueChanged}
@value-changed=${this._handleValueChanged}
.sampleIdx=${idx}
.helper=${this._helper ? "Helper text" : undefined}
></ha-selector>
</ha-settings-row>
@ -627,6 +620,15 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this.data[idx] = {
...this.data[idx],
[ev.target.key]: ev.detail.value,
};
this.requestUpdate();
}
private _handleOptionChange(ev) {
this[`_${ev.target.name}`] = ev.target.checked;
}

View File

@ -4,6 +4,7 @@ import { customElement, query } from "lit/decorators";
import { CoverEntityFeature } from "../../../../src/data/cover";
import { LightColorMode } from "../../../../src/data/light";
import { LockEntityFeature } from "../../../../src/data/lock";
import { MediaPlayerEntityFeature } from "../../../../src/data/media-player";
import { VacuumEntityFeature } from "../../../../src/data/vacuum";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
@ -28,6 +29,10 @@ const ENTITIES = [
device_class: "lock",
supported_features: LockEntityFeature.OPEN,
}),
getEntity("media_player", "living_room", "playing", {
friendly_name: "Living room speaker",
supported_features: MediaPlayerEntityFeature.VOLUME_SET,
}),
getEntity("climate", "thermostat", "heat", {
current_temperature: 73,
min_temp: 45,
@ -197,6 +202,15 @@ const CONFIGS = [
- type: "lock-open-door"
`,
},
{
heading: "Media player volume slider feature",
config: `
- type: tile
entity: media_player.living_room
features:
- type: "media-player-volume-slider"
`,
},
{
heading: "Vacuum commands feature",
config: `

View File

@ -136,7 +136,7 @@ export class HassioAddonStore extends LitElement {
this._manageRepositories(repositoryUrl);
}
this.addEventListener("hass-api-called", (ev) => this.apiCalled(ev));
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
this._loadData();
}
@ -179,7 +179,7 @@ export class HassioAddonStore extends LitElement {
}
}
private apiCalled(ev) {
private _apiCalled(ev) {
if (ev.detail.success) {
this._loadData();
}

View File

@ -58,7 +58,7 @@ export class HassioBackups extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public isWide = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _selectedBackups: string[] = [];
@ -74,7 +74,7 @@ export class HassioBackups extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && this._firstUpdatedCalled) {
this.fetchBackups();
this._fetchBackups();
}
}
@ -107,7 +107,7 @@ export class HassioBackups extends LitElement {
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (this.hass && this.isConnected) {
this.fetchBackups();
this._fetchBackups();
}
this._firstUpdatedCalled = true;
}
@ -198,7 +198,7 @@ export class HassioBackups extends LitElement {
@selection-changed=${this._handleSelectionChanged}
clickable
selectable
hasFab
has-fab
.mainPage=${!atLeastVersion(this.hass.config.version, 2021, 12)}
back-path=${atLeastVersion(this.hass.config.version, 2022, 5)
? "/config/system"
@ -280,7 +280,7 @@ export class HassioBackups extends LitElement {
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this.fetchBackups();
this._fetchBackups();
break;
case 1:
showHassioBackupLocationDialog(this, { supervisor: this.supervisor });
@ -303,13 +303,13 @@ export class HassioBackups extends LitElement {
showHassioBackupDialog(this, {
slug,
supervisor: this.supervisor,
onDelete: () => this.fetchBackups(),
onDelete: () => this._fetchBackups(),
}),
reloadBackup: () => this.fetchBackups(),
reloadBackup: () => this._fetchBackups(),
});
}
private async fetchBackups() {
private async _fetchBackups() {
this._isLoading = true;
await reloadHassioBackups(this.hass);
this._backups = await fetchHassioBackups(this.hass);
@ -341,7 +341,7 @@ export class HassioBackups extends LitElement {
});
return;
}
await this.fetchBackups();
await this._fetchBackups();
this._dataTable.clearSelection();
}
@ -350,7 +350,7 @@ export class HassioBackups extends LitElement {
showHassioBackupDialog(this, {
slug,
supervisor: this.supervisor,
onDelete: () => this.fetchBackups(),
onDelete: () => this._fetchBackups(),
});
}
@ -366,7 +366,7 @@ export class HassioBackups extends LitElement {
}
showHassioCreateBackupDialog(this, {
supervisor: this.supervisor!,
onCreate: () => this.fetchBackups(),
onCreate: () => this._fetchBackups(),
});
}

View File

@ -9,23 +9,24 @@ import type { HomeAssistant } from "../../../src/types";
class HassioCardContent extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property() public title!: string;
@property() public description?: string;
@property({ type: Boolean }) public available = true;
@property({ type: Boolean }) public showTopbar = false;
@property({ attribute: false }) public showTopbar = false;
@property() public topbarClass?: string;
@property({ attribute: false }) public topbarClass?: string;
@property() public iconTitle?: string;
@property({ attribute: false }) public iconTitle?: string;
@property() public iconClass?: string;
@property({ attribute: false }) public iconClass?: string;
@property() public icon = mdiHelpCircle;
@property() public iconImage?: string;
@property({ attribute: false }) public iconImage?: string;
protected render(): TemplateResult {
return html`
@ -35,7 +36,11 @@ class HassioCardContent extends LitElement {
${this.iconImage
? html`
<div class="icon_image ${this.iconClass}">
<img src=${this.iconImage} .title=${this.iconTitle} />
<img
src=${this.iconImage}
.title=${this.iconTitle}
alt=${this.iconTitle ?? ""}
/>
<div></div>
</div>
`

View File

@ -1,7 +1,7 @@
import { mdiFolderUpload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/ha-circular-progress";
import "../../../src/components/ha-file-upload";
@ -10,10 +10,12 @@ import { uploadBackup } from "../../../src/data/hassio/backup";
import { extractApiErrorMessage } from "../../../src/data/hassio/common";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../src/types";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
declare global {
interface HASSDomEvents {
"backup-uploaded": { backup: HassioBackup };
"backup-cleared": void;
}
}
@ -21,6 +23,8 @@ declare global {
export class HassioUploadBackup extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() public value: string | null = null;
@state() private _uploading = false;
@ -32,13 +36,26 @@ export class HassioUploadBackup extends LitElement {
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept="application/x-tar"
label="Upload backup"
supports="Supports .TAR files"
.label=${this.localize?.(
"ui.panel.page-onboarding.restore.upload_backup"
) || "Upload backup"}
.supports=${this.localize?.(
"ui.panel.page-onboarding.restore.upload_supports"
) || "Supports .TAR files"}
.secondary=${this.localize?.(
"ui.panel.page-onboarding.restore.upload_drop"
) || "Or drop your file here"}
@file-picked=${this._uploadFile}
@files-cleared=${this._clear}
></ha-file-upload>
`;
}
private _clear() {
this.value = null;
fireEvent(this, "backup-cleared");
}
private async _uploadFile(ev) {
const file = ev.detail.files[0];

View File

@ -65,7 +65,7 @@ const _computeAddons = (addons): AddonCheckboxItem[] =>
@customElement("supervisor-backup-content")
export class SupervisorBackupContent extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@ -73,23 +73,24 @@ export class SupervisorBackupContent extends LitElement {
@property({ attribute: false }) public backup?: HassioBackupDetail;
@property() public backupType: HassioBackupDetail["type"] = "full";
@property({ attribute: false })
public backupType: HassioBackupDetail["type"] = "full";
@property({ attribute: false }) public folders?: CheckboxItem[];
@property({ attribute: false }) public addons?: AddonCheckboxItem[];
@property({ type: Boolean }) public homeAssistant = false;
@property({ attribute: false }) public homeAssistant = false;
@property({ type: Boolean }) public backupHasPassword = false;
@property({ attribute: false }) public backupHasPassword = false;
@property({ type: Boolean }) public onboarding = false;
@property() public backupName = "";
@property({ attribute: false }) public backupName = "";
@property() public backupPassword = "";
@property({ attribute: false }) public backupPassword = "";
@property() public confirmBackupPassword = "";
@property({ attribute: false }) public confirmBackupPassword = "";
@query("ha-textfield, ha-radio, ha-checkbox", true) private _focusTarget;
@ -185,13 +186,14 @@ export class SupervisorBackupContent extends LitElement {
.iconPath=${mdiHomeAssistant}
.version=${this.backup
? this.backup.homeassistant
: this.hass.config.version}
: this.hass?.config.version}
>
</supervisor-formfield-label>`}
>
<ha-checkbox
.checked=${this.homeAssistant}
@change=${this.toggleHomeAssistant}
.checked=${this.onboarding || this.homeAssistant}
.disabled=${this.onboarding}
@change=${this._toggleHomeAssistant}
>
</ha-checkbox>
</ha-formfield>`
@ -277,7 +279,7 @@ export class SupervisorBackupContent extends LitElement {
`;
}
private toggleHomeAssistant() {
private _toggleHomeAssistant() {
this.homeAssistant = !this.homeAssistant;
}
@ -333,7 +335,7 @@ export class SupervisorBackupContent extends LitElement {
| HassioFullBackupCreateParams {
const data: any = {};
if (!this.backup) {
if (!this.backup && this.hass) {
data.name =
this.backupName ||
formatDate(new Date(), this.hass.locale, this.hass.config);
@ -363,7 +365,9 @@ export class SupervisorBackupContent extends LitElement {
if (folders?.length) {
data.folders = folders;
}
data.homeassistant = this.homeAssistant;
// onboarding needs at least homeassistant to restore
data.homeassistant = this.onboarding || this.homeAssistant;
return data;
}
@ -385,6 +389,7 @@ export class SupervisorBackupContent extends LitElement {
.iconPath=${section === "addons" ? mdiPuzzle : mdiFolder}
.imageUrl=${section === "addons" &&
!this.onboarding &&
this.hass &&
atLeastVersion(this.hass.config.version, 0, 105) &&
addons?.get(item.slug)?.icon
? `/api/hassio/addons/${item.slug}/icon`

View File

@ -7,9 +7,9 @@ import "../../../src/components/ha-svg-icon";
class SupervisorFormfieldLabel extends LitElement {
@property({ type: String }) public label!: string;
@property({ type: String }) public imageUrl?: string;
@property({ attribute: false }) public imageUrl?: string;
@property({ type: String }) public iconPath?: string;
@property({ attribute: false }) public iconPath?: string;
@property({ type: String }) public version?: string;

View File

@ -76,7 +76,7 @@ class HassioDashboard extends LitElement {
.mainPage=${!atLeastVersion(this.hass.config.version, 2021, 12)}
back-path="/config"
supervisor
hasFab
has-fab
>
<span slot="header">
${this.supervisor.localize(

View File

@ -8,9 +8,11 @@ import { atLeastVersion } from "../../../../src/common/config/version";
import { fireEvent } from "../../../../src/common/dom/fire_event";
import { stopPropagation } from "../../../../src/common/dom/stop_propagation";
import { slugify } from "../../../../src/common/string/slugify";
import "../../../../src/components/ha-dialog";
import "../../../../src/components/ha-md-dialog";
import "../../../../src/components/ha-dialog-header";
import "../../../../src/components/buttons/ha-progress-button";
import "../../../../src/components/ha-alert";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-button-menu";
import "../../../../src/components/ha-header-bar";
import "../../../../src/components/ha-icon-button";
@ -19,6 +21,7 @@ import type { HassioBackupDetail } from "../../../../src/data/hassio/backup";
import {
fetchHassioBackupInfo,
removeBackup,
restoreBackup,
} from "../../../../src/data/hassio/backup";
import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
import {
@ -33,6 +36,7 @@ import "../../components/supervisor-backup-content";
import type { SupervisorBackupContent } from "../../components/supervisor-backup-content";
import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup";
import type { BackupOrRestoreKey } from "../../util/translations";
import type { HaMdDialog } from "../../../../src/components/ha-md-dialog";
@customElement("dialog-hassio-backup")
class HassioBackupDialog
@ -52,13 +56,20 @@ class HassioBackupDialog
@query("supervisor-backup-content")
private _backupContent!: SupervisorBackupContent;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(dialogParams: HassioBackupDialogParams) {
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
this._dialogParams = dialogParams;
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
if (!this._backup) {
this._error = this._localize("no_backup_found");
} else if (this._dialogParams.onboarding && !this._backup.homeassistant) {
this._error = this._localize("restore_no_home_assistant");
}
this._restoringBackup = false;
}
public closeDialog() {
private _dialogClosed(): void {
this._backup = undefined;
this._dialogParams = undefined;
this._restoringBackup = false;
@ -66,6 +77,10 @@ class HassioBackupDialog
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog(): void {
this._dialog?.close();
}
private _localize(key: BackupOrRestoreKey) {
return (
this._dialogParams!.supervisor?.localize(`backup.${key}`) ||
@ -78,100 +93,80 @@ class HassioBackupDialog
return nothing;
}
return html`
<ha-dialog
<ha-md-dialog
open
scrimClickAction
@closed=${this.closeDialog}
.heading=${this._backup.name}
.disableCancelAction=${!this._error}
@closed=${this._dialogClosed}
>
<div slot="heading">
<ha-header-bar>
<span slot="title">${this._backup.name}</span>
<ha-icon-button
.label=${this._localize("close")}
.path=${mdiClose}
slot="actionItems"
dialogAction="cancel"
></ha-icon-button>
</ha-header-bar>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this._localize("close")}
.path=${mdiClose}
@click=${this.closeDialog}
.disabled=${this._restoringBackup}
></ha-icon-button>
<span slot="title" .title=${this._backup.name}
>${this._backup.name}</span
>
${!this._dialogParams.onboarding && this._dialogParams.supervisor
? html`<ha-button-menu
slot="actionItems"
fixed
@action=${this._handleMenuAction}
@closed=${stopPropagation}
>
<ha-icon-button
.label=${this._dialogParams.supervisor.localize(
"backup.more_actions"
)}
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
<mwc-list-item
>${this._dialogParams.supervisor.localize(
"backup.download_backup"
)}</mwc-list-item
>
<mwc-list-item class="error"
>${this._dialogParams.supervisor.localize(
"backup.delete_backup_title"
)}</mwc-list-item
>
</ha-button-menu>`
: nothing}
</ha-dialog-header>
<div slot="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: this._restoringBackup
? html`<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>`
: html`
<supervisor-backup-content
.hass=${this.hass}
.supervisor=${this._dialogParams.supervisor}
.backup=${this._backup}
.onboarding=${this._dialogParams.onboarding || false}
.localize=${this._dialogParams.localize}
dialogInitialFocus
>
</supervisor-backup-content>
`}
</div>
${this._restoringBackup
? html`<ha-circular-progress indeterminate></ha-circular-progress>`
: html`
<supervisor-backup-content
.hass=${this.hass}
.supervisor=${this._dialogParams.supervisor}
.backup=${this._backup}
.onboarding=${this._dialogParams.onboarding || false}
.localize=${this._dialogParams.localize}
dialogInitialFocus
>
</supervisor-backup-content>
`}
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<mwc-button
.disabled=${this._restoringBackup}
slot="secondaryAction"
@click=${this._restoreClicked}
>
${this._localize("restore")}
</mwc-button>
${!this._dialogParams.onboarding && this._dialogParams.supervisor
? html`<ha-button-menu
fixed
slot="primaryAction"
@action=${this._handleMenuAction}
@closed=${stopPropagation}
>
<ha-icon-button
.label=${this._dialogParams.supervisor.localize(
"backup.more_actions"
)}
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
<mwc-list-item
>${this._dialogParams.supervisor.localize(
"backup.download_backup"
)}</mwc-list-item
>
<mwc-list-item class="error"
>${this._dialogParams.supervisor.localize(
"backup.delete_backup_title"
)}</mwc-list-item
>
</ha-button-menu>`
: nothing}
</ha-dialog>
<div slot="actions">
<ha-button
.disabled=${this._restoringBackup || !!this._error}
@click=${this._restoreClicked}
>
${this._localize("restore")}
</ha-button>
</div>
</ha-md-dialog>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-circular-progress {
display: block;
text-align: center;
}
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
display: block;
}
ha-icon-button {
color: var(--secondary-text-color);
}
`,
];
}
private _handleMenuAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
@ -184,18 +179,9 @@ class HassioBackupDialog
}
private async _restoreClicked() {
const backupDetails = this._backupContent.backupDetails();
this._restoringBackup = true;
this._dialogParams?.onRestoring?.();
if (this._backupContent.backupType === "full") {
await this._fullRestoreClicked(backupDetails);
} else {
await this._partialRestoreClicked(backupDetails);
}
this._restoringBackup = false;
}
const backupDetails = this._backupContent.backupDetails();
private async _partialRestoreClicked(backupDetails) {
const supervisor = this._dialogParams?.supervisor;
if (supervisor !== undefined && supervisor.info.state !== "running") {
await showAlertDialog(this, {
@ -204,91 +190,45 @@ class HassioBackupDialog
state: supervisor.info.state,
}),
});
this._restoringBackup = false;
return;
}
if (
!(await showConfirmationDialog(this, {
title: this._localize("confirm_restore_partial_backup_title"),
text: this._localize("confirm_restore_partial_backup_text"),
title: this._localize(
this._backupContent.backupType === "full"
? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title"
),
text: this._localize(
this._backupContent.backupType === "full"
? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text"
),
confirmText: this._localize("restore"),
dismissText: this._localize("cancel"),
}))
) {
this._restoringBackup = false;
return;
}
if (!this._dialogParams?.onboarding) {
try {
await this.hass!.callApi(
"POST",
`hassio/${
atLeastVersion(this.hass!.config.version, 2021, 9)
? "backups"
: "snapshots"
}/${this._backup!.slug}/restore/partial`,
backupDetails
);
this.closeDialog();
} catch (error: any) {
this._error = error.body.message;
}
} else {
this._dialogParams?.onRestoring?.();
await fetch(`/api/hassio/backups/${this._backup!.slug}/restore/partial`, {
method: "POST",
body: JSON.stringify(backupDetails),
});
this.closeDialog();
}
}
private async _fullRestoreClicked(backupDetails) {
const supervisor = this._dialogParams?.supervisor;
if (supervisor !== undefined && supervisor.info.state !== "running") {
await showAlertDialog(this, {
title: supervisor.localize("backup.could_not_restore"),
text: supervisor.localize("backup.restore_blocked_not_running", {
state: supervisor.info.state,
}),
});
return;
}
if (
!(await showConfirmationDialog(this, {
title: this._localize("confirm_restore_full_backup_title"),
text: this._localize("confirm_restore_full_backup_text"),
confirmText: this._localize("restore"),
dismissText: this._localize("cancel"),
}))
) {
return;
}
if (!this._dialogParams?.onboarding) {
this.hass!.callApi(
"POST",
`hassio/${
atLeastVersion(this.hass!.config.version, 2021, 9)
? "backups"
: "snapshots"
}/${this._backup!.slug}/restore/full`,
backupDetails
).then(
() => {
this.closeDialog();
},
(error) => {
this._error = error.body.message;
}
try {
await restoreBackup(
this.hass,
this._backupContent.backupType,
this._backup!.slug,
backupDetails,
!!this.hass && atLeastVersion(this.hass.config.version, 2021, 9)
);
} else {
this._dialogParams?.onRestoring?.();
fetch(`/api/hassio/backups/${this._backup!.slug}/restore/full`, {
method: "POST",
body: JSON.stringify(backupDetails),
});
this.closeDialog();
} catch (error: any) {
this._error =
error?.body?.message || this._localize("restore_start_failed");
} finally {
this._restoringBackup = false;
}
}
@ -361,7 +301,36 @@ class HassioBackupDialog
private get _computeName() {
return this._backup
? this._backup.name || this._backup.slug
: "Unnamed backup";
: this._localize("unnamed_backup");
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-circular-progress {
display: block;
text-align: center;
}
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
display: block;
}
ha-icon-button {
color: var(--secondary-text-color);
}
.loading {
width: 100%;
display: flex;
height: 100%;
justify-content: center;
align-items: center;
}
`,
];
}
}

View File

@ -95,7 +95,7 @@ class HassioDatadiskDialog extends LitElement {
.label=${this.dialogParams.supervisor.localize(
"dialog.datadisk_move.select_device"
)}
@selected=${this._select_device}
@selected=${this._selectDevice}
dialogInitialFocus
>
${this.devices.map(
@ -137,7 +137,7 @@ class HassioDatadiskDialog extends LitElement {
`;
}
private _select_device(ev) {
private _selectDevice(ev) {
this.selectedDevice = ev.target.value;
}

View File

@ -12,6 +12,7 @@ import type { HassioMarkdownDialogParams } from "./show-dialog-hassio-markdown";
class HassioMarkdownDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property() public title!: string;
@property() public content!: string;

View File

@ -394,7 +394,7 @@ export class DialogHassioNetwork
`;
}
_toArray(data: string | string[]): string[] {
private _toArray(data: string | string[]): string[] {
if (Array.isArray(data)) {
if (data && typeof data[0] === "string") {
data = data[0];
@ -409,7 +409,7 @@ export class DialogHassioNetwork
return data;
}
_toString(data: string | string[]): string {
private _toString(data: string | string[]): string {
if (!data) {
return "";
}

View File

@ -34,7 +34,7 @@ class HassioIngressView extends LitElement {
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public ingressPanel = false;
@property({ attribute: false }) public ingressPanel = false;
@property({ type: Boolean }) public narrow = false;

View File

@ -58,10 +58,10 @@ const SUPERVISOR_UPDATE_NAMES = {
supervisor: "Home Assistant Supervisor",
};
type updateType = "os" | "supervisor" | "core" | "addon";
type UpdateType = "os" | "supervisor" | "core" | "addon";
const changelogUrl = (
entry: updateType,
entry: UpdateType,
version: string
): string | undefined => {
if (entry === "addon") {
@ -99,7 +99,7 @@ class UpdateAvailableCard extends LitElement {
@property({ attribute: false }) public addonSlug?: string;
@state() private _updateType?: updateType;
@state() private _updateType?: UpdateType;
@state() private _changelogContent?: string;
@ -222,7 +222,7 @@ class UpdateAvailableCard extends LitElement {
const updateType = ["core", "os", "supervisor"].includes(pathPart)
? pathPart
: "addon";
this._updateType = updateType as updateType;
this._updateType = updateType as UpdateType;
switch (updateType) {
case "addon":

View File

@ -64,9 +64,9 @@ class HaLandingPage extends LandingPageBaseElement {
<ha-language-picker
.value=${this.language}
.label=${""}
nativeName
native-name
@value-changed=${this._languageChanged}
inlineArrow
inline-arrow
></ha-language-picker>
<a
href="https://www.home-assistant.io/getting-started/onboarding/"
@ -122,7 +122,10 @@ class HaLandingPage extends LandingPageBaseElement {
if (language !== this.language && language) {
this.language = language;
try {
localStorage.setItem("selectedLanguage", JSON.stringify(language));
window.localStorage.setItem(
"selectedLanguage",
JSON.stringify(language)
);
} catch (err: any) {
// Ignore
}

View File

@ -8,7 +8,7 @@
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"lint:eslint": "eslint --flag unstable_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore",
"lint:eslint": "eslint --flag unstable_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint --flag unstable_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
@ -28,23 +28,23 @@
"dependencies": {
"@babel/runtime": "7.26.0",
"@braintree/sanitize-url": "7.1.0",
"@codemirror/autocomplete": "6.18.3",
"@codemirror/autocomplete": "6.18.4",
"@codemirror/commands": "6.7.1",
"@codemirror/language": "6.10.3",
"@codemirror/language": "6.10.7",
"@codemirror/legacy-modes": "6.4.2",
"@codemirror/search": "6.5.8",
"@codemirror/state": "6.4.1",
"@codemirror/view": "6.35.0",
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.36.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.16.5",
"@formatjs/intl-displaynames": "6.8.5",
"@formatjs/intl-durationformat": "0.6.4",
"@formatjs/intl-getcanonicallocales": "2.5.3",
"@formatjs/intl-listformat": "7.7.5",
"@formatjs/intl-locale": "4.2.5",
"@formatjs/intl-numberformat": "8.14.5",
"@formatjs/intl-pluralrules": "5.3.5",
"@formatjs/intl-relativetimeformat": "11.4.5",
"@formatjs/intl-datetimeformat": "6.17.1",
"@formatjs/intl-displaynames": "6.8.8",
"@formatjs/intl-durationformat": "0.7.1",
"@formatjs/intl-getcanonicallocales": "2.5.4",
"@formatjs/intl-listformat": "7.7.8",
"@formatjs/intl-locale": "4.2.8",
"@formatjs/intl-numberformat": "8.15.1",
"@formatjs/intl-pluralrules": "5.4.1",
"@formatjs/intl-relativetimeformat": "11.4.8",
"@fullcalendar/core": "6.1.15",
"@fullcalendar/daygrid": "6.1.15",
"@fullcalendar/interaction": "6.1.15",
@ -91,8 +91,8 @@
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.5.4",
"@vaadin/vaadin-themable-mixin": "24.5.4",
"@vaadin/combo-box": "24.6.0",
"@vaadin/vaadin-themable-mixin": "24.6.0",
"@vibrant/color": "3.2.1-alpha.1",
"@vibrant/core": "3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
@ -101,7 +101,8 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "2.3.1",
"chart.js": "4.4.6",
"chart.js": "4.4.7",
"chartjs-plugin-zoom": "2.2.0",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.39.0",
@ -111,23 +112,22 @@
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"element-internals-polyfill": "1.3.11",
"element-internals-polyfill": "1.3.12",
"fuse.js": "7.0.0",
"google-timezones-json": "1.2.0",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.4.0",
"idb-keyval": "6.2.1",
"intl-messageformat": "10.7.7",
"intl-messageformat": "10.7.10",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"lit": "2.8.0",
"lit-html": "2.8.0",
"luxon": "3.5.0",
"marked": "15.0.3",
"marked": "15.0.4",
"memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2",
"punycode": "2.3.1",
"qr-scanner": "1.4.2",
"qrcode": "1.5.4",
@ -140,7 +140,6 @@
"tsparticles-engine": "2.12.0",
"tsparticles-preset-links": "2.12.0",
"ua-parser-js": "1.0.39",
"unfetch": "5.0.0",
"vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16",
@ -162,46 +161,42 @@
"@babel/preset-env": "7.26.0",
"@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.17.0",
"@koa/cors": "5.0.0",
"@lokalise/node-api": "12.8.0",
"@lokalise/node-api": "13.0.0",
"@octokit/auth-oauth-device": "7.1.1",
"@octokit/plugin-retry": "7.1.2",
"@octokit/rest": "21.0.2",
"@open-wc/dev-server-hmr": "0.1.4",
"@rsdoctor/rspack-plugin": "0.4.8",
"@rspack/cli": "1.1.4",
"@rspack/core": "1.1.4",
"@rsdoctor/rspack-plugin": "0.4.12",
"@rspack/cli": "1.1.8",
"@rspack/core": "1.1.8",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.19",
"@types/chromecast-caf-receiver": "6.0.20",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.14",
"@types/leaflet": "1.9.15",
"@types/leaflet-draw": "1.0.11",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.4.2",
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.5",
"@types/serve-handler": "6.1.4",
"@types/sortablejs": "1.15.8",
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@vitest/coverage-v8": "2.1.5",
"@web/dev-server": "0.1.38",
"@vitest/coverage-v8": "2.1.8",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.15.0",
"eslint": "9.17.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "18.0.0",
"eslint-config-prettier": "9.1.0",
"eslint-import-resolver-webpack": "0.13.9",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-lit": "1.15.0",
"eslint-plugin-lit-a11y": "4.1.4",
@ -214,30 +209,25 @@
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.0.0",
"gulp-zopfli-green": "6.0.2",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "25.0.1",
"jszip": "3.10.1",
"lint-staged": "15.2.10",
"lint-staged": "15.2.11",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
"magic-string": "0.30.13",
"map-stream": "0.0.7",
"object-hash": "3.0.0",
"open": "10.1.0",
"pinst": "3.0.0",
"prettier": "3.3.3",
"prettier": "3.4.2",
"rspack-manifest-plugin": "5.0.2",
"serve-handler": "6.1.6",
"sinon": "19.0.2",
"systemjs": "6.15.1",
"tar": "7.4.3",
"terser-webpack-plugin": "5.3.10",
"transform-async-modules-webpack-plugin": "1.1.1",
"terser-webpack-plugin": "5.3.11",
"ts-lit-plugin": "2.0.2",
"typescript": "5.7.2",
"vitest": "2.1.5",
"vitest": "2.1.8",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@ -251,7 +241,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.12.0"
"globals": "15.14.0"
},
"packageManager": "yarn@4.5.2"
"packageManager": "yarn@4.5.3"
}

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20241127.9"
version = "20241223.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"

View File

@ -43,12 +43,6 @@
"description": "Group date-fns with dependent timezone package",
"groupName": "date-fns",
"matchPackageNames": ["date-fns", "date-fns-tz"]
},
{
"description": "Group and temporarily disable WDS packages",
"groupName": "Web Dev Server",
"enabled": false,
"matchPackageNames": ["@web/dev-server{/,}**"]
}
]
}

70
script/develop_and_serve Executable file
View File

@ -0,0 +1,70 @@
#!/bin/sh
#
# This script can be used to develop and test the frontend without having to
# link the build in a running core instance through the frontend/development_repo setting.
#
# WARNING:
# If you have an active login session in the frontend. The core that was used
# as a backend during the time of the login remains used until you logout again.
# So if you reuse the url hosting the frontend, you will need to logout before
# it will actually start using the core backend configured by this script.
#
# If you run this script without parameters, the frontend will be accessible under http://localhost:8124.
# And it will use the core instance running under http://localhost:8123 as a backend.
# Note that from a devcontainer, the frontend will be accessible under port 8124 on the host container.
# Inside the devcontainer it will be accessible under port 8123 instead.
# The core instance endpoint remains the same in both cases, as this is resolved from the browser.
#
# You can change the core instance the frontend connects to by passing the -c option.
# For example: script/develop_and_serve -c https://myhost.duckdns.org:8123
# This will also work for existing production core instances.
# It does not need to be a development version hosted locally.
#
# You can change the port the frontend is served on by passing the -p option.
# For example: script/develop_and_serve -p 8654
# Note that if you are running from a devcontainer, you will need to setup
# port forwarding as well if you want to access it from the container host.
# Stop on errors
set -e
cd "$(dirname "$0")/.."
# parse input paramters
if [ -n "$DEVCONTAINER" ]; then
frontendPort=8123
else
frontendPort=8124
fi
coreUrl=http://localhost:8123
while getopts p:c:h flag
do
case "${flag}" in
p) frontendPort=${OPTARG};;
c) coreUrl="${OPTARG}";;
h) echo Documentation can be found inside "$0" && exit 0;;
*) echo Documentation can be found inside "$0" && exit 1;;
esac
done
# display used settings
if [ -n "$DEVCONTAINER" ]; then
echo Frontend is available inside container as http://localhost:${frontendPort}
if [ 8123 -eq $frontendPort ]; then
echo Frontend is available on container host as http://localhost:8124
fi
else
echo Frontend is hosted on http://localhost:${frontendPort}
fi
echo Core is used from ${coreUrl}
# build the frontend so it connects to the passed core
HASS_URL="$coreUrl" ./script/develop &
# serve the frontend
yarn dlx serve -l $frontendPort ./hass_frontend -s &
# keep the script running while serving
wait

View File

@ -30,17 +30,17 @@ type State = "loading" | "error" | "step";
export class HaAuthFlow extends LitElement {
@property({ attribute: false }) public authProvider?: AuthProvider;
@property() public clientId?: string;
@property({ attribute: false }) public clientId?: string;
@property() public redirectUri?: string;
@property({ attribute: false }) public redirectUri?: string;
@property() public oauth2State?: string;
@property({ attribute: false }) public oauth2State?: string;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public step?: DataEntryFlowStep;
@property({ type: Boolean }) public initStoreToken = false;
@property({ attribute: false }) public initStoreToken = false;
@state() private _storeToken = false;

View File

@ -21,13 +21,13 @@ const appNames = {
@customElement("ha-authorize")
export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
@property() public clientId?: string;
@property({ attribute: false }) public clientId?: string;
@property() public redirectUri?: string;
@property({ attribute: false }) public redirectUri?: string;
@property() public oauth2State?: string;
@property({ attribute: false }) public oauth2State?: string;
@property() public translationFragment = "page-authorize";
@property({ attribute: false }) public translationFragment = "page-authorize";
@state() private _authProvider?: AuthProvider;
@ -202,9 +202,9 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
<ha-language-picker
.value=${this.language}
.label=${""}
nativeName
native-name
@value-changed=${this._languageChanged}
inlineArrow
inline-arrow
></ha-language-picker>
<a
href="https://www.home-assistant.io/docs/authentication/"
@ -327,7 +327,7 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
this.language = language;
try {
localStorage.setItem("selectedLanguage", JSON.stringify(language));
window.localStorage.setItem("selectedLanguage", JSON.stringify(language));
} catch (err: any) {
// Ignore
}

View File

@ -1,8 +1,6 @@
import type { AuthData } from "home-assistant-js-websocket";
import { extractSearchParam } from "../url/search-params";
const storage = window.localStorage || {};
declare global {
interface Window {
__tokenCache: {
@ -38,9 +36,15 @@ export function saveTokens(tokens: AuthData | null) {
if (tokenCache.writeEnabled) {
try {
storage.hassTokens = JSON.stringify(tokens);
window.localStorage.setItem("hassTokens", JSON.stringify(tokens));
} catch (err: any) {
// write failed, ignore it. Happens if storage is full or private mode.
// eslint-disable-next-line no-console
console.warn(
"Failed to store tokens; Are you in private mode or is your storage full?"
);
// eslint-disable-next-line no-console
console.error("Error storing tokens:", err);
}
}
}
@ -51,12 +55,11 @@ export function enableWrite() {
saveTokens(tokenCache.tokens);
}
}
export function loadTokens() {
if (tokenCache.tokens === undefined) {
try {
// Delete the old token cache.
delete storage.tokens;
const tokens = storage.hassTokens;
const tokens = window.localStorage.getItem("hassTokens");
if (tokens) {
tokenCache.tokens = JSON.parse(tokens);
tokenCache.writeEnabled = true;

View File

@ -25,9 +25,11 @@ export const rgb2hex = (rgb: [number, number, number]): string =>
// Copyright (c) 2011-2019, Gregor Aisch
// Constants for XYZ and LAB conversion
/* eslint-disable @typescript-eslint/naming-convention */
const Xn = 0.95047;
const Yn = 1;
const Zn = 1.08883;
/* eslint-enable @typescript-eslint/naming-convention */
const t0 = 0.137931034; // 4 / 29
const t1 = 0.206896552; // 6 / 29
@ -88,9 +90,9 @@ export const lab2rgb = (
x = Xn * lab_xyz(x);
z = Zn * lab_xyz(z);
const r = xyz_rgb(3.2404542 * x - 1.5371385 * y - 0.4985314 * z); // D65 -> sRGB
const g = xyz_rgb(-0.969266 * x + 1.8760108 * y + 0.041556 * z);
const b_ = xyz_rgb(0.0556434 * x - 0.2040259 * y + 1.0572252 * z);
const r = Math.round(xyz_rgb(3.2404542 * x - 1.5371385 * y - 0.4985314 * z)); // D65 -> sRGB
const g = Math.round(xyz_rgb(-0.969266 * x + 1.8760108 * y + 0.041556 * z));
const b_ = Math.round(xyz_rgb(0.0556434 * x - 0.2040259 * y + 1.0572252 * z));
return [r, g, b_];
};

View File

@ -8,9 +8,9 @@ export const temperature2rgb = (
): [number, number, number] => {
const value = temperature / 100;
return [
temperatureRed(value),
temperatureGreen(value),
temperatureBlue(value),
Math.round(temperatureRed(value)),
Math.round(temperatureGreen(value)),
Math.round(temperatureBlue(value)),
];
};
@ -59,10 +59,10 @@ const matchMaxScale = (
};
export const mired2kelvin = (miredTemperature: number) =>
Math.floor(1000000 / miredTemperature);
miredTemperature === 0 ? 1000000 : Math.floor(1000000 / miredTemperature);
export const kelvin2mired = (kelvintTemperature: number) =>
Math.floor(1000000 / kelvintTemperature);
export const kelvin2mired = (kelvinTemperature: number) =>
kelvinTemperature === 0 ? 1000000 : Math.floor(1000000 / kelvinTemperature);
export const rgbww2rgb = (
rgbww: [number, number, number, number, number],

View File

@ -14,8 +14,8 @@ export const hexBlend = (c1: string, c2: string, blend = 50): string => {
c1 = expandHex(c1);
c2 = expandHex(c2);
for (let i = 0; i <= 5; i += 2) {
const h1 = parseInt(c1.substr(i, 2), 16);
const h2 = parseInt(c2.substr(i, 2), 16);
const h1 = parseInt(c1.substring(i, i + 2), 16);
const h2 = parseInt(c2.substring(i, i + 2), 16);
let hex = Math.floor(h2 + (h1 - h2) * (blend / 100)).toString(16);
while (hex.length < 2) hex = "0" + hex;
color += hex;

View File

@ -1,12 +1,13 @@
// From https://github.com/gka/chroma.js
// Copyright (c) 2011-2019, Gregor Aisch
export const labDarken = (
lab: [number, number, number],
amount = 1
): [number, number, number] => [lab[0] - 18 * amount, lab[1], lab[2]];
export type LabColor = [number, number, number];
export const labBrighten = (
lab: [number, number, number],
amount = 1
): [number, number, number] => labDarken(lab, -amount);
export const labDarken = (lab: LabColor, amount = 1): LabColor => [
lab[0] - 18 * amount,
lab[1],
lab[2],
];
export const labBrighten = (lab: LabColor, amount = 1): LabColor =>
labDarken(lab, -amount);

View File

@ -8,20 +8,27 @@ export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
!hideAdvancedPage(hass, page) &&
isNotLoadedIntegration(hass, page);
const isLoadedIntegration = (hass: HomeAssistant, page: PageNavigation) =>
export const isLoadedIntegration = (
hass: HomeAssistant,
page: PageNavigation
) =>
!page.component ||
ensureArray(page.component).some((integration) =>
isComponentLoaded(hass, integration)
);
const isNotLoadedIntegration = (hass: HomeAssistant, page: PageNavigation) =>
export const isNotLoadedIntegration = (
hass: HomeAssistant,
page: PageNavigation
) =>
!page.not_component ||
!ensureArray(page.not_component).some((integration) =>
isComponentLoaded(hass, integration)
);
const isCore = (page: PageNavigation) => page.core;
const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
const userWantsAdvanced = (hass: HomeAssistant) => hass.userData?.showAdvanced;
const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
export const isCore = (page: PageNavigation) => page.core;
export const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
export const userWantsAdvanced = (hass: HomeAssistant) =>
hass.userData?.showAdvanced;
export const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
isAdvancedPage(page) && !userWantsAdvanced(hass);

View File

@ -1,202 +1,9 @@
/** Constants to be used in the frontend. */
import {
mdiAccount,
mdiAirFilter,
mdiAlert,
mdiAngleAcute,
mdiAppleSafari,
mdiArrowLeftRight,
mdiBell,
mdiBookmark,
mdiBrightness5,
mdiBullhorn,
mdiButtonPointer,
mdiCalendar,
mdiCalendarClock,
mdiCarCoolantLevel,
mdiCash,
mdiChatSleep,
mdiClipboardList,
mdiClock,
mdiCog,
mdiCommentAlert,
mdiCounter,
mdiCurrentAc,
mdiDatabase,
mdiEarHearing,
mdiEye,
mdiFlash,
mdiFlower,
mdiFormatListBulleted,
mdiFormatListCheckbox,
mdiFormTextbox,
mdiForumOutline,
mdiGauge,
mdiGoogleAssistant,
mdiGoogleCirclesCommunities,
mdiHomeAutomation,
mdiImage,
mdiImageFilterFrames,
mdiLightbulb,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMeterGas,
mdiMicrophoneMessage,
mdiMolecule,
mdiMoleculeCo,
mdiMoleculeCo2,
mdiPalette,
mdiPh,
mdiPipe,
mdiProgressClock,
mdiRayVertex,
mdiRemote,
mdiRobot,
mdiRobotMower,
mdiRobotVacuum,
mdiRoomService,
mdiScriptText,
mdiSineWave,
mdiSpeakerMessage,
mdiSpeedometer,
mdiSunWireless,
mdiThermometer,
mdiThermometerLines,
mdiThermostat,
mdiTimerOutline,
mdiToggleSwitch,
mdiTransmissionTower,
mdiWater,
mdiWaterPercent,
mdiWeatherPartlyCloudy,
mdiWeatherPouring,
mdiWeatherRainy,
mdiWeatherWindy,
mdiWeight,
mdiWhiteBalanceSunny,
mdiWifi,
} from "@mdi/js";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
// Constants should be alphabetically sorted by name.
// Arrays with values should be alphabetically sorted if order doesn't matter.
// Each constant should have a description what it is supposed to be used for.
/** Icon to use when no icon specified for service. */
export const DEFAULT_SERVICE_ICON = mdiRoomService;
/** Icon to use when no icon specified for domain. */
export const DEFAULT_DOMAIN_ICON = mdiBookmark;
/** Icons for each domain */
export const FIXED_DOMAIN_ICONS = {
air_quality: mdiAirFilter,
alert: mdiAlert,
automation: mdiRobot,
calendar: mdiCalendar,
climate: mdiThermostat,
configurator: mdiCog,
conversation: mdiForumOutline,
counter: mdiCounter,
date: mdiCalendar,
datetime: mdiCalendarClock,
demo: mdiHomeAssistant,
device_tracker: mdiAccount,
google_assistant: mdiGoogleAssistant,
group: mdiGoogleCirclesCommunities,
homeassistant: mdiHomeAssistant,
homekit: mdiHomeAutomation,
image_processing: mdiImageFilterFrames,
image: mdiImage,
input_boolean: mdiToggleSwitch,
input_button: mdiButtonPointer,
input_datetime: mdiCalendarClock,
input_number: mdiRayVertex,
input_select: mdiFormatListBulleted,
input_text: mdiFormTextbox,
lawn_mower: mdiRobotMower,
light: mdiLightbulb,
notify: mdiCommentAlert,
number: mdiRayVertex,
persistent_notification: mdiBell,
person: mdiAccount,
plant: mdiFlower,
proximity: mdiAppleSafari,
remote: mdiRemote,
scene: mdiPalette,
schedule: mdiCalendarClock,
script: mdiScriptText,
select: mdiFormatListBulleted,
sensor: mdiEye,
simple_alarm: mdiBell,
siren: mdiBullhorn,
stt: mdiMicrophoneMessage,
sun: mdiWhiteBalanceSunny,
text: mdiFormTextbox,
time: mdiClock,
timer: mdiTimerOutline,
todo: mdiClipboardList,
tts: mdiSpeakerMessage,
vacuum: mdiRobotVacuum,
wake_word: mdiChatSleep,
weather: mdiWeatherPartlyCloudy,
zone: mdiMapMarkerRadius,
};
export const FIXED_DEVICE_CLASS_ICONS = {
apparent_power: mdiFlash,
aqi: mdiAirFilter,
atmospheric_pressure: mdiThermometerLines,
// battery: mdiBattery, => not included by design since `sensorIcon()` will dynamically determine the icon
carbon_dioxide: mdiMoleculeCo2,
carbon_monoxide: mdiMoleculeCo,
current: mdiCurrentAc,
data_rate: mdiTransmissionTower,
data_size: mdiDatabase,
date: mdiCalendar,
distance: mdiArrowLeftRight,
duration: mdiProgressClock,
energy: mdiLightningBolt,
frequency: mdiSineWave,
gas: mdiMeterGas,
humidity: mdiWaterPercent,
illuminance: mdiBrightness5,
irradiance: mdiSunWireless,
moisture: mdiWaterPercent,
monetary: mdiCash,
nitrogen_dioxide: mdiMolecule,
nitrogen_monoxide: mdiMolecule,
nitrous_oxide: mdiMolecule,
ozone: mdiMolecule,
ph: mdiPh,
pm1: mdiMolecule,
pm10: mdiMolecule,
pm25: mdiMolecule,
power: mdiFlash,
power_factor: mdiAngleAcute,
precipitation: mdiWeatherRainy,
precipitation_intensity: mdiWeatherPouring,
pressure: mdiGauge,
reactive_power: mdiFlash,
shopping_List: mdiFormatListCheckbox,
signal_strength: mdiWifi,
sound_pressure: mdiEarHearing,
speed: mdiSpeedometer,
sulphur_dioxide: mdiMolecule,
temperature: mdiThermometer,
timestamp: mdiClock,
volatile_organic_compounds: mdiMolecule,
volatile_organic_compounds_parts: mdiMolecule,
voltage: mdiSineWave,
volume: mdiCarCoolantLevel,
volume_flow_rate: mdiPipe,
water: mdiWater,
weight: mdiWeight,
wind_speed: mdiWeatherWindy,
};
/** Domains that have a state card. */
export const DOMAINS_WITH_CARD = [
"alert",

View File

@ -1,3 +1,12 @@
import {
addMilliseconds,
addMonths,
isFirstDayOfMonth,
isLastDayOfMonth,
differenceInMilliseconds,
differenceInMonths,
endOfMonth,
} from "date-fns";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import type { HassConfig } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation";
@ -55,3 +64,55 @@ export const calcDateDifferenceProperty = (
? toZonedTime(startDate, config.time_zone)
: startDate
);
export const shiftDateRange = (
startDate: Date,
endDate: Date,
forward: boolean,
locale: FrontendLocaleData,
config: any
): { start: Date; end: Date } => {
let start: Date;
let end: Date;
if (
(calcDateProperty(
startDate,
isFirstDayOfMonth,
locale,
config
) as boolean) &&
(calcDateProperty(endDate, isLastDayOfMonth, locale, config) as boolean)
) {
const difference =
((calcDateDifferenceProperty(
endDate,
startDate,
differenceInMonths,
locale,
config
) as number) +
1) *
(forward ? 1 : -1);
start = calcDate(startDate, addMonths, locale, config, difference);
end = calcDate(
calcDate(endDate, addMonths, locale, config, difference),
endOfMonth,
locale,
config
);
} else {
const difference =
((calcDateDifferenceProperty(
endDate,
startDate,
differenceInMilliseconds,
locale,
config
) as number) +
1) *
(forward ? 1 : -1);
start = calcDate(startDate, addMilliseconds, locale, config, difference);
end = calcDate(endDate, addMilliseconds, locale, config, difference);
}
return { start, end };
};

View File

@ -29,7 +29,6 @@
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
declare global {
// eslint-disable-next-line
interface HASSDomEvents {}
}

View File

@ -14,10 +14,8 @@ export default function scrollToTarget(element, target) {
const top = 0;
const scroller = target;
const easingFn = function easeOutQuad(t, b, c, d) {
/* eslint-disable no-param-reassign, space-infix-ops, no-mixed-operators */
t /= d;
return -c * t * (t - 2) + b;
/* eslint-enable no-param-reassign, space-infix-ops, no-mixed-operators */
};
const animationId = Math.random();
const duration = 200;

View File

@ -56,13 +56,15 @@ export const computeStateDisplayFromEntityAttributes = (
}
const domain = computeDomain(entityId);
const is_number_domain =
domain === "counter" || domain === "number" || domain === "input_number";
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
if (
isNumericFromAttributes(
attributes,
domain === "sensor" ? sensorNumericDeviceClasses : []
)
) ||
is_number_domain
) {
// state is duration
if (
@ -165,20 +167,6 @@ export const computeStateDisplayFromEntityAttributes = (
}
}
// `counter` `number` and `input_number` domains do not have a unit of measurement but should still use `formatNumber`
if (
domain === "counter" ||
domain === "number" ||
domain === "input_number"
) {
// Format as an integer if the value and step are integers
return formatNumber(
state,
locale,
getNumberFormatOptions({ state, attributes } as HassEntity, entity)
);
}
// state is a timestamp
if (
[

View File

@ -15,6 +15,7 @@ export const FIXED_DOMAIN_STATES = {
"pending",
"triggered",
],
assist_satellite: ["idle", "listening", "responding", "processing"],
automation: ["on", "off"],
binary_sensor: ["on", "off"],
button: [],

View File

@ -1,4 +1,4 @@
import { historyPromise } from "../state/url-sync-mixin";
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";
@ -14,20 +14,28 @@ export interface NavigateOptions {
data?: any;
}
export const navigate = (path: string, options?: NavigateOptions) => {
const replace = options?.replace || false;
if (historyPromise) {
historyPromise.then(() => navigate(path, options));
return;
export const navigate = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (history.state?.dialog) {
const closed = await closeAllDialogs();
if (!closed) {
// eslint-disable-next-line no-console
console.warn("Navigation blocked, because dialog refused to close");
return false;
}
return new Promise<boolean>((resolve) => {
// need to wait for history state to be updated in case a dialog was closed
setTimeout(() => {
navigate(path, options).then(resolve);
});
});
}
const replace = options?.replace || false;
if (__DEMO__) {
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
history.replaceState(
history.state?.root ? { root: true } : (options?.data ?? null),
"",
`${mainWindow.location.pathname}#${path}`
);
@ -35,15 +43,16 @@ export const navigate = (path: string, options?: NavigateOptions) => {
mainWindow.location.hash = path;
}
} else if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
history.replaceState(
history.state?.root ? { root: true } : (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
return true;
};

View File

@ -12,7 +12,7 @@ export type FormatEntityAttributeValueFunc = (
attribute: string,
value?: any
) => string;
export type formatEntityAttributeNameFunc = (
export type FormatEntityAttributeNameFunc = (
stateObj: HassEntity,
attribute: string
) => string;
@ -26,7 +26,7 @@ export const computeFormatFunctions = async (
): Promise<{
formatEntityState: FormatEntityStateFunc;
formatEntityAttributeValue: FormatEntityAttributeValueFunc;
formatEntityAttributeName: formatEntityAttributeNameFunc;
formatEntityAttributeName: FormatEntityAttributeNameFunc;
}> => {
const { computeStateDisplay } = await import(
"../entity/compute_state_display"

View File

@ -94,6 +94,7 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
resources: Resources,
formats?: FormatsType
): Promise<LocalizeFunc<Keys>> => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { IntlMessageFormat } = await import("intl-messageformat");
await polyfillLocaleData(language);

View File

@ -3,7 +3,7 @@
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
// leading edge and on the trailing.
export const debounce = <T extends any[]>(
func: (...args: T) => void,
@ -14,9 +14,7 @@ export const debounce = <T extends any[]>(
const debouncedFunc = (...args: T): void => {
const later = () => {
timeout = undefined;
if (!immediate) {
func(...args);
}
func(...args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);

View File

@ -14,6 +14,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { clamp } from "../../common/number/clamp";
import type { HomeAssistant } from "../../types";
import { debounce } from "../../common/util/debounce";
import { isMac } from "../../util/is_mac";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@ -53,9 +54,10 @@ export class HaChartBase extends LitElement {
@property({ type: Number }) public height?: number;
@property({ type: Number }) public paddingYAxis = 0;
@property({ attribute: false, type: Number }) public paddingYAxis = 0;
@property({ type: Boolean }) public externalHidden = false;
@property({ attribute: "external-hidden", type: Boolean })
public externalHidden = false;
@state() private _chartHeight?: number;
@ -63,6 +65,10 @@ export class HaChartBase extends LitElement {
@state() private _hiddenDatasets: Set<number> = new Set();
@state() private _showZoomHint = false;
@state() private _isZoomed = false;
private _paddingUpdateCount = 0;
private _paddingUpdateLock = false;
@ -200,7 +206,9 @@ export class HaChartBase extends LitElement {
}
this.chart.data = this.data;
}
if (changedProps.has("options")) {
if (changedProps.has("options") && !this.chart.isZoomedOrPanned()) {
// this resets the chart zoom because min/max scales changed
// so we only do it if the user is not zooming or panning
this.chart.options = this._createOptions();
}
this.chart.update("none");
@ -248,7 +256,7 @@ export class HaChartBase extends LitElement {
})}
>
<div
class="chartContainer"
class="chart-container"
style=${styleMap({
height: `${
this.height ?? this._chartHeight ?? this.clientWidth / 2
@ -258,8 +266,26 @@ export class HaChartBase extends LitElement {
"padding-inline-start": `${this._paddingYAxisInternal}px`,
"padding-inline-end": 0,
})}
@wheel=${this._handleChartScroll}
>
<canvas></canvas>
<canvas
class=${classMap({
"not-zoomed": !this._isZoomed,
})}
></canvas>
<div
class="zoom-hint ${classMap({
visible: this._showZoomHint,
})}"
>
<div>
${isMac
? this.hass.localize(
"ui.components.history_charts.zoom_hint_mac"
)
: this.hass.localize("ui.components.history_charts.zoom_hint")}
</div>
</div>
${this._tooltip
? html`<div
class="chartTooltip ${classMap({
@ -316,6 +342,7 @@ export class HaChartBase extends LitElement {
.getContext("2d")!;
this._loading = true;
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
const ChartConstructor = (await import("../../resources/chartjs")).Chart;
const computedStyles = getComputedStyle(this);
@ -341,9 +368,13 @@ export class HaChartBase extends LitElement {
}
}
private _createOptions() {
private _createOptions(): ChartOptions {
const modifierKey = isMac ? "meta" : "ctrl";
return {
maintainAspectRatio: false,
animation: {
duration: 500,
},
...this.options,
plugins: {
...this.options?.plugins,
@ -356,6 +387,49 @@ export class HaChartBase extends LitElement {
...this.options?.plugins?.legend,
display: false,
},
zoom: {
...this.options?.plugins?.zoom,
pan: {
enabled: true,
},
zoom: {
pinch: {
enabled: true,
},
drag: {
enabled: true,
modifierKey,
threshold: 2,
},
wheel: {
enabled: true,
modifierKey,
speed: 0.05,
},
mode: "x",
onZoomComplete: () => {
const isZoomed = this.chart?.isZoomedOrPanned() ?? false;
if (this._isZoomed && !isZoomed) {
setTimeout(() => {
// make sure the scales are properly reset after full zoom out
// they get bugged when zooming in/out multiple times and panning
this.chart?.resetZoom();
});
}
this._isZoomed = isZoomed;
},
},
limits: {
x: {
min: "original",
max: (this.options?.scales?.x as any)?.max ?? "original",
},
y: {
min: "original",
max: "original",
},
},
},
},
};
}
@ -380,6 +454,16 @@ export class HaChartBase extends LitElement {
];
}
private _handleChartScroll(ev: MouseEvent) {
const modifier = isMac ? "metaKey" : "ctrlKey";
if (!ev[modifier] && !this._showZoomHint) {
this._showZoomHint = true;
setTimeout(() => {
this._showZoomHint = false;
}, 1000);
}
}
private _legendClick(ev) {
if (!this.chart) {
return;
@ -448,9 +532,16 @@ export class HaChartBase extends LitElement {
height: 0;
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chart-container {
position: relative;
}
canvas {
max-height: var(--chart-max-height, 400px);
}
canvas.not-zoomed {
/* allow scrolling if the chart is not zoomed */
touch-action: pan-y !important;
}
.chartLegend {
text-align: center;
}
@ -537,6 +628,31 @@ export class HaChartBase extends LitElement {
font-weight: 300;
word-break: break-all;
}
.zoom-hint {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 500ms cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.zoom-hint.visible {
opacity: 1;
}
.zoom-hint > div {
color: white;
font-size: 1.5em;
font-weight: 500;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
box-shadow: 0 0 32px 32px rgba(0, 0, 0, 0.3);
}
`;
}
}

View File

@ -0,0 +1,544 @@
import { customElement, property } from "lit/decorators";
import { LitElement, html, css, svg, nothing } from "lit";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../types";
export type Node = {
id: string;
value: number;
index: number; // like z-index but for x/y
label?: string;
tooltip?: string;
color?: string;
passThrough?: boolean;
};
export type Link = { source: string; target: string; value?: number };
export type SankeyChartData = {
nodes: Node[];
links: Link[];
};
type ProcessedNode = Node & {
x: number;
y: number;
size: number;
};
type ProcessedLink = Link & {
value: number;
offset: {
source: number;
target: number;
};
passThroughNodeIds: string[];
};
type Section = {
nodes: ProcessedNode[];
offset: number;
index: number;
totalValue: number;
statePerPixel: number;
};
const MIN_SIZE = 3;
const DEFAULT_COLOR = "var(--primary-color)";
const NODE_WIDTH = 15;
const FONT_SIZE = 12;
const MIN_DISTANCE = FONT_SIZE / 2;
@customElement("sankey-chart")
export class SankeyChart extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: SankeyChartData = {
nodes: [],
links: [],
};
@property({ type: Boolean }) public vertical = false;
@property({ attribute: false }) public loadingText?: string;
private _statePerPixel = 0;
private _textMeasureCanvas?: HTMLCanvasElement;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect,
});
disconnectedCallback() {
super.disconnectedCallback();
this._textMeasureCanvas = undefined;
}
willUpdate() {
this._statePerPixel = 0;
}
render() {
if (!this._sizeController.value) {
return this.loadingText ?? nothing;
}
const { width, height } = this._sizeController.value;
const { nodes, paths } = this._processNodesAndPaths(
this.data.nodes,
this.data.links
);
return html`
<svg
width=${width}
height=${height}
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
<defs>
${paths.map(
(path, i) => svg`
<linearGradient id="gradient${path.sourceNode.id}.${path.targetNode.id}.${i}" gradientTransform="${
this.vertical ? "rotate(90)" : ""
}">
<stop offset="0%" stop-color="${path.sourceNode.color}"></stop>
<stop offset="100%" stop-color="${path.targetNode.color}"></stop>
</linearGradient>
`
)}
</defs>
${paths.map(
(path, i) =>
svg`
<path d="${path.path.map(([cmd, x, y]) => `${cmd}${x},${y}`).join(" ")} Z"
fill="url(#gradient${path.sourceNode.id}.${path.targetNode.id}.${i})" fill-opacity="0.4" />
`
)}
${nodes.map((node) =>
node.passThrough
? nothing
: svg`
<g transform="translate(${node.x},${node.y})">
<rect
class="node"
width=${this.vertical ? node.size : NODE_WIDTH}
height=${this.vertical ? NODE_WIDTH : node.size}
style="fill: ${node.color}"
>
<title>${node.tooltip}</title>
</rect>
${
this.vertical
? nothing
: svg`
<text
class="node-label"
x=${NODE_WIDTH + 5}
y=${node.size / 2}
text-anchor="start"
dominant-baseline="middle"
>${node.label}</text>
`
}
</g>
`
)}
</svg>
${this.vertical
? nodes.map((node) => {
if (!node.label) {
return nothing;
}
const labelWidth = MIN_DISTANCE + node.size;
const fontSize = this._getVerticalLabelFontSize(
node.label,
labelWidth
);
return html`<div
class="node-label vertical"
style="
left: ${node.x - MIN_DISTANCE / 2}px;
top: ${node.y + NODE_WIDTH}px;
width: ${labelWidth}px;
height: ${FONT_SIZE * 3}px;
font-size: ${fontSize}px;
line-height: ${fontSize}px;
"
title=${node.label}
>
${node.label}
</div>`;
})
: nothing}
`;
}
private _processNodesAndPaths = memoizeOne(
(rawNodes: Node[], rawLinks: Link[]) => {
const filteredNodes = rawNodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
const { links, passThroughNodes } = this._processLinks(
filteredNodes,
indexes,
rawLinks
);
const nodes = this._processNodes(
[...filteredNodes, ...passThroughNodes],
indexes
);
const paths = this._processPaths(nodes, links);
return { nodes, paths };
}
);
private _processLinks(nodes: Node[], indexes: number[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
const passThroughNodes: Node[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
// handle links across sections
const sourceIndex = indexes.findIndex((i) => i === sourceNode.index);
const targetIndex = indexes.findIndex((i) => i === targetNode.index);
const passThroughSections = indexes.slice(sourceIndex + 1, targetIndex);
// create pass-through nodes to reserve space
const passThroughNodeIds = passThroughSections.map((index) => {
const node = {
passThrough: true,
id: `${sourceNode.id}-${targetNode.id}-${index}`,
value,
index,
};
passThroughNodes.push(node);
return node.id;
});
if (value > 0) {
links.push({
...link,
value,
offset: {
source: sourceAccounted / (sourceNode.value || 1),
target: targetAccounted / (targetNode.value || 1),
},
passThroughNodeIds,
});
}
});
return { links, passThroughNodes };
}
private _processNodes(filteredNodes: Node[], indexes: number[]) {
// add MIN_DISTANCE as padding
const sectionSize = this.vertical
? this._sizeController.value!.width - MIN_DISTANCE * 2
: this._sizeController.value!.height - MIN_DISTANCE * 2;
const nodesPerSection: Record<number, Node[]> = {};
filteredNodes.forEach((node) => {
if (!nodesPerSection[node.index]) {
nodesPerSection[node.index] = [node];
} else {
nodesPerSection[node.index].push(node);
}
});
const sectionFlexSize = this._getSectionFlexSize(
Object.values(nodesPerSection)
);
const sections: Section[] = indexes.map((index, i) => {
const nodes: ProcessedNode[] = nodesPerSection[index].map(
(node: Node) => ({
...node,
color: node.color || DEFAULT_COLOR,
x: 0,
y: 0,
size: 0,
})
);
const availableSpace =
sectionSize - (nodes.length * MIN_DISTANCE - MIN_DISTANCE);
const totalValue = nodes.reduce(
(acc: number, node: Node) => acc + node.value,
0
);
const { nodes: sizedNodes, statePerPixel } = this._setNodeSizes(
nodes,
availableSpace,
totalValue
);
return {
nodes: sizedNodes,
offset: sectionFlexSize * i,
index,
totalValue,
statePerPixel,
};
});
sections.forEach((section) => {
// calc sizes again with the best statePerPixel
let totalSize = 0;
if (section.statePerPixel !== this._statePerPixel) {
section.nodes.forEach((node) => {
const size = Math.max(
MIN_SIZE,
Math.floor(node.value / this._statePerPixel)
);
totalSize += size;
node.size = size;
});
} else {
totalSize = section.nodes.reduce((sum, b) => sum + b.size, 0);
}
// calc margin betwee boxes
const emptySpace = sectionSize - totalSize;
const spacerSize = emptySpace / (section.nodes.length - 1);
// account for MIN_DISTANCE padding and center single node sections
let offset =
section.nodes.length > 1 ? MIN_DISTANCE : emptySpace / 2 + MIN_DISTANCE;
// calc positions - swap x/y for vertical layout
section.nodes.forEach((node) => {
if (this.vertical) {
node.x = offset;
node.y = section.offset;
} else {
node.x = section.offset;
node.y = offset;
}
offset += node.size + spacerSize;
});
});
return sections.flatMap((section) => section.nodes);
}
private _processPaths(nodes: ProcessedNode[], links: ProcessedLink[]) {
const flowDirection = this.vertical ? "y" : "x";
const orthDirection = this.vertical ? "x" : "y"; // orthogonal to the flow
const nodesById = new Map(nodes.map((n) => [n.id, n]));
return links.map((link) => {
const { source, target, value, offset, passThroughNodeIds } = link;
const pathNodes = [source, ...passThroughNodeIds, target].map(
(id) => nodesById.get(id)!
);
const offsets = [
offset.source,
...link.passThroughNodeIds.map(() => 0),
offset.target,
];
const sourceNode = pathNodes[0];
const targetNode = pathNodes[pathNodes.length - 1];
let path: [string, number, number][] = [
[
"M",
sourceNode[flowDirection] + NODE_WIDTH,
sourceNode[orthDirection] + offset.source * sourceNode.size,
],
]; // starting point
// traverse the path forwards. stop before the last node
for (let i = 0; i < pathNodes.length - 1; i++) {
const node = pathNodes[i];
const nextNode = pathNodes[i + 1];
const flowMiddle =
(nextNode[flowDirection] - node[flowDirection]) / 2 +
node[flowDirection];
const orthStart = node[orthDirection] + offsets[i] * node.size;
const orthEnd =
nextNode[orthDirection] + offsets[i + 1] * nextNode.size;
path.push(
["L", node[flowDirection] + NODE_WIDTH, orthStart],
["C", flowMiddle, orthStart],
["", flowMiddle, orthEnd],
["", nextNode[flowDirection], orthEnd]
);
}
// traverse the path backwards. stop before the first node
for (let i = pathNodes.length - 1; i > 0; i--) {
const node = pathNodes[i];
const prevNode = pathNodes[i - 1];
const flowMiddle =
(node[flowDirection] - prevNode[flowDirection]) / 2 +
prevNode[flowDirection];
const orthStart =
node[orthDirection] +
offsets[i] * node.size +
Math.max((value / (node.value || 1)) * node.size, 0);
const orthEnd =
prevNode[orthDirection] +
offsets[i - 1] * prevNode.size +
Math.max((value / (prevNode.value || 1)) * prevNode.size, 0);
path.push(
["L", node[flowDirection], orthStart],
["C", flowMiddle, orthStart],
["", flowMiddle, orthEnd],
["", prevNode[flowDirection] + NODE_WIDTH, orthEnd]
);
}
if (this.vertical) {
// Just swap x and y coordinates for vertical layout
path = path.map((c) => [c[0], c[2], c[1]]);
}
return {
sourceNode,
targetNode,
value,
path,
};
});
}
private _setNodeSizes(
nodes: ProcessedNode[],
availableSpace: number,
totalValue: number
): { nodes: ProcessedNode[]; statePerPixel: number } {
const statePerPixel = totalValue / availableSpace;
if (statePerPixel > this._statePerPixel) {
this._statePerPixel = statePerPixel;
}
let deficitHeight = 0;
const result = nodes.map((node) => {
if (node.size === MIN_SIZE) {
return node;
}
let size = Math.floor(node.value / this._statePerPixel);
if (size < MIN_SIZE) {
deficitHeight += MIN_SIZE - size;
size = MIN_SIZE;
}
return {
...node,
size,
};
});
if (deficitHeight > 0) {
return this._setNodeSizes(
result,
availableSpace - deficitHeight,
totalValue
);
}
return { nodes: result, statePerPixel: this._statePerPixel };
}
private _getSectionFlexSize(nodesPerSection: Node[][]): number {
const fullSize = this.vertical
? this._sizeController.value!.height
: this._sizeController.value!.width;
if (nodesPerSection.length < 2) {
return fullSize;
}
let lastSectionFlexSize: number;
if (this.vertical) {
lastSectionFlexSize = FONT_SIZE * 2 + NODE_WIDTH; // estimated based on the font size + some margin
} else {
// Estimate the width needed for the last section based on label length
const lastIndex = nodesPerSection.length - 1;
const lastSectionNodes = nodesPerSection[lastIndex];
const TEXT_PADDING = 5; // Padding between node and text
lastSectionFlexSize =
lastSectionNodes.length > 0
? Math.max(
...lastSectionNodes.map(
(node) =>
NODE_WIDTH +
TEXT_PADDING +
(node.label ? this._getTextWidth(node.label) : 0)
)
)
: 0;
}
// Calculate the flex size for other sections
const remainingSize = fullSize - lastSectionFlexSize;
const flexSize = remainingSize / (nodesPerSection.length - 1);
// if the last section is bigger than the others, we make them all the same size
// this is to prevent the last section from squishing the others
return lastSectionFlexSize < flexSize
? flexSize
: fullSize / nodesPerSection.length;
}
private _getTextWidth(text: string): number {
if (!this._textMeasureCanvas) {
this._textMeasureCanvas = document.createElement("canvas");
}
const context = this._textMeasureCanvas.getContext("2d");
if (!context) return 0;
// Match the font style from CSS
context.font = `${FONT_SIZE}px sans-serif`;
return context.measureText(text).width;
}
private _getVerticalLabelFontSize(label: string, labelWidth: number): number {
// reduce the label font size so the longest word fits on one line
const longestWord = label
.split(" ")
.reduce(
(longest, current) =>
longest.length > current.length ? longest : current,
""
);
const wordWidth = this._getTextWidth(longestWord);
return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE);
}
static styles = css`
:host {
display: block;
flex: 1;
background: var(--ha-card-background, var(--card-background-color, #000));
overflow: hidden;
position: relative;
}
svg {
overflow: visible;
position: absolute;
}
.node-label {
font-size: ${FONT_SIZE}px;
fill: var(--primary-text-color, white);
}
.node-label.vertical {
position: absolute;
text-align: center;
overflow: hidden;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"sankey-chart": SankeyChart;
}
}

View File

@ -32,25 +32,28 @@ export class StateHistoryChartLine extends LitElement {
@property() public identifier?: string;
@property({ type: Boolean }) public showNames = true;
@property({ attribute: "show-names", type: Boolean })
public showNames = true;
@property({ type: Boolean }) public clickForMoreInfo = true;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@property({ attribute: false }) public startTime!: Date;
@property({ attribute: false }) public endTime!: Date;
@property({ type: Number }) public paddingYAxis = 0;
@property({ attribute: false, type: Number }) public paddingYAxis = 0;
@property({ type: Number }) public chartIndex?;
@property({ attribute: false, type: Number }) public chartIndex?;
@property({ type: Boolean }) public logarithmicScale = false;
@property({ attribute: "logarithmic-scale", type: Boolean })
public logarithmicScale = false;
@property({ type: Number }) public minYAxis?: number;
@property({ attribute: false, type: Number }) public minYAxis?: number;
@property({ type: Number }) public maxYAxis?: number;
@property({ attribute: false, type: Number }) public maxYAxis?: number;
@property({ type: Boolean }) public fitYData = false;
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@state() private _chartData?: ChartData<"line">;
@ -96,7 +99,6 @@ export class StateHistoryChartLine extends LitElement {
) {
this._chartOptions = {
parsing: false,
animation: false,
interaction: {
mode: "nearest",
axis: "xy",
@ -111,7 +113,7 @@ export class StateHistoryChartLine extends LitElement {
},
},
min: this.startTime,
suggestedMax: this.endTime,
max: this.endTime,
ticks: {
maxRotation: 0,
sampleSize: 5,

View File

@ -30,9 +30,10 @@ export class StateHistoryChartTimeline extends LitElement {
@property() public identifier?: string;
@property({ type: Boolean }) public showNames = true;
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
@property({ type: Boolean }) public clickForMoreInfo = true;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@property({ type: Boolean }) public chunked = false;
@ -40,9 +41,9 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public endTime!: Date;
@property({ type: Number }) public paddingYAxis = 0;
@property({ attribute: false, type: Number }) public paddingYAxis = 0;
@property({ type: Number }) public chartIndex?;
@property({ attribute: false, type: Number }) public chartIndex?;
@state() private _chartData?: ChartData<"timeline">;
@ -102,10 +103,9 @@ export class StateHistoryChartTimeline extends LitElement {
this._chartOptions = {
maintainAspectRatio: false,
parsing: false,
animation: false,
scales: {
x: {
type: "timeline",
type: "time",
position: "bottom",
adapters: {
date: {

View File

@ -1,5 +1,5 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import {
customElement,
eventOptions,
@ -7,6 +7,7 @@ import {
queryAll,
state,
} from "lit/decorators";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@ -58,21 +59,24 @@ export class StateHistoryCharts extends LitElement {
@property({ type: Boolean, attribute: "up-to-now" }) public upToNow = false;
@property({ type: Number }) public hoursToShow?: number;
@property({ attribute: false, type: Number }) public hoursToShow?: number;
@property({ type: Boolean }) public showNames = true;
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
@property({ type: Boolean }) public clickForMoreInfo = true;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@property({ type: Boolean }) public isLoadingData = false;
@property({ attribute: "is-loading-data", type: Boolean })
public isLoadingData = false;
@property({ type: Boolean }) public logarithmicScale = false;
@property({ attribute: "logarithmic-scale", type: Boolean })
public logarithmicScale = false;
@property({ type: Number }) public minYAxis?: number;
@property({ attribute: false, type: Number }) public minYAxis?: number;
@property({ type: Number }) public maxYAxis?: number;
@property({ attribute: false, type: Number }) public maxYAxis?: number;
@property({ type: Boolean }) public fitYData = false;
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
private _computedStartTime!: Date;
@ -122,6 +126,7 @@ export class StateHistoryCharts extends LitElement {
).concat(this.historyData.line)
: this.historyData.line;
// eslint-disable-next-line lit/no-this-assign-in-render
this._chartCount = combinedItems.length;
return this.virtualize
@ -139,12 +144,12 @@ export class StateHistoryCharts extends LitElement {
)}`;
}
private _renderHistoryItem = (
item: TimelineEntity[] | LineChartUnit,
index: number
) => {
private _renderHistoryItem: RenderItemFunction<
TimelineEntity[] | LineChartUnit
> = (item, index) => {
if (!item || index === undefined) {
return nothing;
// eslint-disable-next-line lit/prefer-nothing
return html``;
}
if (!Array.isArray(item)) {
return html`<div class="entry-container">

View File

@ -63,28 +63,28 @@ export class StatisticsChart extends LitElement {
@property({ attribute: false }) public endTime?: Date;
@property({ type: Array }) public statTypes: Array<StatisticType> = [
"sum",
"min",
"mean",
"max",
];
@property({ attribute: false, type: Array })
public statTypes: Array<StatisticType> = ["sum", "min", "mean", "max"];
@property() public chartType: ChartType = "line";
@property({ attribute: false }) public chartType: ChartType = "line";
@property({ type: Number }) public minYAxis?: number;
@property({ attribute: false, type: Number }) public minYAxis?: number;
@property({ type: Number }) public maxYAxis?: number;
@property({ attribute: false, type: Number }) public maxYAxis?: number;
@property({ type: Boolean }) public fitYData = false;
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@property({ type: Boolean }) public hideLegend = false;
@property({ attribute: "hide-legend", type: Boolean }) public hideLegend =
false;
@property({ type: Boolean }) public logarithmicScale = false;
@property({ attribute: "logarithmic-scale", type: Boolean })
public logarithmicScale = false;
@property({ type: Boolean }) public isLoadingData = false;
@property({ attribute: "is-loading-data", type: Boolean })
public isLoadingData = false;
@property({ type: Boolean }) public clickForMoreInfo = true;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@property() public period?: string;
@ -167,7 +167,7 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
externalHidden
external-hidden
.hass=${this.hass}
.data=${this._chartData}
.extraData=${this._chartDatasetExtra}
@ -194,7 +194,6 @@ export class StatisticsChart extends LitElement {
private _createOptions(unit?: string) {
this._chartOptions = {
parsing: false,
animation: false,
interaction: {
mode: "nearest",
axis: "x",

View File

@ -17,7 +17,6 @@ declare module "chart.js" {
datasetOptions: BarControllerDatasetOptions;
defaultDataPoint: TimeLineData;
parsedDataType: any;
scales: "timeline";
};
}
}

View File

@ -1,55 +0,0 @@
import { TimeScale } from "chart.js";
import type { TimeLineData } from "./const";
export class TimeLineScale extends TimeScale {
static id = "timeline";
static defaults = {
position: "bottom",
tooltips: {
mode: "nearest",
},
ticks: {
autoSkip: true,
},
};
determineDataLimits() {
const options = this.options;
// @ts-ignore
const adapter = this._adapter;
const unit = options.time.unit || "day";
let { min, max } = this.getUserBounds();
const chart = this.chart;
// Convert data to timestamps
chart.data.datasets.forEach((dataset, index) => {
if (!chart.isDatasetVisible(index)) {
return;
}
for (const data of dataset.data as TimeLineData[]) {
let timestamp0 = adapter.parse(data.start, this);
let timestamp1 = adapter.parse(data.end, this);
if (timestamp0 > timestamp1) {
[timestamp0, timestamp1] = [timestamp1, timestamp0];
}
if (min > timestamp0 && timestamp0) {
min = timestamp0;
}
if (max < timestamp1 && timestamp1) {
max = timestamp1;
}
}
});
// In case there is no valid min/max, var's use today limits
min =
isFinite(min) && !isNaN(min) ? min : +adapter.startOf(Date.now(), unit);
max = isFinite(max) && !isNaN(max) ? max : +adapter.endOf(Date.now(), unit);
// Make sure that max is strictly higher than min (required by the lookup table)
this.min = adapter.parse(options.min, this) ?? Math.min(min, max - 1);
this.max = adapter.parse(options.max, this) ?? Math.max(min + 1, max);
}
}

View File

@ -185,7 +185,7 @@ export class DialogDataTableSettings extends LitElement {
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
}
_toggle(ev) {
private _toggle(ev) {
if (!this._params) {
return;
}
@ -266,7 +266,7 @@ export class DialogDataTableSettings extends LitElement {
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
}
_reset() {
private _reset() {
this._columnOrder = undefined;
this._hiddenColumns = undefined;

View File

@ -116,7 +116,7 @@ export class HaDataTable extends LitElement {
@property({ type: Boolean }) public clickable = false;
@property({ type: Boolean }) public hasFab = false;
@property({ attribute: "has-fab", type: Boolean }) public hasFab = false;
/**
* Add an extra row at the bottom of the data table
@ -127,24 +127,25 @@ export class HaDataTable extends LitElement {
@property({ type: Boolean, attribute: "auto-height" })
public autoHeight = false;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: String }) public id = "id";
@property({ type: String }) public noDataText?: string;
@property({ attribute: false, type: String }) public noDataText?: string;
@property({ type: String }) public searchLabel?: string;
@property({ attribute: false, type: String }) public searchLabel?: string;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@property({ type: String }) public filter = "";
@property() public groupColumn?: string;
@property({ attribute: false }) public groupColumn?: string;
@property({ attribute: false }) public groupOrder?: string[];
@property() public sortColumn?: string;
@property({ attribute: false }) public sortColumn?: string;
@property() public sortDirection: SortingDirection = null;
@property({ attribute: false }) public sortDirection: SortingDirection = null;
@property({ attribute: false }) public initialCollapsedGroups?: string[];

View File

@ -11,6 +11,7 @@ import {
} from "../common/datetime/localize_date";
import { mainWindow } from "../common/dom/get_main_window";
// eslint-disable-next-line @typescript-eslint/naming-convention
const CustomDateRangePicker = Vue.extend({
mixins: [DateRangePicker],
methods: {
@ -53,6 +54,7 @@ const CustomDateRangePicker = Vue.extend({
},
});
// eslint-disable-next-line @typescript-eslint/naming-convention
const Component = Vue.extend({
props: {
timePicker: {
@ -154,6 +156,7 @@ const Component = Vue.extend({
});
// Assertion corrects HTMLElement type from package
// eslint-disable-next-line @typescript-eslint/naming-convention
const WrappedElement = wrap(
Vue,
Component

View File

@ -24,7 +24,7 @@ export abstract class HaDeviceAutomationPicker<
@property() public label?: string;
@property() public deviceId?: string;
@property({ attribute: false }) public deviceId?: string;
@property({ type: Object }) public value?: T;

View File

@ -75,7 +75,7 @@ class HaEntitiesPickerLight extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ type: Array }) public createDomains?: string[];
@property({ attribute: false, type: Array }) public createDomains?: string[];
protected render() {
if (!this.hass) {

View File

@ -13,7 +13,7 @@ export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
class HaEntityAttributePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entityId?: string;
@property({ attribute: false }) public entityId?: string;
/**
* List of attributes to be hidden.
@ -23,6 +23,7 @@ class HaEntityAttributePicker extends LitElement {
@property({ type: Array, attribute: "hide-attributes" })
public hideAttributes?: string[];
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;

View File

@ -34,6 +34,7 @@ const CREATE_ID = "___create-new-entity___";
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@ -49,7 +50,7 @@ export class HaEntityPicker extends LitElement {
@property() public helper?: string;
@property({ type: Array }) public createDomains?: string[];
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
* Show entities from specific domains.
@ -102,7 +103,8 @@ export class HaEntityPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ type: Boolean }) public hideClearIcon = false;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "item-label-path" }) public itemLabelPath =
"friendly_name";

View File

@ -79,6 +79,7 @@ class HaEntityStatePicker extends LitElement {
@property({ attribute: false }) public entityId?: string;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;

View File

@ -14,12 +14,13 @@ export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
class HaEntityStatePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entityId?: string;
@property({ attribute: false }) public entityId?: string;
@property() public attribute?: string;
@property({ attribute: false }) public extraOptions?: any[];
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;

View File

@ -55,7 +55,7 @@ export class HaStateLabelBadge extends LitElement {
@property() public image?: string;
@property({ type: Boolean }) public showName = false;
@property({ attribute: "show-name", type: Boolean }) public showName = false;
@state() private _timerTimeRemaining?: number;
@ -66,13 +66,13 @@ export class HaStateLabelBadge extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
this._connected = true;
this.startInterval(this.state);
this._startInterval(this.state);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._connected = false;
this.clearInterval();
this._clearInterval();
}
protected render(): TemplateResult {
@ -151,7 +151,7 @@ export class HaStateLabelBadge extends LitElement {
super.updated(changedProperties);
if (this._connected && changedProperties.has("state")) {
this.startInterval(this.state);
this._startInterval(this.state);
}
}
@ -237,28 +237,28 @@ export class HaStateLabelBadge extends LitElement {
return entityState.attributes.unit_of_measurement || null;
}
private clearInterval() {
private _clearInterval() {
if (this._updateRemaining) {
clearInterval(this._updateRemaining);
this._updateRemaining = undefined;
}
}
private startInterval(stateObj) {
this.clearInterval();
private _startInterval(stateObj) {
this._clearInterval();
if (stateObj && computeStateDomain(stateObj) === "timer") {
this.calculateTimerRemaining(stateObj);
this._calculateTimerRemaining(stateObj);
if (stateObj.state === "active") {
this._updateRemaining = window.setInterval(
() => this.calculateTimerRemaining(this.state),
() => this._calculateTimerRemaining(this.state),
1000
);
}
}
}
private calculateTimerRemaining(stateObj) {
private _calculateTimerRemaining(stateObj) {
this._timerTimeRemaining = timerTimeRemaining(stateObj);
}

View File

@ -39,7 +39,8 @@ export class HaStatisticPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ type: Array }) public statisticIds?: StatisticsMetaData[];
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ type: Boolean }) public disabled = false;
@ -84,7 +85,8 @@ export class HaStatisticPicker extends LitElement {
@property({ type: Array, attribute: "exclude-statistics" })
public excludeStatistics?: string[];
@property() public helpMissingEntityUrl = "/more-info/statistics/";
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
@state() private _opened?: boolean;

View File

@ -12,7 +12,7 @@ class HaStatisticsPicker extends LitElement {
@property({ type: Array }) public value?: string[];
@property({ type: Array }) public statisticIds?: string[];
@property({ attribute: false, type: Array }) public statisticIds?: string[];
@property({ attribute: "statistic-types" })
public statisticTypes?: "mean" | "sum";

View File

@ -22,9 +22,9 @@ export class StateBadge extends LitElement {
@property({ attribute: false }) public stateObj?: HassEntity;
@property() public overrideIcon?: string;
@property({ attribute: false }) public overrideIcon?: string;
@property() public overrideImage?: string;
@property({ attribute: false }) public overrideImage?: string;
// Cannot be a boolean attribute because undefined is treated different than
// false. When it is undefined, state is still colored for light entities.
@ -113,71 +113,74 @@ export class StateBadge extends LitElement {
this.icon = true;
if (stateObj && this.overrideImage === undefined) {
// hide icon if we have entity picture
if (
(stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture) &&
!this.overrideIcon
) {
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (stateObj) {
const domain = computeDomain(stateObj.entity_id);
if (this.overrideImage === undefined) {
// hide icon if we have entity picture
if (
(stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture) &&
!this.overrideIcon
) {
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
} else if (this.color) {
// Externally provided overriding color wins over state color
iconStyle.color = this.color;
} else if (this._stateColor) {
const color = stateColorCss(stateObj);
if (color) {
iconStyle.color = color;
}
if (stateObj.attributes.rgb_color) {
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
}
if (stateObj.attributes.brightness) {
const brightness = stateObj.attributes.brightness;
if (typeof brightness !== "number") {
const errorMessage = `Type error: state-badge expected number, but type of ${
stateObj.entity_id
}.attributes.brightness is ${typeof brightness} (${brightness})`;
// eslint-disable-next-line
console.warn(errorMessage);
}
iconStyle.filter = stateColorBrightness(stateObj);
}
if (stateObj.attributes.hvac_action) {
const hvacAction = stateObj.attributes.hvac_action;
if (hvacAction in CLIMATE_HVAC_ACTION_TO_MODE) {
iconStyle.color = stateColorCss(
stateObj,
CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]
)!;
} else {
delete iconStyle.color;
}
}
}
} else if (this.overrideImage) {
let imageUrl = this.overrideImage;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
const domain = computeDomain(stateObj.entity_id);
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
if (domain === "update") {
this.style.borderRadius = "0";
} else if (domain === "media_player") {
this.style.borderRadius = "8%";
}
} else if (this.color) {
// Externally provided overriding color wins over state color
iconStyle.color = this.color;
} else if (this._stateColor) {
const color = stateColorCss(stateObj);
if (color) {
iconStyle.color = color;
}
if (stateObj.attributes.rgb_color) {
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
}
if (stateObj.attributes.brightness) {
const brightness = stateObj.attributes.brightness;
if (typeof brightness !== "number") {
const errorMessage = `Type error: state-badge expected number, but type of ${
stateObj.entity_id
}.attributes.brightness is ${typeof brightness} (${brightness})`;
// eslint-disable-next-line
console.warn(errorMessage);
}
iconStyle.filter = stateColorBrightness(stateObj);
}
if (stateObj.attributes.hvac_action) {
const hvacAction = stateObj.attributes.hvac_action;
if (hvacAction in CLIMATE_HVAC_ACTION_TO_MODE) {
iconStyle.color = stateColorCss(
stateObj,
CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]
)!;
} else {
delete iconStyle.color;
}
}
}
} else if (this.overrideImage) {
let imageUrl = this.overrideImage;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
if (domain === "update") {
this.style.borderRadius = "0";
} else if (domain === "media_player" || domain === "camera") {
this.style.borderRadius = "8%";
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
}
this._iconStyle = iconStyle;

View File

@ -14,7 +14,7 @@ class StateInfo extends LitElement {
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ type: Boolean }) public inDialog = false;
@property({ attribute: "in-dialog", type: Boolean }) public inDialog = false;
@property() public color?: string;
@ -32,7 +32,7 @@ class StateInfo extends LitElement {
.color=${this.color}
></state-badge>
<div class="info">
<div class="name" .title=${name} .inDialog=${this.inDialog}>
<div class="name ${this.inDialog ? "in-dialog" : ""}" .title=${name}>
${name}
</div>
${this.inDialog
@ -108,7 +108,7 @@ class StateInfo extends LitElement {
text-overflow: ellipsis;
}
.name[inDialog],
.name.in-dialog,
:host([secondary-line]) .name {
line-height: 20px;
}

View File

@ -27,6 +27,7 @@ declare global {
@customElement("ha-alert")
class HaAlert extends LitElement {
// eslint-disable-next-line lit/no-native-attributes
@property() public title = "";
@property({ attribute: "alert-type" }) public alertType:
@ -63,7 +64,7 @@ class HaAlert extends LitElement {
<slot name="action">
${this.dismissable
? html`<ha-icon-button
@click=${this._dismiss_clicked}
@click=${this._dismissClicked}
label="Dismiss alert"
.path=${mdiClose}
></ha-icon-button>`
@ -75,7 +76,7 @@ class HaAlert extends LitElement {
`;
}
private _dismiss_clicked() {
private _dismissClicked() {
fireEvent(this, "alert-dismissed-clicked");
}

View File

@ -183,7 +183,7 @@ export class HaAnsiToHtml extends LitElement {
/* eslint-disable no-cond-assign */
let match;
// eslint-disable-next-line
while ((match = re.exec(line)) !== null) {
const j = match!.index;
const substring = line.substring(i, j);

View File

@ -0,0 +1,639 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, LitElement, html, nothing } from "lit";
import { mdiAlertCircle, mdiMicrophone, mdiSend } from "@mdi/js";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../types";
import {
runAssistPipeline,
type AssistPipeline,
} from "../data/assist_pipeline";
import { supportsFeature } from "../common/entity/supports-feature";
import { ConversationEntityFeature } from "../data/conversation";
import { AudioRecorder } from "../util/audio-recorder";
import "./ha-alert";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
import { documentationUrl } from "../util/documentation-url";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
interface AssistMessage {
who: string;
text?: string | TemplateResult;
error?: boolean;
}
@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: false })
public startListening?: boolean;
@query("#message-input") private _messageInput!: HaTextField;
@query("#scroll-container") private _scrollContainer!: HTMLDivElement;
@state() private _conversation: AssistMessage[] = [];
@state() private _showSendButton = false;
@state() private _processing = false;
private _conversationId: string | null = null;
private _audioRecorder?: AudioRecorder;
private _audioBuffer?: Int16Array[];
private _audio?: HTMLAudioElement;
private _stt_binary_handler_id?: number | null;
protected willUpdate(changedProperties: PropertyValues): void {
if (!this.hasUpdated || changedProperties.has("pipeline")) {
this._conversation = [
{
who: "hass",
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
},
];
}
}
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (
this.startListening &&
this.pipeline &&
this.pipeline.stt_engine &&
AudioRecorder.isSupported
) {
this._toggleListening();
}
setTimeout(() => this._messageInput.focus(), 0);
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_conversation")) {
this._scrollMessagesBottom();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._audioRecorder?.close();
this._audioRecorder = undefined;
this._audio?.pause();
this._conversation = [];
this._conversationId = null;
}
protected render(): TemplateResult {
const controlHA = !this.pipeline
? false
: this.pipeline.prefer_local_intents ||
(this.hass.states[this.pipeline.conversation_engine]
? supportsFeature(
this.hass.states[this.pipeline.conversation_engine],
ConversationEntityFeature.CONTROL
)
: true);
const supportsMicrophone = AudioRecorder.isSupported;
const supportsSTT = this.pipeline?.stt_engine;
return html`
${controlHA
? nothing
: html`
<ha-alert>
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
`}
<div class="messages">
<div class="messages-container" id="scroll-container">
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
`
)}
</div>
</div>
<div class="input" slot="primaryAction">
<ha-textfield
id="message-input"
@keyup=${this._handleKeyUp}
@input=${this._handleInput}
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
.iconTrailing=${true}
>
<div slot="trailingIcon">
${this._showSendButton || !supportsSTT
? html`
<ha-icon-button
class="listening-icon"
.path=${mdiSend}
@click=${this._handleSendMessage}
.disabled=${this._processing}
.label=${this.hass.localize(
"ui.dialogs.voice_command.send_text"
)}
>
</ha-icon-button>
`
: html`
${this._audioRecorder?.active
? html`
<div class="bouncer">
<div class="double-bounce1"></div>
<div class="double-bounce2"></div>
</div>
`
: nothing}
<div class="listening-icon">
<ha-icon-button
.path=${mdiMicrophone}
@click=${this._handleListeningButton}
.disabled=${this._processing}
.label=${this.hass.localize(
"ui.dialogs.voice_command.start_listening"
)}
>
</ha-icon-button>
${!supportsMicrophone
? html`
<ha-svg-icon
.path=${mdiAlertCircle}
class="unsupported"
></ha-svg-icon>
`
: null}
</div>
`}
</div>
</ha-textfield>
</div>
`;
}
private _scrollMessagesBottom() {
const scrollContainer = this._scrollContainer;
if (!scrollContainer) {
return;
}
scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
}
private _handleKeyUp(ev: KeyboardEvent) {
const input = ev.target as HaTextField;
if (!this._processing && ev.key === "Enter" && input.value) {
this._processText(input.value);
input.value = "";
this._showSendButton = false;
}
}
private _handleInput(ev: InputEvent) {
const value = (ev.target as HaTextField).value;
if (value && !this._showSendButton) {
this._showSendButton = true;
} else if (!value && this._showSendButton) {
this._showSendButton = false;
}
}
private _handleSendMessage() {
if (this._messageInput.value) {
this._processText(this._messageInput.value.trim());
this._messageInput.value = "";
this._showSendButton = false;
}
}
private _handleListeningButton(ev) {
ev.stopPropagation();
ev.preventDefault();
this._toggleListening();
}
private async _toggleListening() {
const supportsMicrophone = AudioRecorder.isSupported;
if (!supportsMicrophone) {
this._showNotSupportedMessage();
return;
}
if (!this._audioRecorder?.active) {
this._startListening();
} else {
this._stopListening();
}
}
private _addMessage(message: AssistMessage) {
this._conversation = [...this._conversation!, message];
}
private async _showNotSupportedMessage() {
this._addMessage({
who: "hass",
text:
// New lines matter for messages
// prettier-ignore
html`${this.hass.localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this.hass.localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this.hass,
"/docs/configuration/securing/#remote-access"
)}
>${this.hass.localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
)}`,
});
}
private async _startListening() {
this._processing = true;
this._audio?.pause();
if (!this._audioRecorder) {
this._audioRecorder = new AudioRecorder((audio) => {
if (this._audioBuffer) {
this._audioBuffer.push(audio);
} else {
this._sendAudioChunk(audio);
}
});
}
this._stt_binary_handler_id = undefined;
this._audioBuffer = [];
const userMessage: AssistMessage = {
who: "user",
text: "…",
};
await this._audioRecorder.start();
this._addMessage(userMessage);
this.requestUpdate("_audioRecorder");
const hassMessage: AssistMessage = {
who: "hass",
text: "…",
};
// To make sure the answer is placed at the right user text, we add it before we process it
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
if (event.type === "run-start") {
this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id;
}
// When we start STT stage, the WS has a binary handler
if (event.type === "stt-start" && this._audioBuffer) {
// Send the buffer over the WS to the STT engine.
for (const buffer of this._audioBuffer) {
this._sendAudioChunk(buffer);
}
this._audioBuffer = undefined;
}
// Stop recording if the server is done with STT stage
if (event.type === "stt-end") {
this._stt_binary_handler_id = undefined;
this._stopListening();
userMessage.text = event.data.stt_output.text;
this.requestUpdate("_conversation");
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
}
this.requestUpdate("_conversation");
}
if (event.type === "tts-end") {
const url = event.data.tts_output.url;
this._audio = new Audio(url);
this._audio.play();
this._audio.addEventListener("ended", this._unloadAudio);
this._audio.addEventListener("pause", this._unloadAudio);
this._audio.addEventListener("canplaythrough", this._playAudio);
this._audio.addEventListener("error", this._audioError);
}
if (event.type === "run-end") {
this._stt_binary_handler_id = undefined;
unsub();
}
if (event.type === "error") {
this._stt_binary_handler_id = undefined;
if (userMessage.text === "…") {
userMessage.text = event.data.message;
userMessage.error = true;
} else {
hassMessage.text = event.data.message;
hassMessage.error = true;
}
this._stopListening();
this.requestUpdate("_conversation");
unsub();
}
},
{
start_stage: "stt",
end_stage: this.pipeline?.tts_engine ? "tts" : "intent",
input: { sample_rate: this._audioRecorder.sampleRate! },
pipeline: this.pipeline?.id,
conversation_id: this._conversationId,
}
);
} catch (err: any) {
await showAlertDialog(this, {
title: "Error starting pipeline",
text: err.message || err,
});
this._stopListening();
} finally {
this._processing = false;
}
}
private _stopListening() {
this._audioRecorder?.stop();
this.requestUpdate("_audioRecorder");
// We're currently STTing, so finish audio
if (this._stt_binary_handler_id) {
if (this._audioBuffer) {
for (const chunk of this._audioBuffer) {
this._sendAudioChunk(chunk);
}
}
// Send empty message to indicate we're done streaming.
this._sendAudioChunk(new Int16Array());
this._stt_binary_handler_id = undefined;
}
this._audioBuffer = undefined;
}
private _sendAudioChunk(chunk: Int16Array) {
this.hass.connection.socket!.binaryType = "arraybuffer";
// eslint-disable-next-line eqeqeq
if (this._stt_binary_handler_id == undefined) {
return;
}
// Turn into 8 bit so we can prefix our handler ID.
const data = new Uint8Array(1 + chunk.length * 2);
data[0] = this._stt_binary_handler_id;
data.set(new Uint8Array(chunk.buffer), 1);
this.hass.connection.socket!.send(data);
}
private _playAudio = () => {
this._audio?.play();
};
private _audioError = () => {
showAlertDialog(this, { title: "Error playing audio." });
this._audio?.removeAttribute("src");
};
private _unloadAudio = () => {
this._audio?.removeAttribute("src");
this._audio = undefined;
};
private async _processText(text: string) {
this._processing = true;
this._audio?.pause();
this._addMessage({ who: "user", text });
const message: AssistMessage = {
who: "hass",
text: "…",
};
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(message);
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
message.text = plain.speech;
}
this.requestUpdate("_conversation");
unsub();
}
if (event.type === "error") {
message.text = event.data.message;
message.error = true;
this.requestUpdate("_conversation");
unsub();
}
},
{
start_stage: "intent",
input: { text },
end_stage: "intent",
pipeline: this.pipeline?.id,
conversation_id: this._conversationId,
}
);
} catch {
message.text = this.hass.localize("ui.dialogs.voice_command.error");
message.error = true;
this.requestUpdate("_conversation");
} finally {
this._processing = false;
}
}
static get styles(): CSSResultGroup {
return css`
:host {
flex: 1;
display: flex;
flex-direction: column;
min-height: var(--ha-assist-chat-min-height, 415px);
}
ha-textfield {
display: block;
margin: 0 24px 16px;
}
.messages {
flex: 1;
display: block;
box-sizing: border-box;
position: relative;
}
.messages-container {
position: absolute;
bottom: 0px;
right: 0px;
left: 0px;
padding: 24px;
box-sizing: border-box;
overflow-y: auto;
max-height: 100%;
}
.message {
white-space: pre-line;
font-size: 18px;
clear: both;
margin: 8px 0;
padding: 8px;
border-radius: 15px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.message {
font-size: 16px;
}
}
.message p {
margin: 0;
}
.message p:not(:last-child) {
margin-bottom: 8px;
}
.message.user {
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
float: var(--float-end);
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--primary-color);
color: var(--text-primary-color);
direction: var(--direction);
}
.message.hass {
margin-right: 24px;
margin-inline-end: 24px;
margin-inline-start: initial;
float: var(--float-start);
border-bottom-left-radius: 0px;
background-color: var(--secondary-background-color);
color: var(--primary-text-color);
direction: var(--direction);
}
.message.user a {
color: var(--text-primary-color);
}
.message.hass a {
color: var(--primary-text-color);
}
.message.error {
background-color: var(--error-color);
color: var(--text-primary-color);
}
.bouncer {
width: 48px;
height: 48px;
position: absolute;
}
.double-bounce1,
.double-bounce2 {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: var(--primary-color);
opacity: 0.2;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
.listening-icon {
position: relative;
color: var(--secondary-text-color);
margin-right: -24px;
margin-inline-end: -24px;
margin-inline-start: initial;
direction: var(--direction);
transform: scaleX(var(--scale-direction));
}
.listening-icon[active] {
color: var(--primary-color);
}
.unsupported {
color: var(--error-color);
position: absolute;
--mdc-icon-size: 16px;
right: 5px;
inset-inline-end: 5px;
inset-inline-start: initial;
top: 0px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-assist-chat": HaAssistChat;
}
}

View File

@ -26,7 +26,7 @@ export class HaAssistPipelinePicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ type: Boolean }) public includeLastUsed = false;
@property({ attribute: false }) public includeLastUsed = false;
@state() _pipelines?: AssistPipeline[];

View File

@ -15,7 +15,7 @@ export class HaAttributeIcon extends LitElement {
@property() public attribute?: string;
@property() public attributeValue?: string;
@property({ attribute: false }) public attributeValue?: string;
@property() public icon?: string;

View File

@ -20,7 +20,7 @@ class HaAttributes extends LitElement {
@state() private _expanded = false;
private get _filteredAttributes() {
return this.computeDisplayAttributes(
return this._computeDisplayAttributes(
STATE_ATTRIBUTES.concat(
this.extraFilters ? this.extraFilters.split(",") : []
)
@ -53,7 +53,7 @@ class HaAttributes extends LitElement {
"ui.components.attributes.expansion_header"
)}
outlined
@expanded-will-change=${this.expandedChanged}
@expanded-will-change=${this._expandedChanged}
>
<div class="attribute-container">
${this._expanded
@ -128,7 +128,7 @@ class HaAttributes extends LitElement {
];
}
private computeDisplayAttributes(filtersArray: string[]): string[] {
private _computeDisplayAttributes(filtersArray: string[]): string[] {
if (!this.stateObj) {
return [];
}
@ -137,7 +137,7 @@ class HaAttributes extends LitElement {
);
}
private expandedChanged(ev) {
private _expandedChanged(ev) {
this._expanded = ev.detail.expanded;
}
}

View File

@ -36,7 +36,7 @@ export class HaBaseTimeInput extends LitElement {
/**
* auto validate time inputs
*/
@property({ type: Boolean }) autoValidate = false;
@property({ attribute: "auto-validate", type: Boolean }) autoValidate = false;
/**
* determines if inputs are required
@ -81,52 +81,56 @@ export class HaBaseTimeInput extends LitElement {
/**
* Label for the day input
*/
@property() dayLabel = "";
@property({ attribute: false }) dayLabel = "";
/**
* Label for the hour input
*/
@property() hourLabel = "";
@property({ attribute: false }) hourLabel = "";
/**
* Label for the min input
*/
@property() minLabel = "";
@property({ attribute: false }) minLabel = "";
/**
* Label for the sec input
*/
@property() secLabel = "";
@property({ attribute: false }) secLabel = "";
/**
* Label for the milli sec input
*/
@property() millisecLabel = "";
@property({ attribute: false }) millisecLabel = "";
/**
* show the sec field
*/
@property({ type: Boolean }) enableSecond = false;
@property({ attribute: "enable-second", type: Boolean })
public enableSecond = false;
/**
* show the milli sec field
*/
@property({ type: Boolean }) enableMillisecond = false;
@property({ attribute: "enable-millisecond", type: Boolean })
public enableMillisecond = false;
/**
* show the day field
*/
@property({ type: Boolean }) enableDay = false;
@property({ attribute: "enable-day", type: Boolean })
public enableDay = false;
/**
* limit hours input
*/
@property({ type: Boolean }) noHoursLimit = false;
@property({ attribute: "no-hours-limit", type: Boolean })
public noHoursLimit = false;
/**
* AM or PM
*/
@property() amPm: "AM" | "PM" = "AM";
@property({ attribute: false }) amPm: "AM" | "PM" = "AM";
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
@ -134,7 +138,7 @@ export class HaBaseTimeInput extends LitElement {
return html`
${this.label
? html`<label>${this.label}${this.required ? " *" : ""}</label>`
: ""}
: nothing}
<div class="time-input-wrap-wrap">
<div class="time-input-wrap">
${this.enableDay
@ -158,7 +162,7 @@ export class HaBaseTimeInput extends LitElement {
>
</ha-textfield>
`
: ""}
: nothing}
<ha-textfield
id="hour"
@ -221,7 +225,7 @@ export class HaBaseTimeInput extends LitElement {
class=${this.enableMillisecond ? "has-suffix" : ""}
>
</ha-textfield>`
: ""}
: nothing}
${this.enableMillisecond
? html`<ha-textfield
id="millisec"
@ -240,7 +244,7 @@ export class HaBaseTimeInput extends LitElement {
.disabled=${this.disabled}
>
</ha-textfield>`
: ""}
: nothing}
${this.clearable && !this.required && !this.disabled
? html`<ha-icon-button
label="clear"
@ -251,7 +255,7 @@ export class HaBaseTimeInput extends LitElement {
</div>
${this.format === 24
? ""
? nothing
: html`<ha-select
.required=${this.required}
.value=${this.amPm}
@ -265,10 +269,10 @@ export class HaBaseTimeInput extends LitElement {
<mwc-list-item value="AM">AM</mwc-list-item>
<mwc-list-item value="PM">PM</mwc-list-item>
</ha-select>`}
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: ""}
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing}
`;
}
@ -363,17 +367,17 @@ export class HaBaseTimeInput extends LitElement {
width: 85px;
}
:host([clearable]) .mdc-select__anchor {
padding-inline-end: var(--select-selected-text-padding-end, 12px);
padding-inline-end: var(--select-selected-text-padding-end, 12px);
}
ha-icon-button {
position: relative
position: relative;
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
display: flex;
align-items: center;
background-color:var(--mdc-text-field-fill-color, whitesmoke);
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-bottom-style: solid;
border-bottom-width: 1px;
}
@ -398,6 +402,10 @@ export class HaBaseTimeInput extends LitElement {
padding-inline-start: 4px;
padding-inline-end: initial;
}
ha-input-helper-text {
padding-top: 8px;
line-height: normal;
}
`;
}

View File

@ -14,7 +14,8 @@ export class HaButtonMenu extends LitElement {
@property() public corner: Corner = "BOTTOM_START";
@property() public menuCorner: MenuCorner = "START";
@property({ attribute: "menu-corner" }) public menuCorner: MenuCorner =
"START";
@property({ type: Number }) public x: number | null = null;

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