mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-08 23:51:20 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7def3cb900 | ||
|
|
78a2d4d6df | ||
|
|
0f8d7b4d41 | ||
|
|
cf3009f408 | ||
|
|
bdf629f836 | ||
|
|
464b58af04 | ||
|
|
29a75209ac | ||
|
|
f89b3c23c7 | ||
|
|
29b631b960 | ||
|
|
dd94a7bac1 | ||
|
|
34154195c8 | ||
|
|
0b34fcb559 | ||
|
|
ea5a1bca99 | ||
|
|
7ae5dafc95 | ||
|
|
52cc65cbfc | ||
|
|
f36330274d | ||
|
|
a4912d0706 | ||
|
|
3900a804f0 | ||
|
|
cd5dfdb86f | ||
|
|
a602865117 | ||
|
|
f42a5012a9 | ||
|
|
90f5c7a349 | ||
|
|
d03ba15c09 | ||
|
|
7143acc860 | ||
|
|
c9c46d4507 | ||
|
|
5916775745 | ||
|
|
52ee78d8a2 | ||
|
|
146a089044 | ||
|
|
8c566b43b6 | ||
|
|
1089c5d1c5 | ||
|
|
2894113033 | ||
|
|
b9bbef3bfc | ||
|
|
706382cb68 | ||
|
|
cc17921c22 | ||
|
|
b5ef563b71 |
@@ -42,7 +42,20 @@ jobs:
|
||||
- name: Bump version
|
||||
run: script/version_bump.js nightly
|
||||
|
||||
# Warm the shared compression cache so releases reuse it (see release.yaml).
|
||||
- name: Restore compression cache
|
||||
id: compress-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -51,6 +64,14 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
- name: Save compression cache
|
||||
if: always() && steps.compress-cache.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
|
||||
@@ -49,12 +49,36 @@ jobs:
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
# Restore the content-addressed compression cache. Unchanged chunks and
|
||||
# static assets are then reused instead of re-run through brotli/zopfli.
|
||||
- name: Restore compression cache
|
||||
id: compress-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build and release package
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
run: |
|
||||
python3 -m pip install build
|
||||
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
|
||||
script/release
|
||||
|
||||
# The build prunes the cache to this dist's files, so saving keeps it
|
||||
# bounded. A unique key always writes; restore-keys picks the newest.
|
||||
- name: Save compression cache
|
||||
if: always() && steps.compress-cache.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
with:
|
||||
|
||||
@@ -6,6 +6,7 @@ build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Gulp transform that brotli-compresses files, several at a time.
|
||||
//
|
||||
// Drop-in replacement for gulp-brotli. zlib already does the work off the main
|
||||
// thread, but that plugin wraps it in through2, which waits for each file
|
||||
// before starting the next, so only one compression is ever in flight. The
|
||||
// compressed bytes are unchanged; only how many run at once differs.
|
||||
//
|
||||
// The real ceiling is libuv's threadpool, which zlib runs on. It sizes itself
|
||||
// from UV_THREADPOOL_SIZE before any JavaScript runs, so it can only be raised
|
||||
// from the environment, never from inside the build.
|
||||
|
||||
import { availableParallelism } from "node:os";
|
||||
import { buffer as readStream } from "node:stream/consumers";
|
||||
import { promisify } from "node:util";
|
||||
import { brotliCompress, constants } from "node:zlib";
|
||||
import { withCache } from "./compress-cache.mjs";
|
||||
import { ParallelTransform } from "./parallel-transform.mjs";
|
||||
|
||||
const EXTENSION = ".br";
|
||||
|
||||
const compress = promisify(brotliCompress);
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.skipLarger] Drop files that compression grows.
|
||||
* @param {object} [options.params] Brotli parameters, passed to zlib as-is.
|
||||
*/
|
||||
export default ({ skipLarger = false, params } = {}) => {
|
||||
// Isolate cache entries by anything that changes the output bytes: the brotli
|
||||
// quality, and the node major that produced them.
|
||||
const quality = params?.[constants.BROTLI_PARAM_QUALITY] ?? "default";
|
||||
const namespace = `brotli-q${quality}-node${process.versions.node.split(".")[0]}`;
|
||||
|
||||
return new ParallelTransform(availableParallelism(), async (file) => {
|
||||
if (file.isNull()) {
|
||||
return file;
|
||||
}
|
||||
if (file.isStream()) {
|
||||
file.contents = await readStream(file.contents);
|
||||
}
|
||||
|
||||
const compressed = await withCache(namespace, file.contents, async () => {
|
||||
const out = await compress(file.contents, { params });
|
||||
// Dropped rather than passed through, as gulp-brotli did: the
|
||||
// uncompressed file is already in the output directory.
|
||||
return skipLarger && out.length >= file.contents.length ? undefined : out;
|
||||
});
|
||||
if (compressed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
file.contents = compressed;
|
||||
file.path += EXTENSION;
|
||||
return file;
|
||||
});
|
||||
};
|
||||
@@ -311,7 +311,15 @@ module.exports.config = {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
dashboard: path.resolve(
|
||||
paths.e2eTestApp_dir,
|
||||
"src/dashboard-entrypoint.ts"
|
||||
),
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
onboarding: path.resolve(
|
||||
paths.e2eTestApp_dir,
|
||||
"src/onboarding-entrypoint.ts"
|
||||
),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Content-addressed cache for compression output.
|
||||
//
|
||||
// brotli (quality 11) and zopfli are the slowest part of a production build,
|
||||
// and they redo every file from scratch on every run. But production chunks are
|
||||
// content-hashed in their filenames, so a chunk that didn't change produces
|
||||
// byte-identical input here. Keying the compressed output by a hash of the
|
||||
// input bytes lets an unchanged file skip compression entirely, and lets a
|
||||
// nightly build warm the cache a release reuses the next day.
|
||||
//
|
||||
// Disabled unless COMPRESS_CACHE_DIR points at a directory. Local builds set
|
||||
// nothing and behave exactly as before. The compressed bytes are unchanged
|
||||
// either way; only whether they were recomputed differs.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const rootDir = process.env.COMPRESS_CACHE_DIR;
|
||||
export const cacheEnabled = Boolean(rootDir);
|
||||
|
||||
// Total cache size to keep across builds, least-recently-used first when over.
|
||||
// The cache is shared between branches (a dev nightly warms it for a release,
|
||||
// which builds from rc/master), so pruning must NOT drop everything the current
|
||||
// build didn't touch — that would make each branch evict the other's
|
||||
// branch-specific files every run. Instead the current build is pinned and the
|
||||
// rest is kept up to this budget, so nothing is dropped unless the cache is
|
||||
// genuinely too large. Override with COMPRESS_CACHE_MAX_BYTES.
|
||||
const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB
|
||||
const maxBytes = () =>
|
||||
Number(process.env.COMPRESS_CACHE_MAX_BYTES) || DEFAULT_MAX_BYTES;
|
||||
|
||||
// Every cache key touched this build, hits and writes alike, so a prune can pin
|
||||
// the current dist and never evict a file this build depends on.
|
||||
const touched = new Set();
|
||||
|
||||
// Per-namespace directory, created lazily and only once.
|
||||
const dirs = new Map();
|
||||
|
||||
let tmpCounter = 0;
|
||||
|
||||
const sha256 = (contents) =>
|
||||
createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
const namespaceDir = (namespace) => {
|
||||
let dir = dirs.get(namespace);
|
||||
if (!dir) {
|
||||
const dirPath = path.join(rootDir, namespace);
|
||||
dir = { path: dirPath, ready: undefined };
|
||||
dirs.set(namespace, dir);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const ensureDir = (dir) => {
|
||||
dir.ready ??= mkdir(dir.path, { recursive: true });
|
||||
return dir.ready;
|
||||
};
|
||||
|
||||
// Written via a unique temp file and renamed into place so a concurrent reader
|
||||
// in the same build never sees a half-written entry (rename is atomic on the
|
||||
// same filesystem).
|
||||
const writeAtomic = async (dir, name, contents) => {
|
||||
await ensureDir(dir);
|
||||
tmpCounter += 1;
|
||||
const tmp = path.join(dir.path, `.${process.pid}-${tmpCounter}.tmp`);
|
||||
await writeFile(tmp, contents);
|
||||
await rename(tmp, path.join(dir.path, name));
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the cached compression result for `contents`, or run `compute` and
|
||||
* cache what it returns. `compute` resolves to a Buffer for compressed output,
|
||||
* or `undefined` when the file should be dropped (brotli skipLarger); both
|
||||
* outcomes are cached, so a dropped file is not recompressed on the next build.
|
||||
*
|
||||
* @param {string} namespace Isolates entries by algorithm, parameters and tool
|
||||
* version, so a change to any of them can never return a stale result.
|
||||
* @param {Buffer} contents Uncompressed input bytes, used as the cache key.
|
||||
* @param {() => Promise<Buffer | undefined>} compute Runs on a cache miss.
|
||||
* @returns {Promise<Buffer | undefined>}
|
||||
*/
|
||||
export const withCache = async (namespace, contents, compute) => {
|
||||
if (!cacheEnabled) {
|
||||
return compute();
|
||||
}
|
||||
|
||||
const dir = namespaceDir(namespace);
|
||||
const hash = sha256(contents);
|
||||
touched.add(`${namespace}/${hash}`);
|
||||
|
||||
const dataPath = path.join(dir.path, hash);
|
||||
const dropName = `${hash}.drop`;
|
||||
|
||||
try {
|
||||
return await readFile(dataPath);
|
||||
} catch {
|
||||
// Miss (or unreadable) — fall through and compute.
|
||||
}
|
||||
if (existsSync(path.join(dir.path, dropName))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = await compute();
|
||||
if (result === undefined) {
|
||||
await writeAtomic(dir, dropName, "");
|
||||
} else {
|
||||
await writeAtomic(dir, hash, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the cache under `maxBytes`, evicting least-recently-used entries first.
|
||||
* Files this build touched are always kept (and their timestamp refreshed, so
|
||||
* shared files stay warm across branches); the remainder — including another
|
||||
* branch's entries — is kept up to the budget. No-op when the cache is disabled
|
||||
* or nothing was compressed, so it never wipes a warm cache on a build that
|
||||
* skipped compression.
|
||||
*/
|
||||
export const pruneCache = async () => {
|
||||
if (!cacheEnabled || touched.size === 0 || !existsSync(rootDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const pinned = [];
|
||||
const others = [];
|
||||
|
||||
// Scan every namespace on disk, not just the ones this build used, so entries
|
||||
// from an old tool version (a different namespace) are eligible for eviction.
|
||||
const namespaces = await readdir(rootDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
namespaces.map(async (ns) => {
|
||||
if (!ns.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
const nsDir = path.join(rootDir, ns.name);
|
||||
const entries = await readdir(nsDir);
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(nsDir, entry);
|
||||
// Stray temp files from an interrupted write are never valid entries.
|
||||
if (entry.endsWith(".tmp")) {
|
||||
await rm(entryPath, { force: true });
|
||||
return;
|
||||
}
|
||||
const hash = entry.endsWith(".drop") ? entry.slice(0, -5) : entry;
|
||||
const info = await stat(entryPath);
|
||||
if (touched.has(`${ns.name}/${hash}`)) {
|
||||
pinned.push({ entryPath, size: info.size });
|
||||
} else {
|
||||
others.push({ entryPath, size: info.size, mtimeMs: info.mtimeMs });
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Pinned files stay and are refreshed so they rank as most-recently-used for
|
||||
// future prunes; they always count against the budget first.
|
||||
let kept = 0;
|
||||
await Promise.all(
|
||||
pinned.map(async ({ entryPath, size }) => {
|
||||
kept += size;
|
||||
// A failed timestamp refresh only affects future LRU ordering, not
|
||||
// correctness, so it is safe to ignore.
|
||||
await utimes(entryPath, now, now).catch(() => undefined);
|
||||
})
|
||||
);
|
||||
|
||||
// Keep the most-recently-used others until the budget is spent; drop the rest.
|
||||
others.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
const budget = maxBytes();
|
||||
const toDelete = [];
|
||||
for (const entry of others) {
|
||||
if (kept + entry.size <= budget) {
|
||||
kept += entry.size;
|
||||
} else {
|
||||
toDelete.push(entry.entryPath);
|
||||
}
|
||||
}
|
||||
await Promise.all(toDelete.map((p) => rm(p, { force: true })));
|
||||
};
|
||||
@@ -50,7 +50,9 @@ gulp.task(
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
// Don't compress running tests
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
|
||||
...(env.isTestBuild() || env.isStatsBuild()
|
||||
? []
|
||||
: ["compress-app", "prune-compress-cache"])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { constants } from "node:zlib";
|
||||
import gulp from "gulp";
|
||||
import brotli from "gulp-brotli";
|
||||
import brotli from "../brotli.mjs";
|
||||
import { pruneCache } from "../compress-cache.mjs";
|
||||
import paths from "../paths.cjs";
|
||||
import zopfli from "../zopfli.mjs";
|
||||
|
||||
@@ -57,3 +58,7 @@ gulp.task(
|
||||
compressAppOtherZopfli
|
||||
)
|
||||
);
|
||||
|
||||
// Drop cache entries this build didn't use, keeping the compression cache
|
||||
// bounded. No-op unless COMPRESS_CACHE_DIR is set.
|
||||
gulp.task("prune-compress-cache", () => pruneCache());
|
||||
|
||||
@@ -303,7 +303,11 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = {
|
||||
"index.html": ["main"],
|
||||
"dashboard.html": ["dashboard"],
|
||||
"onboarding.html": ["onboarding"],
|
||||
};
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Object-mode transform that keeps several files in flight at once.
|
||||
//
|
||||
// through2 and node's Transform both wait for the previous callback before
|
||||
// handling the next file, which serialises asynchronous work down to one file
|
||||
// at a time. This keeps `limit` files in flight and applies backpressure beyond
|
||||
// that. Files are emitted in completion order rather than input order.
|
||||
|
||||
import { Transform } from "node:stream";
|
||||
|
||||
export class ParallelTransform extends Transform {
|
||||
#limit;
|
||||
|
||||
#handle;
|
||||
|
||||
#inFlight = 0;
|
||||
|
||||
#resume;
|
||||
|
||||
#finish;
|
||||
|
||||
constructor(limit, handle) {
|
||||
super({ objectMode: true, highWaterMark: limit });
|
||||
this.#limit = limit;
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_transform(file, _encoding, callback) {
|
||||
this.#inFlight += 1;
|
||||
this.#handle(file)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
this.push(result);
|
||||
}
|
||||
})
|
||||
.catch((error) => this.destroy(error))
|
||||
.finally(() => {
|
||||
this.#inFlight -= 1;
|
||||
const resume = this.#resume;
|
||||
this.#resume = undefined;
|
||||
resume?.();
|
||||
if (this.#inFlight === 0) {
|
||||
const finish = this.#finish;
|
||||
this.#finish = undefined;
|
||||
finish?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.#inFlight < this.#limit) {
|
||||
callback();
|
||||
} else {
|
||||
this.#resume = callback;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_flush(callback) {
|
||||
if (this.#inFlight === 0) {
|
||||
callback();
|
||||
} else {
|
||||
this.#finish = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-61
@@ -5,14 +5,23 @@
|
||||
// blocks the event loop for the whole compression step. Running one WASM
|
||||
// instance per worker parallelises it; the output bytes are unchanged.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { availableParallelism } from "node:os";
|
||||
import { Transform } from "node:stream";
|
||||
import { buffer as readStream } from "node:stream/consumers";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { withCache } from "./compress-cache.mjs";
|
||||
import { ParallelTransform } from "./parallel-transform.mjs";
|
||||
|
||||
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
|
||||
const EXTENSION = ".gz";
|
||||
|
||||
// Cache namespace tied to the zopfli version, since a different version can
|
||||
// produce different bytes for the same input.
|
||||
const ZOPFLI_VERSION = createRequire(import.meta.url)(
|
||||
"@gfx/zopfli/package.json"
|
||||
).version;
|
||||
const NAMESPACE = `gzip-zopfli${ZOPFLI_VERSION}`;
|
||||
|
||||
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
|
||||
// what gulp-zopfli-green did, so compressed output stays byte-identical.
|
||||
const ZOPFLI_OPTIONS = {};
|
||||
@@ -112,65 +121,6 @@ const sharedPool = () => {
|
||||
return pool;
|
||||
};
|
||||
|
||||
// through2 and node's Transform both wait for the previous callback before
|
||||
// handling the next file, which would serialise the pool down to one worker.
|
||||
// This keeps `limit` files in flight and applies backpressure beyond that.
|
||||
class ParallelTransform extends Transform {
|
||||
#limit;
|
||||
|
||||
#handle;
|
||||
|
||||
#inFlight = 0;
|
||||
|
||||
#resume;
|
||||
|
||||
#finish;
|
||||
|
||||
constructor(limit, handle) {
|
||||
super({ objectMode: true, highWaterMark: limit });
|
||||
this.#limit = limit;
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_transform(file, _encoding, callback) {
|
||||
this.#inFlight += 1;
|
||||
this.#handle(file)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
this.push(result);
|
||||
}
|
||||
})
|
||||
.catch((error) => this.destroy(error))
|
||||
.finally(() => {
|
||||
this.#inFlight -= 1;
|
||||
const resume = this.#resume;
|
||||
this.#resume = undefined;
|
||||
resume?.();
|
||||
if (this.#inFlight === 0) {
|
||||
const finish = this.#finish;
|
||||
this.#finish = undefined;
|
||||
finish?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.#inFlight < this.#limit) {
|
||||
callback();
|
||||
} else {
|
||||
this.#resume = callback;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_flush(callback) {
|
||||
if (this.#inFlight === 0) {
|
||||
callback();
|
||||
} else {
|
||||
this.#finish = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.threshold] Skip files smaller than this many bytes.
|
||||
@@ -189,7 +139,9 @@ export default ({ threshold = 0 } = {}) => {
|
||||
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
|
||||
return file;
|
||||
}
|
||||
file.contents = await compress(file.contents);
|
||||
file.contents = await withCache(NAMESPACE, file.contents, () =>
|
||||
compress(file.contents)
|
||||
);
|
||||
file.path += EXTENSION;
|
||||
return file;
|
||||
});
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { mainWindow } from "../../../src/common/dom/get_main_window";
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import "../../../src/components/ha-button";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-icon-button";
|
||||
import "../../../src/components/ha-svg-icon";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "../stubs/cloud-demo-state";
|
||||
|
||||
// Walk the DOM, descending into shadow roots, to find the first matching
|
||||
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
|
||||
// can ask it to re-fetch after a scenario change.
|
||||
const deepQuery = (
|
||||
selector: string,
|
||||
root: Document | ShadowRoot = document
|
||||
): Element | null => {
|
||||
const direct = root.querySelector(selector);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const elements = root.querySelectorAll("*");
|
||||
for (const element of elements) {
|
||||
const shadow = element.shadowRoot;
|
||||
if (shadow) {
|
||||
const found = deepQuery(selector, shadow);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
|
||||
* reviewers can preview every UI state of the cloud account page. It writes to
|
||||
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
|
||||
* then nudges the page to re-read it. Lives entirely under demo/.
|
||||
*/
|
||||
@customElement("cloud-demo-controls")
|
||||
export class CloudDemoControls extends LitElement {
|
||||
@state() private _open = true;
|
||||
|
||||
@state() private _visible = false;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
|
||||
|
||||
private _unsub?: () => void;
|
||||
|
||||
// The demo uses hash-based routing (navigate() sets location.hash), so the
|
||||
// active route lives in the hash, not the pathname.
|
||||
private get _currentPath(): string {
|
||||
const hash = mainWindow.location.hash;
|
||||
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
this._visible = this._currentPath.startsWith("/config/cloud");
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._locationChanged();
|
||||
mainWindow.addEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.addEventListener("popstate", this._locationChanged);
|
||||
mainWindow.addEventListener("hashchange", this._locationChanged);
|
||||
this._unsub = subscribeCloudDemoScenario((scenario) => {
|
||||
this._scenario = { ...scenario };
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
mainWindow.removeEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.removeEventListener("popstate", this._locationChanged);
|
||||
mainWindow.removeEventListener("hashchange", this._locationChanged);
|
||||
this._unsub?.();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._visible) {
|
||||
return nothing;
|
||||
}
|
||||
if (!this._open) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
class="fab"
|
||||
label="Cloud demo controls"
|
||||
.path=${mdiFlaskOutline}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="header">
|
||||
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
|
||||
<span class="title">Cloud demo controls</span>
|
||||
<ha-icon-button
|
||||
label="Close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<p class="note">
|
||||
Demo only. Flips the mocked cloud state shown on this page.
|
||||
</p>
|
||||
<div class="controls">
|
||||
${this._segment("Subscription", "account", [
|
||||
["active", "Active"],
|
||||
["trialing", "Trialing"],
|
||||
["canceled", "Canceled"],
|
||||
["expired", "Expired"],
|
||||
["unknown", "Unknown"],
|
||||
])}
|
||||
${this._toggle("Onboarded", "onboarded")}
|
||||
${this._toggle("Onboarding postponed", "postponed")}
|
||||
${this._toggle("Remote access", "remote")}
|
||||
${this._segment("Remote status", "remoteStatus", [
|
||||
["ready", "Ready"],
|
||||
["generating", "Preparing"],
|
||||
["loading", "Loading"],
|
||||
["loaded", "Loaded"],
|
||||
["error", "Error"],
|
||||
])}
|
||||
${this._segment("Backups", "backup", [
|
||||
["fresh", "Recent"],
|
||||
["stale", "Old"],
|
||||
["failed", "Failed"],
|
||||
["local", "Local only"],
|
||||
["none", "None"],
|
||||
])}
|
||||
${this._toggle("Alexa linked", "alexa")}
|
||||
${this._toggle("Google linked", "google")}
|
||||
${this._toggle("Cameras (WebRTC)", "webrtc")}
|
||||
${this._toggle("Has webhooks", "webhooks")}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _segment(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: [string, string][]
|
||||
) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<div class="segment">
|
||||
${options.map(
|
||||
([value, text]) => html`
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance=${
|
||||
this._scenario[field] === value ? "filled" : "plain"
|
||||
}
|
||||
data-field=${field}
|
||||
data-value=${value}
|
||||
@click=${this._segmentClick}
|
||||
>
|
||||
${text}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._toggleChange}
|
||||
></ha-switch>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleOpen() {
|
||||
this._open = !this._open;
|
||||
}
|
||||
|
||||
private _segmentClick(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
this._set(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.dataset.value!
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleChange(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
|
||||
}
|
||||
|
||||
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
private _refresh() {
|
||||
// Refresh the shared cloud status so login-state changes (signed out) and
|
||||
// status-derived fields update.
|
||||
const panel = deepQuery("ha-panel-config");
|
||||
if (panel) {
|
||||
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
|
||||
}
|
||||
// cloud-account fetches its subscription/backup/webhook data once on mount
|
||||
// and is not cached by the router, so bounce through a sibling cloud route
|
||||
// to force a clean remount that re-reads the updated mocks.
|
||||
const path = this._currentPath;
|
||||
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
|
||||
const sibling =
|
||||
path === "/config/cloud/remote"
|
||||
? "/config/cloud/account"
|
||||
: "/config/cloud/remote";
|
||||
navigate(sibling, { replace: true });
|
||||
window.setTimeout(() => navigate(path, { replace: true }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 9999;
|
||||
}
|
||||
.fab {
|
||||
--mdc-icon-button-size: 48px;
|
||||
--mdc-icon-size: 24px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.header .title {
|
||||
flex: 1;
|
||||
font-weight: var(--ha-font-weight-medium, 500);
|
||||
}
|
||||
.header ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.note {
|
||||
margin: 8px 16px;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s, 0.875rem);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.segment {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-demo-controls": CloudDemoControls;
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -32,7 +32,6 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
@@ -59,6 +58,8 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"search/related",
|
||||
"tag/list",
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -90,11 +91,6 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
},
|
||||
});
|
||||
|
||||
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
|
||||
// the document level; it shows itself only on the cloud panel.
|
||||
if (!document.querySelector("cloud-demo-controls")) {
|
||||
document.body.appendChild(document.createElement("cloud-demo-controls"));
|
||||
}
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
+25
-109
@@ -7,31 +7,43 @@ import type {
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { DemoCloudBackup } from "./cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
// Fixed "recent" backup state: an automatic backup completed 12h ago to both the
|
||||
// local and cloud agents, with the next run scheduled for tomorrow. This is the
|
||||
// healthy state the cloud overview status line and backup sub-page render.
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups: [],
|
||||
backups: [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: recent,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
],
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup: future,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
next_automatic_backup: null,
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
next_automatic_backup: future,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", CLOUD_AGENT],
|
||||
@@ -61,88 +73,6 @@ const agentsInfo: BackupAgentsInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
|
||||
// cloud overview status line and the backup sub-page reflect the chosen state.
|
||||
const applyScenario = () => {
|
||||
const kind = getCloudDemoScenario().backup;
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
|
||||
// actually reads as overdue rather than slipping under the margin.
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
// The cloud agent is a backup target for the cloud-backed states only. For
|
||||
// "local" a backup exists but is stored locally (no cloud copy), and for
|
||||
// "none" there are no automatic backups at all.
|
||||
const cloudEnabled =
|
||||
kind === "fresh" || kind === "stale" || kind === "failed";
|
||||
backupConfig.create_backup.agent_ids = cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"];
|
||||
|
||||
switch (kind) {
|
||||
case "fresh":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "local":
|
||||
// Automatic backups run, but only to the local agent.
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "stale":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
backupConfig.last_attempted_automatic_backup = old;
|
||||
// Next scheduled backup is in the past, so it reads as overdue.
|
||||
backupConfig.next_automatic_backup = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
// Most recent attempt is newer than the last success, so it failed.
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "none":
|
||||
backupConfig.automatic_backups_configured = false;
|
||||
backupConfig.last_completed_automatic_backup = null;
|
||||
backupConfig.last_attempted_automatic_backup = null;
|
||||
backupConfig.next_automatic_backup = null;
|
||||
break;
|
||||
}
|
||||
|
||||
backupInfo.last_completed_automatic_backup =
|
||||
backupConfig.last_completed_automatic_backup;
|
||||
backupInfo.last_attempted_automatic_backup =
|
||||
backupConfig.last_attempted_automatic_backup;
|
||||
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
|
||||
backupInfo.backups =
|
||||
cloudEnabled && backupConfig.last_completed_automatic_backup
|
||||
? [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: backupConfig.last_completed_automatic_backup,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
]
|
||||
: [];
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
// Fresh objects each fetch so re-reading after a mutation actually re-renders
|
||||
// (Lit change detection is identity-based; the real WS API returns new
|
||||
@@ -171,20 +101,6 @@ export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
if (update.agents) {
|
||||
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
|
||||
}
|
||||
// Reflect the UI-driven backup change into the demo scenario so the demo
|
||||
// controls panel stays in sync with the mocked state.
|
||||
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
const current = getCloudDemoScenario().backup;
|
||||
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudNow
|
||||
? current === "fresh" || current === "stale" || current === "failed"
|
||||
? current
|
||||
: "fresh"
|
||||
: "local";
|
||||
if (next !== current) {
|
||||
setCloudDemoScenario({ backup: next });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
hass.mockWS(
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// Demo-only switchable Home Assistant Cloud scenario.
|
||||
//
|
||||
// The redesigned cloud account page (src/panels/config/cloud/account) renders
|
||||
// purely from real WS data. To let reviewers preview every UI state without a
|
||||
// real cloud account, this module holds a mutable "scenario" that the cloud and
|
||||
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
|
||||
// to. It is persisted to localStorage so the choice survives the data the page
|
||||
// fetches once per visit (subscription, backup config, webhooks).
|
||||
//
|
||||
// This lives entirely under demo/ — no production code imports it.
|
||||
|
||||
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
export type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups are configured, but not to the cloud agent
|
||||
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
|
||||
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
export interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
// Onboarding postponed server-side (maps to onboarding_postponed); hides
|
||||
// the onboarding UI without marking it completed.
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
onboarded: true,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: true,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "cloudDemoScenario";
|
||||
|
||||
const readScenario = (): CloudDemoScenario => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed or unavailable storage and fall back to the default.
|
||||
}
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
|
||||
};
|
||||
|
||||
let scenario: CloudDemoScenario = readScenario();
|
||||
|
||||
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
|
||||
|
||||
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
|
||||
|
||||
export const subscribeCloudDemoScenario = (
|
||||
listener: (scenario: CloudDemoScenario) => void
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const setCloudDemoScenario = (
|
||||
partial: Partial<CloudDemoScenario>
|
||||
): void => {
|
||||
scenario = { ...scenario, ...partial };
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
|
||||
} catch (_err) {
|
||||
// Ignore storage failures (e.g. private mode); state still applies in-memory.
|
||||
}
|
||||
listeners.forEach((listener) => listener(scenario));
|
||||
};
|
||||
+24
-107
@@ -6,11 +6,6 @@ import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { Webhook } from "../../../src/data/webhook";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
@@ -34,14 +29,30 @@ const demoWebhooks: Webhook[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// (both via the real UI and the demo scenario controls) are reflected back.
|
||||
const demoCloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// A single mutable status object seeded to one fixed demo state: an active cloud
|
||||
// subscription with remote ready, cloud backups recent, webhooks set up and voice
|
||||
// set up (Alexa linked), but cameras (WebRTC) off, so onboarding is still in
|
||||
// progress (streaming is the remaining step) and not postponed. Page-driven
|
||||
// changes (connect remote, postpone onboarding, add/delete webhooks) mutate this
|
||||
// object directly and reset on reload.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "[email protected]",
|
||||
google_registered: true,
|
||||
google_registered: false,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: true,
|
||||
@@ -58,20 +69,20 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
onboarding_completed: false,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
google_enabled: false,
|
||||
alexa_enabled: true,
|
||||
remote_enabled: true,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks: {},
|
||||
cloudhooks: demoCloudhooks,
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
onboarded_items: [...ONBOARDING_ITEMS],
|
||||
cloud_ice_servers_enabled: false,
|
||||
onboarded_items: [],
|
||||
onboarding_postponed_until: null,
|
||||
},
|
||||
};
|
||||
@@ -93,94 +104,6 @@ const ttsInfo: CloudTTSInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the high-level demo scenario onto the mutable cloud status / subscription.
|
||||
const applyScenario = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
|
||||
switch (scenario.account) {
|
||||
case "trialing":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "trialing" };
|
||||
break;
|
||||
case "canceled":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "canceled" };
|
||||
break;
|
||||
case "expired":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "expired" };
|
||||
break;
|
||||
case "unknown":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "unknown" };
|
||||
break;
|
||||
default:
|
||||
// "active"
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "active" };
|
||||
}
|
||||
|
||||
cloudStatus.prefs.onboarded_items = scenario.onboarded
|
||||
? [...ONBOARDING_ITEMS]
|
||||
: [];
|
||||
cloudStatus.onboarding_completed = scenario.onboarded;
|
||||
cloudStatus.onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null;
|
||||
cloudStatus.prefs.remote_enabled = scenario.remote;
|
||||
cloudStatus.remote_connected = scenario.remote;
|
||||
cloudStatus.remote_certificate_status = scenario.remoteStatus;
|
||||
cloudStatus.alexa_registered = scenario.alexa;
|
||||
cloudStatus.google_registered = scenario.google;
|
||||
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
|
||||
|
||||
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
|
||||
if (scenario.webhooks && !hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
} else if (!scenario.webhooks && hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = {};
|
||||
}
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
|
||||
// back into the demo scenario so the demo controls panel stays in sync with the
|
||||
// mocked state. Only writes when a value actually changed, to avoid needless
|
||||
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
|
||||
// this stays idempotent and does not fight the direct mutation above.
|
||||
const syncScenarioFromStatus = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
const next = {
|
||||
onboarded: cloudStatus.onboarding_completed,
|
||||
postponed: cloudStatus.onboarding_postponed,
|
||||
remote: cloudStatus.prefs.remote_enabled,
|
||||
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
|
||||
};
|
||||
if (
|
||||
scenario.onboarded !== next.onboarded ||
|
||||
scenario.postponed !== next.postponed ||
|
||||
scenario.remote !== next.remote ||
|
||||
scenario.webrtc !== next.webrtc ||
|
||||
scenario.webhooks !== next.webhooks
|
||||
) {
|
||||
setCloudDemoScenario(next);
|
||||
}
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...cloudStatus,
|
||||
@@ -193,7 +116,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
syncScenarioFromStatus();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -202,7 +124,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
@@ -222,7 +143,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
(onboardingItem) =>
|
||||
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
|
||||
);
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
@@ -245,20 +165,17 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
cloudStatus.prefs.remote_enabled = true;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
cloudStatus.prefs.remote_enabled = false;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
import { mockSearch } from "./search";
|
||||
import { mockSlugify } from "./slugify";
|
||||
import { mockSystemHealth } from "./system_health";
|
||||
import { mockTags } from "./tags";
|
||||
import { mockZone } from "./zone";
|
||||
@@ -39,4 +41,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockSearch(hass);
|
||||
mockTags(hass);
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
EntityRegistrySettings,
|
||||
fetchEntityRegistrySettings,
|
||||
updateEntityRegistrySettings,
|
||||
} from "../../../src/data/entity/entity_registry_settings";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntityRegistrySettings = (hass: MockHomeAssistant) => {
|
||||
let settings: EntityRegistrySettings = { entity_id_parts: null };
|
||||
|
||||
hass.mockWS<typeof fetchEntityRegistrySettings>(
|
||||
"config/entity_registry/settings/get",
|
||||
() => settings
|
||||
);
|
||||
hass.mockWS<typeof updateEntityRegistrySettings>(
|
||||
"config/entity_registry/settings/update",
|
||||
(msg: Partial<EntityRegistrySettings>) => {
|
||||
settings = { ...settings, ...msg };
|
||||
return settings;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import type { fetchSlug } from "../../../src/data/ws-slugify";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSlugify = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS<typeof fetchSlug>("slugify", (msg: { text: string }) => ({
|
||||
slug: slugify(msg.text),
|
||||
}));
|
||||
};
|
||||
+1
-31
@@ -17,9 +17,6 @@ const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
);
|
||||
|
||||
// Applies everywhere, including the files exempted from the history rule below.
|
||||
const restrictedSyntax = ["LabeledStatement", "WithStatement"];
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
@@ -114,16 +111,7 @@ export default tseslint.config(
|
||||
"no-bitwise": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
...restrictedSyntax,
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.property.name=/^(push|replace)State$/]",
|
||||
message:
|
||||
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
|
||||
},
|
||||
],
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"wc/no-self-class": "off",
|
||||
|
||||
// import-x rules
|
||||
@@ -234,24 +222,6 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// These own history entries themselves: the navigation helpers, the dialog
|
||||
// stack, the boot paths that run before the app has any state to keep, and
|
||||
// the tests that fabricate entries to simulate a document load.
|
||||
files: [
|
||||
"src/common/navigate.ts",
|
||||
"src/dialogs/make-dialog-manager.ts",
|
||||
"src/state/url-sync-mixin.ts",
|
||||
"src/panels/config/automation/add-automation-element-dialog.ts",
|
||||
"src/entrypoints/core.ts",
|
||||
"src/onboarding/**/*.ts",
|
||||
"cast/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-syntax": ["error", ...restrictedSyntax],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/util/recorder-worklet.js"],
|
||||
languageOptions: {
|
||||
|
||||
@@ -228,6 +228,7 @@ export default [
|
||||
"entity-state",
|
||||
"ha-markdown",
|
||||
"integration-card",
|
||||
"cloud-account",
|
||||
"box-shadow",
|
||||
"util-long-press",
|
||||
"remove-delete-add-create",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import { getCardElementClass } from "../../../src/panels/lovelace/create-element/create-card-element";
|
||||
|
||||
export const validateCardConfig = async (config: LovelaceCardConfig) => {
|
||||
const cardClass = await getCardElementClass(config.type);
|
||||
new cardClass().setConfig(config);
|
||||
};
|
||||
@@ -1,15 +1,21 @@
|
||||
import { load } from "js-yaml";
|
||||
import { dump } from "js-yaml";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import "../../../src/components/ha-alert";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { validateCardConfig } from "../common/validate-card-config";
|
||||
|
||||
export interface DemoCardConfig {
|
||||
export interface DemoCardConfig<
|
||||
T extends LovelaceCardConfig = LovelaceCardConfig,
|
||||
> {
|
||||
heading: string;
|
||||
config: string;
|
||||
config: T;
|
||||
expectConfigError?: boolean;
|
||||
}
|
||||
|
||||
@customElement("demo-card")
|
||||
@@ -23,12 +29,29 @@ class DemoCard extends LitElement {
|
||||
|
||||
@state() private _size?: number;
|
||||
|
||||
@state() private _configError?: string;
|
||||
|
||||
@query("hui-card", false) private _card?: HuiCard;
|
||||
|
||||
private _config = memoizeOne((config: string) => {
|
||||
const c = (load(config) as any)[0];
|
||||
return c;
|
||||
});
|
||||
private _yamlConfig = memoizeOne((config: LovelaceCardConfig) =>
|
||||
dump([config]).trim()
|
||||
);
|
||||
|
||||
protected async firstUpdated() {
|
||||
try {
|
||||
await validateCardConfig(this.config.config);
|
||||
} catch (err) {
|
||||
if (this.config.expectConfigError) {
|
||||
return;
|
||||
}
|
||||
this._configError = err instanceof Error ? err.message : String(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.expectConfigError) {
|
||||
this._configError = `Expected config error for ${this.config.heading}`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
@@ -40,15 +63,20 @@ class DemoCard extends LitElement {
|
||||
: ""
|
||||
}
|
||||
</h2>
|
||||
${
|
||||
this._configError
|
||||
? html`<ha-alert alert-type="error">${this._configError}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="root">
|
||||
<hui-card
|
||||
.config=${this._config(this.config.config)}
|
||||
.config=${this.config.config}
|
||||
.hass=${this.hass}
|
||||
@card-updated=${this._cardUpdated}
|
||||
></hui-card>
|
||||
${
|
||||
this.showConfig
|
||||
? html`<pre>${this.config.config.trim()}</pre>`
|
||||
? html`<pre>${this._yamlConfig(this.config.config)}</pre>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
@@ -81,6 +109,9 @@ class DemoCard extends LitElement {
|
||||
font-size: 0.5em;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
hui-card {
|
||||
max-width: 400px;
|
||||
width: 100vw;
|
||||
|
||||
@@ -106,6 +106,17 @@ export class DemoHaSelectBox extends LitElement {
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label>Disabled with a selected option</label>
|
||||
<ha-select-box
|
||||
.value=${"card"}
|
||||
.options=${fullOptions}
|
||||
.disabled=${true}
|
||||
>
|
||||
</ha-select-box>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Column layout</b></p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { AlarmPanelCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -40,52 +42,50 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic Example",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With Title",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm_armed
|
||||
name: My Alarm
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm_armed",
|
||||
name: "My Alarm",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Code Example",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm_code
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm_code",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Using only Arm_Home State",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm
|
||||
states:
|
||||
- arm_home
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm",
|
||||
states: ["arm_home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.unavailable
|
||||
states:
|
||||
- arm_home
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.unavailable",
|
||||
states: ["arm_home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid Entity",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm1
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm1",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<AlarmPanelCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-alarm-panel-card")
|
||||
class DemoAlarmPanelEntity extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { AreaCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -80,33 +82,33 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Bedroom",
|
||||
config: `
|
||||
- type: area
|
||||
area: bedroom
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "bedroom",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Living Room",
|
||||
config: `
|
||||
- type: area
|
||||
area: living_room
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "living_room",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Office",
|
||||
config: `
|
||||
- type: area
|
||||
area: office
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "office",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Kitchen",
|
||||
config: `
|
||||
- type: area
|
||||
area: kitchen
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "kitchen",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<AreaCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-area-card")
|
||||
class DemoArea extends LitElement {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
ConditionalCardConfig,
|
||||
EntitiesCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -39,35 +44,37 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Controller",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- light.controller_1
|
||||
- light.controller_2
|
||||
- type: divider
|
||||
- light.floor
|
||||
- light.kitchen
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"light.controller_1",
|
||||
"light.controller_2",
|
||||
{ type: "divider" },
|
||||
"light.floor",
|
||||
"light.kitchen",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Demo",
|
||||
config: `
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: light.controller_1
|
||||
state: "on"
|
||||
- entity: light.controller_2
|
||||
state_not: "off"
|
||||
card:
|
||||
type: entities
|
||||
entities:
|
||||
- light.controller_1
|
||||
- light.controller_2
|
||||
- light.floor
|
||||
- light.kitchen
|
||||
`,
|
||||
config: {
|
||||
type: "conditional",
|
||||
conditions: [
|
||||
{ entity: "light.controller_1", state: "on" },
|
||||
{ entity: "light.controller_2", state_not: "off" },
|
||||
],
|
||||
card: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"light.controller_1",
|
||||
"light.controller_2",
|
||||
"light.floor",
|
||||
"light.kitchen",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<EntitiesCardConfig | ConditionalCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-conditional-card")
|
||||
class DemoConditional extends LitElement {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
EntitiesCardEntityConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { CallServiceConfig } from "../../../../src/panels/lovelace/entity-rows/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -254,169 +260,194 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
type GalleryEntitiesCardConfig = Omit<EntitiesCardConfig, "entities"> & {
|
||||
type: EntitiesCardConfig["type"];
|
||||
entities: (
|
||||
| EntitiesCardConfig["entities"][number]
|
||||
| Pick<EntitiesCardEntityConfig, "entity" | "secondary_info">
|
||||
| Omit<CallServiceConfig, "entity">
|
||||
)[];
|
||||
};
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- light.non_existing
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
- sensor.humidity
|
||||
- text.message
|
||||
- event.doorbell
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"light.non_existing",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
"sensor.humidity",
|
||||
"text.message",
|
||||
"event.doorbell",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With enabled state color",
|
||||
config: `
|
||||
- type: entities
|
||||
state_color: true
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- light.non_existing
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
- sensor.humidity
|
||||
- text.message
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
state_color: true,
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"light.non_existing",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
"sensor.humidity",
|
||||
"text.message",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Helpers",
|
||||
config: `
|
||||
- type: entities
|
||||
title: Helpers
|
||||
entities:
|
||||
- entity: input_boolean.toggle
|
||||
- entity: input_datetime.date_and_time
|
||||
- entity: input_number.number
|
||||
- entity: input_select.dropdown
|
||||
- entity: input_text.text
|
||||
- entity: timer.timer
|
||||
- entity: counter.counter
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
title: "Helpers",
|
||||
entities: [
|
||||
{ entity: "input_boolean.toggle" },
|
||||
{ entity: "input_datetime.date_and_time" },
|
||||
{ entity: "input_number.number" },
|
||||
{ entity: "input_select.dropdown" },
|
||||
{ entity: "input_text.text" },
|
||||
{ entity: "timer.timer" },
|
||||
{ entity: "counter.counter" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title, toggle-able",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
title: Random group
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
],
|
||||
title: "Random group",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title, toggle = false",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
title: Random group
|
||||
show_header_toggle: false
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
],
|
||||
title: "Random group",
|
||||
show_header_toggle: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title, can't toggle",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
title: Random group
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: ["device_tracker.demo_paulus"],
|
||||
title: "Random group",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.unavailable
|
||||
- device_tracker.unavailable
|
||||
- cover.unavailable
|
||||
- lock.unavailable
|
||||
- light.unavailable
|
||||
- climate.unavailable
|
||||
- input_number.unavailable
|
||||
- input_select.unavailable
|
||||
- text.unavailable
|
||||
- event.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.unavailable",
|
||||
"device_tracker.unavailable",
|
||||
"cover.unavailable",
|
||||
"lock.unavailable",
|
||||
"light.unavailable",
|
||||
"climate.unavailable",
|
||||
"input_number.unavailable",
|
||||
"input_select.unavailable",
|
||||
"text.unavailable",
|
||||
"event.unavailable",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom name, secondary info, custom icon",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: scene.romantic_lights
|
||||
name: ¯\\_(ツ)_/¯
|
||||
- entity: device_tracker.demo_paulus
|
||||
secondary_info: entity-id
|
||||
- entity: cover.kitchen_window
|
||||
secondary_info: last-changed
|
||||
- entity: group.kitchen
|
||||
icon: mdi:home-assistant
|
||||
- lock.kitchen_door
|
||||
- entity: light.bed_light
|
||||
icon: mdi:alarm-light
|
||||
name: Bed Light Custom Icon
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
title: Random group
|
||||
show_header_toggle: false
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
{ entity: "scene.romantic_lights", name: "¯\\_(ツ)_/¯" },
|
||||
{
|
||||
entity: "device_tracker.demo_paulus",
|
||||
secondary_info: "entity-id",
|
||||
},
|
||||
{
|
||||
entity: "cover.kitchen_window",
|
||||
secondary_info: "last-changed",
|
||||
},
|
||||
{ entity: "group.kitchen", icon: "mdi:home-assistant" },
|
||||
"lock.kitchen_door",
|
||||
{
|
||||
entity: "light.bed_light",
|
||||
icon: "mdi:alarm-light",
|
||||
name: "Bed Light Custom Icon",
|
||||
},
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
],
|
||||
title: "Random group",
|
||||
show_header_toggle: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Special rows",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- type: perform-action
|
||||
icon: mdi:power
|
||||
name: Bed light
|
||||
action_name: Toggle light
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.bed_light
|
||||
- type: section
|
||||
label: Links
|
||||
- type: weblink
|
||||
url: http://google.com/
|
||||
icon: mdi:google
|
||||
name: Google
|
||||
- type: divider
|
||||
- type: divider
|
||||
style:
|
||||
height: 30px
|
||||
margin: 4px 0
|
||||
background: center / contain url("/images/divider.png") no-repeat
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
{
|
||||
type: "perform-action",
|
||||
icon: "mdi:power",
|
||||
name: "Bed light",
|
||||
action_name: "Toggle light",
|
||||
action: "light.toggle",
|
||||
data: { entity_id: "light.bed_light" },
|
||||
},
|
||||
{ type: "section", label: "Links" },
|
||||
{
|
||||
type: "weblink",
|
||||
url: "http://google.com/",
|
||||
icon: "mdi:google",
|
||||
name: "Google",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "divider",
|
||||
style: {
|
||||
height: "30px",
|
||||
margin: "4px 0",
|
||||
background: 'center / contain url("/images/divider.png") no-repeat',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GalleryEntitiesCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-entities-card")
|
||||
class DemoEntities extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { ButtonCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -18,60 +20,64 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With Name (defined in card)",
|
||||
config: `
|
||||
- type: button
|
||||
name: Custom Name
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
name: "Custom Name",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With Icon",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
icon: mdi:tools
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
icon: "mdi:tools",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With State",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
show_state: true
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
show_state: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom Tap Action (toggle)",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
tap_action: {
|
||||
action: "toggle",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Running Service",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
service: light.toggle
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
tap_action: {
|
||||
action: "perform-action",
|
||||
perform_action: "light.toggle",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid Entity",
|
||||
config: `
|
||||
- type: button
|
||||
entity: sensor.invalid_entity
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "sensor.invalid_entity",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<ButtonCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-entity-button-card")
|
||||
class DemoButtonEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
EntityFilterCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -114,184 +119,198 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
type StateFilterEntityFilterCardConfig = Pick<
|
||||
EntityFilterCardConfig,
|
||||
"type" | "entities" | "card" | "show_empty"
|
||||
> & {
|
||||
conditions?: never;
|
||||
state_filter: NonNullable<EntityFilterCardConfig["state_filter"]>;
|
||||
};
|
||||
|
||||
const VALID_CONFIGS = [
|
||||
{
|
||||
heading: "Unfiltered entities",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "On and home entities",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- "on"
|
||||
- home
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["on", "home"] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Same state as Bed Light",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["light.bed_light"] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: 'With "entities" card config',
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- "on"
|
||||
- home
|
||||
card:
|
||||
type: entities
|
||||
title: Custom Title
|
||||
show_header_toggle: false
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["on", "home"] }],
|
||||
card: {
|
||||
type: "entities",
|
||||
title: "Custom Title",
|
||||
show_header_toggle: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: 'With "glance" card config',
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- "on"
|
||||
- home
|
||||
card:
|
||||
type: glance
|
||||
show_state: true
|
||||
title: Custom Title
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["on", "home"] }],
|
||||
card: {
|
||||
type: "glance",
|
||||
show_state: true,
|
||||
title: "Custom Title",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Filtered entities by battery attribute (< '30') using state filter",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
state_filter:
|
||||
- operator: <
|
||||
attribute: battery
|
||||
value: "30"
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
state_filter: [{ operator: "<", attribute: "battery", value: "30" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unfiltered number entities",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- input_number.min_battery_level
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"input_number.min_battery_level",
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Battery lower than 50%",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
below: 50
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
conditions: [{ condition: "numeric_state", below: 50 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Battery lower than min battery level",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
below: input_number.min_battery_level
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
conditions: [
|
||||
{ condition: "numeric_state", below: "input_number.min_battery_level" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Battery between min battery level and 70%",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
above: input_number.min_battery_level
|
||||
below: 70
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "numeric_state",
|
||||
above: "input_number.min_battery_level",
|
||||
below: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<
|
||||
| EntitiesCardConfig
|
||||
| EntityFilterCardConfig
|
||||
| StateFilterEntityFilterCardConfig
|
||||
>[];
|
||||
|
||||
const INVALID_CONFIGS = [
|
||||
{
|
||||
heading: "Error: Entities must be specified",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
`,
|
||||
config: { type: "entity-filter" },
|
||||
expectConfigError: true,
|
||||
},
|
||||
{
|
||||
heading: "Error: Incorrect filter config",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.gas_station_lowest_price
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: ["sensor.gas_station_lowest_price"],
|
||||
},
|
||||
expectConfigError: true,
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<
|
||||
| Pick<EntityFilterCardConfig, "type">
|
||||
| Pick<EntityFilterCardConfig, "type" | "entities">
|
||||
>[];
|
||||
|
||||
const CONFIGS = [...VALID_CONFIGS, ...INVALID_CONFIGS];
|
||||
|
||||
@customElement("demo-lovelace-entity-filter-card")
|
||||
class DemoEntityFilter extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { GaugeCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -30,158 +32,156 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.outside_humidity
|
||||
name: Outside Humidity
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.outside_humidity",
|
||||
name: "Outside Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom unit of measurement",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.outside_temperature
|
||||
unit_of_measurement: C
|
||||
name: Outside Temperature
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.outside_temperature",
|
||||
unit: "C",
|
||||
name: "Outside Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Rendering needle",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.outside_humidity
|
||||
name: Outside Humidity
|
||||
needle: true
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.outside_humidity",
|
||||
name: "Outside Humidity",
|
||||
needle: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Rendering needle and severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
name: Brightness High
|
||||
needle: true
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
name: "Brightness High",
|
||||
needle: true,
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness
|
||||
name: Brightness Low
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness",
|
||||
name: "Brightness Low",
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_medium
|
||||
name: Brightness Medium
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_medium",
|
||||
name: "Brightness Medium",
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
name: Brightness High
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
name: "Brightness High",
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting min (0) and mx (15) values",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness
|
||||
name: Brightness
|
||||
min: 0
|
||||
max: 15
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness",
|
||||
name: "Brightness",
|
||||
min: 0,
|
||||
max: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid entity",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.invalid_entity
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.invalid_entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non-numeric value",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: plant.bonsai
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "plant.bonsai",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable entity",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.not_working
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.not_working",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Lower minimum",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
needle: true
|
||||
severity:
|
||||
green: 0
|
||||
yellow: 0.45
|
||||
red: 0.9
|
||||
min: -0.05
|
||||
name: " "
|
||||
max: 1.9
|
||||
unit: GBP/h`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
needle: true,
|
||||
severity: {
|
||||
green: 0,
|
||||
yellow: 0.45,
|
||||
red: 0.9,
|
||||
},
|
||||
min: -0.05,
|
||||
name: " ",
|
||||
max: 1.9,
|
||||
unit: "GBP/h",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "A lot of segments",
|
||||
config: `
|
||||
- type: gauge
|
||||
needle: true
|
||||
name: Percent gauge
|
||||
entity: sensor.brightness_high
|
||||
unit: "%"
|
||||
min: 0
|
||||
max: 100
|
||||
segments:
|
||||
- from: 0
|
||||
color: "#db4437"
|
||||
- from: 10
|
||||
color: "#cc4d39"
|
||||
- from: 20
|
||||
color: "#bd563a"
|
||||
- from: 30
|
||||
color: "#ad603c"
|
||||
- from: 40
|
||||
color: "#9e693d"
|
||||
- from: 50
|
||||
color: "#8f723f"
|
||||
- from: 60
|
||||
color: "#807b41"
|
||||
- from: 70
|
||||
color: "#718442"
|
||||
- from: 80
|
||||
color: "#618e44"
|
||||
- from: 90
|
||||
color: "#43a047"`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
needle: true,
|
||||
name: "Percent gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: 100,
|
||||
segments: [
|
||||
{ from: 0, color: "#db4437" },
|
||||
{ from: 10, color: "#cc4d39" },
|
||||
{ from: 20, color: "#bd563a" },
|
||||
{ from: 30, color: "#ad603c" },
|
||||
{ from: 40, color: "#9e693d" },
|
||||
{ from: 50, color: "#8f723f" },
|
||||
{ from: 60, color: "#807b41" },
|
||||
{ from: 70, color: "#718442" },
|
||||
{ from: 80, color: "#618e44" },
|
||||
{ from: 90, color: "#43a047" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GaugeCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-gauge-card")
|
||||
class DemoGaugeEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
GlanceCardConfig,
|
||||
GlanceConfigEntity,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -86,172 +91,193 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
type LegacyNullNameGlanceCardConfig = Omit<GlanceCardConfig, "entities"> & {
|
||||
type: GlanceCardConfig["type"];
|
||||
entities: (
|
||||
| string
|
||||
| GlanceConfigEntity
|
||||
| (Omit<GlanceConfigEntity, "name"> & { name: null })
|
||||
)[];
|
||||
};
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No state colors",
|
||||
config: `
|
||||
- type: glance
|
||||
state_color: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
state_color: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title",
|
||||
config: `
|
||||
- type: glance
|
||||
title: Custom title
|
||||
columns: 4
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
title: "Custom title",
|
||||
columns: 4,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom number of columns",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 7
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 7,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No entity names",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
show_name: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
show_name: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No state labels",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
show_state: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
show_state: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No names and no state labels",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
show_name: false
|
||||
show_state: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
show_name: false,
|
||||
show_state: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom name + custom icon",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
name: ¯\\_(ツ)_/¯
|
||||
icon: mdi:home-assistant
|
||||
- entity: media_player.living_room
|
||||
name: ¯\\_(ツ)_/¯
|
||||
icon: mdi:home-assistant
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
entities: [
|
||||
{
|
||||
entity: "device_tracker.demo_paulus",
|
||||
name: "¯\\_(ツ)_/¯",
|
||||
icon: "mdi:home-assistant",
|
||||
},
|
||||
{
|
||||
entity: "media_player.living_room",
|
||||
name: "¯\\_(ツ)_/¯",
|
||||
icon: "mdi:home-assistant",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Selectively hidden name",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- entity: media_player.living_room
|
||||
name:
|
||||
- sun.sun
|
||||
- entity: cover.kitchen_window
|
||||
name:
|
||||
- light.kitchen_lights
|
||||
- entity: lock.kitchen_door
|
||||
name:
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
{ entity: "media_player.living_room", name: null },
|
||||
"sun.sun",
|
||||
{ entity: "cover.kitchen_window", name: null },
|
||||
"light.kitchen_lights",
|
||||
{ entity: "lock.kitchen_door", name: null },
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom tap action",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
entities:
|
||||
- entity: lock.kitchen_door
|
||||
name: Custom
|
||||
tap_action:
|
||||
type: toggle
|
||||
- entity: light.ceiling_lights
|
||||
name: Custom
|
||||
tap_action:
|
||||
action: call-service
|
||||
service: light.turn_on
|
||||
data:
|
||||
entity_id: light.ceiling_lights
|
||||
- entity: sun.sun
|
||||
name: Regular
|
||||
- entity: light.kitchen_lights
|
||||
name: Regular
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
entities: [
|
||||
{
|
||||
entity: "lock.kitchen_door",
|
||||
name: "Custom",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
{
|
||||
entity: "light.ceiling_lights",
|
||||
name: "Custom",
|
||||
tap_action: {
|
||||
action: "perform-action",
|
||||
perform_action: "light.turn_on",
|
||||
data: { entity_id: "light.ceiling_lights" },
|
||||
},
|
||||
},
|
||||
{ entity: "sun.sun", name: "Regular" },
|
||||
{ entity: "light.kitchen_lights", name: "Regular" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GlanceCardConfig | LegacyNullNameGlanceCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-glance-card")
|
||||
class DemoGlanceEntity extends LitElement {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { mockHistory } from "../../../../demo/src/stubs/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
GridCardConfig,
|
||||
StackCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -70,159 +75,159 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Default Grid",
|
||||
config: `
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
- type: entity
|
||||
entity: device_tracker.demo_anne_therese
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non-square Grid with 2 columns",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 2
|
||||
square: false
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Default Grid with title",
|
||||
config: `
|
||||
- type: grid
|
||||
title: Kitchen
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
- type: entity
|
||||
entity: device_tracker.demo_anne_therese
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
title: "Kitchen",
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Columns 4",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 4
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 4,
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Columns 2",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 2
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Columns 1",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 1
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 1,
|
||||
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Size for single card",
|
||||
config: `
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
heading: "Vertical Stack",
|
||||
config: `
|
||||
- type: vertical-stack
|
||||
cards:
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "vertical-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
},
|
||||
{
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Horizontal Stack",
|
||||
config: `
|
||||
- type: horizontal-stack
|
||||
cards:
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "horizontal-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
},
|
||||
{
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Combination of both",
|
||||
config: `
|
||||
- type: vertical-stack
|
||||
cards:
|
||||
- type: horizontal-stack
|
||||
cards:
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- type: picture-entity
|
||||
image: /images/bed.png
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "vertical-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "horizontal-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
},
|
||||
{
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/bed.png",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GridCardConfig | StackCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-grid-and-stack-card")
|
||||
class DemoStack extends LitElement {
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { IframeCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Without title",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
title: Weather radar
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
title: "Weather radar",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Height-Width 3:4",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
aspect_ratio: 75%
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
aspect_ratio: "75%",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Height-Width 1:1",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
aspect_ratio: 100%
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
aspect_ratio: "100%",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<IframeCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-iframe-card")
|
||||
class DemoIframe extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { LightCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -44,40 +46,40 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Switchable Light",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Dimmable Light On",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.dim_on
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.dim_on",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Dimmable Light Off",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.dim_off
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.dim_off",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non existing",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.nonexisting
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.nonexisting",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<LightCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-light-card")
|
||||
class DemoLightEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { MapCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -86,107 +88,101 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Without title",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- device_tracker.demo_home_boy
|
||||
- zone.home
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: [
|
||||
{ entity: "device_tracker.demo_paulus" },
|
||||
"device_tracker.demo_home_boy",
|
||||
"zone.home",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
title: Where is Paulus?
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
title: "Where is Paulus?",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Height-Width 1:2",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
aspect_ratio: 50%
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
aspect_ratio: "50%",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Default Zoom",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 12
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 12,
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Default Zoom too High",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 20
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 20,
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Single Marker",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: ["device_tracker.demo_paulus"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Single Marker Default Zoom",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 8
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 8,
|
||||
entities: ["device_tracker.demo_paulus"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No Entities",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: ["light.bed_light"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No Entities, Default Zoom",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 8
|
||||
entities:
|
||||
- light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 8,
|
||||
entities: ["light.bed_light"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Geo Location Entities",
|
||||
config: `
|
||||
- type: map
|
||||
geo_location_sources:
|
||||
- bushfire_demo
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
geo_location_sources: ["bushfire_demo"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Geo Location Entities with Home Zone",
|
||||
config: `
|
||||
- type: map
|
||||
geo_location_sources:
|
||||
- bushfire_demo
|
||||
entities:
|
||||
- zone.bushfire
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
geo_location_sources: ["bushfire_demo"],
|
||||
entities: ["zone.bushfire"],
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
heading: "Scale ruler",
|
||||
config: {
|
||||
type: "map",
|
||||
scale_ruler: true,
|
||||
entities: ["zone.home"],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<MapCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-map-card")
|
||||
class DemoMap extends LitElement {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { mockTemplate } from "../../../../demo/src/stubs/template";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { MarkdownCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "markdown-it demo",
|
||||
config: `
|
||||
- type: markdown
|
||||
content: |
|
||||
# h1 Heading 8-)
|
||||
config: {
|
||||
type: "markdown",
|
||||
content: `# h1 Heading 8-)
|
||||
|
||||
## h2 Heading
|
||||
|
||||
@@ -278,9 +279,12 @@ const CONFIGS = [
|
||||
<ha-alert alert-type="success">This is a success alert — check it out!</ha-alert>
|
||||
<ha-alert title="Test alert">This is an alert with a title</ha-alert>
|
||||
|
||||
`,
|
||||
`
|
||||
.replace(/^ {4}/gm, "")
|
||||
.replace(/\n+$/, "\n"),
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<MarkdownCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-markdown-card")
|
||||
class DemoMarkdown extends LitElement {
|
||||
|
||||
@@ -1,162 +1,165 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
GridCardConfig,
|
||||
MediaControlCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { createMediaPlayerEntities } from "../../data/media_players";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Paused Music",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.music_paused
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.music_paused",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Music",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.music_playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.music_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Stream",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.stream_playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.stream_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Paused Stream",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.stream_paused
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.stream_paused",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: 'Playing Stream (with "previous" support)',
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.stream_playing_previous
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.stream_playing_previous",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing non-skip TV Show",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.tv_playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.tv_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Screen Casting",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.android_cast
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.android_cast",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Digital Picture Frame",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.image_display
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.image_display",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Sonos Idle",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.sonos_idle
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.sonos_idle",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Idle waiting for Browse Media",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.idle_browse_media
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.idle_browse_media",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Off",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_off
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_off",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player On",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_on
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_on",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Off (cannot be switched on)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_off_static
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_off_static",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player On (cannot be switched off)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_on_static
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_on_static",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Idle",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.idle
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.idle",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Playing",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Unavailable",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Unknown",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.unknown
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Receiver On (selectable sources)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.receiver_on
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.receiver_on",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Receiver Off (selectable sources)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.receiver_off
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.receiver_off",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Grid Full Size",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 1
|
||||
cards:
|
||||
- type: media-control
|
||||
entity: media_player.music_paused
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 1,
|
||||
cards: [{ type: "media-control", entity: "media_player.music_paused" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<MediaControlCardConfig | GridCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-media-control-card")
|
||||
class DemoHuiMediaControlCard extends LitElement {
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { EntitiesCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { createMediaPlayerEntities } from "../../data/media_players";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Media Players",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: media_player.music_paused
|
||||
name: Paused Music
|
||||
- entity: media_player.music_playing
|
||||
name: Playing Music
|
||||
- entity: media_player.stream_playing
|
||||
name: Playing Stream
|
||||
- entity: media_player.stream_paused
|
||||
name: Paused Stream
|
||||
- entity: media_player.stream_playing_previous
|
||||
name: Playing Stream (with "previous" support)
|
||||
- entity: media_player.tv_playing
|
||||
name: Playing non-skip TV Show
|
||||
- entity: media_player.android_cast
|
||||
name: Screen casting
|
||||
- entity: media_player.image_display
|
||||
name: Digital Picture Frame
|
||||
- entity: media_player.sonos_idle
|
||||
name: Sonos Idle
|
||||
- entity: media_player.idle_browse_media
|
||||
name: Idle waiting for Browse Media
|
||||
- entity: media_player.theater_off
|
||||
name: Player Off
|
||||
- entity: media_player.theater_on
|
||||
name: Player On
|
||||
- entity: media_player.theater_off_static
|
||||
name: Player Off (cannot be switched on)
|
||||
- entity: media_player.theater_on_static
|
||||
name: Player On (cannot be switched off)
|
||||
- entity: media_player.idle
|
||||
name: Player Idle
|
||||
- entity: media_player.playing
|
||||
name: Player Playing
|
||||
- entity: media_player.unavailable
|
||||
name: Player Unavailable
|
||||
- entity: media_player.unknown
|
||||
name: Player Unknown
|
||||
- entity: media_player.receiver_on
|
||||
name: Receiver On (selectable sources)
|
||||
- entity: media_player.receiver_off
|
||||
name: Receiver Off (selectable sources)
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
{ entity: "media_player.music_paused", name: "Paused Music" },
|
||||
{ entity: "media_player.music_playing", name: "Playing Music" },
|
||||
{ entity: "media_player.stream_playing", name: "Playing Stream" },
|
||||
{ entity: "media_player.stream_paused", name: "Paused Stream" },
|
||||
{
|
||||
entity: "media_player.stream_playing_previous",
|
||||
name: 'Playing Stream (with "previous" support)',
|
||||
},
|
||||
{ entity: "media_player.tv_playing", name: "Playing non-skip TV Show" },
|
||||
{ entity: "media_player.android_cast", name: "Screen casting" },
|
||||
{ entity: "media_player.image_display", name: "Digital Picture Frame" },
|
||||
{ entity: "media_player.sonos_idle", name: "Sonos Idle" },
|
||||
{
|
||||
entity: "media_player.idle_browse_media",
|
||||
name: "Idle waiting for Browse Media",
|
||||
},
|
||||
{ entity: "media_player.theater_off", name: "Player Off" },
|
||||
{ entity: "media_player.theater_on", name: "Player On" },
|
||||
{
|
||||
entity: "media_player.theater_off_static",
|
||||
name: "Player Off (cannot be switched on)",
|
||||
},
|
||||
{
|
||||
entity: "media_player.theater_on_static",
|
||||
name: "Player On (cannot be switched off)",
|
||||
},
|
||||
{ entity: "media_player.idle", name: "Player Idle" },
|
||||
{ entity: "media_player.playing", name: "Player Playing" },
|
||||
{ entity: "media_player.unavailable", name: "Player Unavailable" },
|
||||
{ entity: "media_player.unknown", name: "Player Unknown" },
|
||||
{
|
||||
entity: "media_player.receiver_on",
|
||||
name: "Receiver On (selectable sources)",
|
||||
},
|
||||
{
|
||||
entity: "media_player.receiver_off",
|
||||
name: "Receiver Off (selectable sources)",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<EntitiesCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-media-player-row")
|
||||
export class DemoLovelaceMediaPlayerRow extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { PictureCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -19,26 +21,26 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Image URL",
|
||||
config: `
|
||||
- type: picture
|
||||
image: /images/living_room.png
|
||||
`,
|
||||
config: {
|
||||
type: "picture",
|
||||
image: "/images/living_room.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture
|
||||
image_entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "picture",
|
||||
image_entity: "person.paulus",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Error: Image required",
|
||||
config: `
|
||||
- type: picture
|
||||
entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "picture",
|
||||
},
|
||||
expectConfigError: true,
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PictureCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-card")
|
||||
class DemoPicture extends LitElement {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { PictureElementsCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type {
|
||||
ImageElementConfig,
|
||||
LovelaceElementConfig,
|
||||
} from "../../../../src/panels/lovelace/elements/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -60,116 +66,141 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
type LegacyImageElementConfig = Omit<
|
||||
ImageElementConfig,
|
||||
"state_filter" | "state_image"
|
||||
> & {
|
||||
state_filter?: Record<string, string>;
|
||||
state_image?: Record<string, string>;
|
||||
};
|
||||
|
||||
type GalleryPictureElementsCardConfig = Omit<
|
||||
PictureElementsCardConfig,
|
||||
"elements"
|
||||
> & {
|
||||
type: PictureElementsCardConfig["type"];
|
||||
elements: (LovelaceElementConfig | LegacyImageElementConfig)[];
|
||||
};
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Card with few elements",
|
||||
config: `
|
||||
- type: picture-elements
|
||||
image: /images/floorplan.png
|
||||
elements:
|
||||
- type: service-button
|
||||
title: Lights Off
|
||||
style:
|
||||
top: 97%
|
||||
left: 90%
|
||||
padding: 0px
|
||||
service: light.turn_off
|
||||
data:
|
||||
entity_id: group.all_lights
|
||||
- type: icon
|
||||
icon: mdi:cctv
|
||||
entity: camera.demo_camera
|
||||
style:
|
||||
top: 12%
|
||||
left: 6%
|
||||
transform: rotate(-60deg) scaleX(-1)
|
||||
--mdc-icon-size: 30px
|
||||
--mdc-icon-stroke-color: black
|
||||
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
|
||||
- type: image
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
image: /images/light_bulb_off.png
|
||||
state_image:
|
||||
'on': /images/light_bulb_on.png
|
||||
state_filter:
|
||||
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
|
||||
'off': brightness(80%) saturate(0.8)
|
||||
style:
|
||||
top: 35%
|
||||
left: 65%
|
||||
width: 7%
|
||||
padding: 50px 50px 100px 50px
|
||||
- type: state-icon
|
||||
entity: binary_sensor.movement_backyard
|
||||
style:
|
||||
top: 8%
|
||||
left: 35%
|
||||
`,
|
||||
config: {
|
||||
type: "picture-elements",
|
||||
image: "/images/floorplan.png",
|
||||
elements: [
|
||||
{
|
||||
type: "service-button",
|
||||
title: "Lights Off",
|
||||
style: { top: "97%", left: "90%", padding: "0px" },
|
||||
service: "light.turn_off",
|
||||
data: { entity_id: "group.all_lights" },
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
icon: "mdi:cctv",
|
||||
entity: "camera.demo_camera",
|
||||
style: {
|
||||
top: "12%",
|
||||
left: "6%",
|
||||
transform: "rotate(-60deg) scaleX(-1)",
|
||||
"--mdc-icon-size": "30px",
|
||||
"--mdc-icon-stroke-color": "black",
|
||||
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
entity: "light.bed_light",
|
||||
tap_action: { action: "toggle" },
|
||||
image: "/images/light_bulb_off.png",
|
||||
state_image: { on: "/images/light_bulb_on.png" },
|
||||
state_filter: {
|
||||
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
|
||||
off: "brightness(80%) saturate(0.8)",
|
||||
},
|
||||
style: {
|
||||
top: "35%",
|
||||
left: "65%",
|
||||
width: "7%",
|
||||
padding: "50px 50px 100px 50px",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "state-icon",
|
||||
entity: "binary_sensor.movement_backyard",
|
||||
style: { top: "8%", left: "35%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Card with header",
|
||||
config: `
|
||||
- type: picture-elements
|
||||
image: /images/floorplan.png
|
||||
title: My House
|
||||
elements:
|
||||
- type: service-button
|
||||
title: Lights Off
|
||||
style:
|
||||
top: 97%
|
||||
left: 90%
|
||||
padding: 0px
|
||||
service: light.turn_off
|
||||
data:
|
||||
entity_id: group.all_lights
|
||||
- type: icon
|
||||
icon: mdi:cctv
|
||||
entity: camera.demo_camera
|
||||
style:
|
||||
top: 12%
|
||||
left: 6%
|
||||
transform: rotate(-60deg) scaleX(-1)
|
||||
--mdc-icon-size: 30px
|
||||
--mdc-icon-stroke-color: black
|
||||
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
|
||||
- type: image
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
image: /images/light_bulb_off.png
|
||||
state_image:
|
||||
'on': /images/light_bulb_on.png
|
||||
state_filter:
|
||||
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
|
||||
'off': brightness(80%) saturate(0.8)
|
||||
style:
|
||||
top: 35%
|
||||
left: 65%
|
||||
width: 7%
|
||||
padding: 50px 50px 100px 50px
|
||||
- type: state-icon
|
||||
entity: binary_sensor.movement_backyard
|
||||
style:
|
||||
top: 8%
|
||||
left: 35%
|
||||
`,
|
||||
config: {
|
||||
type: "picture-elements",
|
||||
image: "/images/floorplan.png",
|
||||
title: "My House",
|
||||
elements: [
|
||||
{
|
||||
type: "service-button",
|
||||
title: "Lights Off",
|
||||
style: { top: "97%", left: "90%", padding: "0px" },
|
||||
service: "light.turn_off",
|
||||
data: { entity_id: "group.all_lights" },
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
icon: "mdi:cctv",
|
||||
entity: "camera.demo_camera",
|
||||
style: {
|
||||
top: "12%",
|
||||
left: "6%",
|
||||
transform: "rotate(-60deg) scaleX(-1)",
|
||||
"--mdc-icon-size": "30px",
|
||||
"--mdc-icon-stroke-color": "black",
|
||||
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
entity: "light.bed_light",
|
||||
tap_action: { action: "toggle" },
|
||||
image: "/images/light_bulb_off.png",
|
||||
state_image: { on: "/images/light_bulb_on.png" },
|
||||
state_filter: {
|
||||
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
|
||||
off: "brightness(80%) saturate(0.8)",
|
||||
},
|
||||
style: {
|
||||
top: "35%",
|
||||
left: "65%",
|
||||
width: "7%",
|
||||
padding: "50px 50px 100px 50px",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "state-icon",
|
||||
entity: "binary_sensor.movement_backyard",
|
||||
style: { top: "8%", left: "35%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture-elements
|
||||
image_entity: person.paulus
|
||||
elements:
|
||||
- type: state-icon
|
||||
entity: sensor.battery
|
||||
style:
|
||||
top: 8%
|
||||
left: 8%
|
||||
`,
|
||||
config: {
|
||||
type: "picture-elements",
|
||||
image_entity: "person.paulus",
|
||||
elements: [
|
||||
{
|
||||
type: "state-icon",
|
||||
entity: "sensor.battery",
|
||||
style: { top: "8%", left: "8%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GalleryPictureElementsCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-elements-card")
|
||||
class DemoPictureElements extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { PictureEntityCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -33,75 +35,73 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "State on",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
tap_action:
|
||||
action: toggle
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "State off",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/bed.png
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/bed.png",
|
||||
entity: "light.bed_light",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Entity unavailable",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/living_room.png
|
||||
entity: light.non_existing
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/living_room.png",
|
||||
entity: "light.non_existing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Camera entity",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
entity: camera.demo_camera
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
entity: "camera.demo_camera",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
entity: "person.paulus",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Hidden name",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
show_name: false
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
show_name: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Hidden state",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
show_state: false
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
show_state: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Both hidden",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
show_name: false
|
||||
show_state: false
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
show_name: false,
|
||||
show_state: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PictureEntityCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-entity-card")
|
||||
class DemoPictureEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { PictureGlanceCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -58,110 +60,110 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Title, dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: [
|
||||
"switch.decorative_lights",
|
||||
"light.ceiling_lights",
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Title, dialog, no toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: [
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Title, no dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: ["switch.decorative_lights", "light.ceiling_lights"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No title, dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
entities: [
|
||||
"switch.decorative_lights",
|
||||
"light.ceiling_lights",
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No title, dialog, no toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
entities:
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
entities: [
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No title, no dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
entities: ["switch.decorative_lights", "light.ceiling_lights"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image_entity: person.paulus
|
||||
entities:
|
||||
- sensor.battery
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image_entity: "person.paulus",
|
||||
entities: ["sensor.battery"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom icon",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- entity: switch.decorative_lights
|
||||
icon: mdi:power
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: [
|
||||
{ entity: "switch.decorative_lights", icon: "mdi:power" },
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom tap action",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entity: light.ceiling_lights
|
||||
tap_action:
|
||||
action: toggle
|
||||
entities:
|
||||
- entity: switch.decorative_lights
|
||||
icon: mdi:power
|
||||
tap_action:
|
||||
action: toggle
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entity: "light.ceiling_lights",
|
||||
tap_action: { action: "toggle" },
|
||||
entities: [
|
||||
{
|
||||
entity: "switch.decorative_lights",
|
||||
icon: "mdi:power",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PictureGlanceCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-glance-card")
|
||||
class DemoPictureGlance extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { PlantStatusCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { createPlantEntities } from "../../data/plants";
|
||||
@@ -9,27 +11,27 @@ import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: plant-status
|
||||
entity: plant.lemon_tree
|
||||
`,
|
||||
config: {
|
||||
type: "plant-status",
|
||||
entity: "plant.lemon_tree",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Problem (too bright) + low battery",
|
||||
config: `
|
||||
- type: plant-status
|
||||
entity: plant.apple_tree
|
||||
`,
|
||||
config: {
|
||||
type: "plant-status",
|
||||
entity: "plant.apple_tree",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With picture + multiple problems",
|
||||
config: `
|
||||
- type: plant-status
|
||||
entity: plant.sunflowers
|
||||
name: Sunflowers Name Overwrite
|
||||
`,
|
||||
config: {
|
||||
type: "plant-status",
|
||||
entity: "plant.sunflowers",
|
||||
name: "Sunflowers Name Overwrite",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PlantStatusCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-plant-card")
|
||||
export class DemoPlantEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ThermostatCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -123,120 +125,131 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Range example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.ecobee
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.ecobee",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Single temp example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.nest
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.nest",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Feature example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.overkiz_radiator
|
||||
features:
|
||||
- type: climate-hvac-modes
|
||||
hvac_modes:
|
||||
- heat
|
||||
- 'off'
|
||||
- auto
|
||||
- type: climate-preset-modes
|
||||
style: icons
|
||||
preset_modes:
|
||||
- none
|
||||
- frost_protection
|
||||
- eco
|
||||
- comfort
|
||||
- comfort-1
|
||||
- comfort-2
|
||||
- auto
|
||||
- boost
|
||||
- external
|
||||
- prog
|
||||
- type: climate-preset-modes
|
||||
style: dropdown
|
||||
preset_modes:
|
||||
- none
|
||||
- frost_protection
|
||||
- eco
|
||||
- comfort
|
||||
- comfort-1
|
||||
- comfort-2
|
||||
- auto
|
||||
- boost
|
||||
- external
|
||||
- prog
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.overkiz_radiator",
|
||||
features: [
|
||||
{
|
||||
type: "climate-hvac-modes",
|
||||
hvac_modes: ["heat", "off", "auto"],
|
||||
},
|
||||
{
|
||||
type: "climate-preset-modes",
|
||||
style: "icons",
|
||||
preset_modes: [
|
||||
"none",
|
||||
"frost_protection",
|
||||
"eco",
|
||||
"comfort",
|
||||
"comfort-1",
|
||||
"comfort-2",
|
||||
"auto",
|
||||
"boost",
|
||||
"external",
|
||||
"prog",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "climate-preset-modes",
|
||||
style: "dropdown",
|
||||
preset_modes: [
|
||||
"none",
|
||||
"frost_protection",
|
||||
"eco",
|
||||
"comfort",
|
||||
"comfort-1",
|
||||
"comfort-2",
|
||||
"auto",
|
||||
"boost",
|
||||
"external",
|
||||
"prog",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Preset only example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.overkiz_towel_dryer
|
||||
features:
|
||||
- type: climate-hvac-modes
|
||||
hvac_modes:
|
||||
- heat
|
||||
- 'off'
|
||||
- type: climate-preset-modes
|
||||
style: icons
|
||||
preset_modes:
|
||||
- none
|
||||
- frost_protection
|
||||
- eco
|
||||
- comfort
|
||||
- comfort-1
|
||||
- comfort-2
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.overkiz_towel_dryer",
|
||||
features: [
|
||||
{
|
||||
type: "climate-hvac-modes",
|
||||
hvac_modes: ["heat", "off"],
|
||||
},
|
||||
{
|
||||
type: "climate-preset-modes",
|
||||
style: "icons",
|
||||
preset_modes: [
|
||||
"none",
|
||||
"frost_protection",
|
||||
"eco",
|
||||
"comfort",
|
||||
"comfort-1",
|
||||
"comfort-2",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan only example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.sensibo
|
||||
features:
|
||||
- type: climate-hvac-modes
|
||||
hvac_modes:
|
||||
- fan_only
|
||||
- 'off'
|
||||
- type: climate-fan-modes
|
||||
style: icons
|
||||
fan_modes:
|
||||
- low
|
||||
- high
|
||||
- type: climate-swing-modes
|
||||
style: icons
|
||||
swing_modes:
|
||||
- 'both'
|
||||
- 'rangefull'
|
||||
- 'off'
|
||||
swing_horizontal_modes:
|
||||
- 'both'
|
||||
- 'rangefull'
|
||||
- 'off'
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.sensibo",
|
||||
features: [
|
||||
{
|
||||
type: "climate-hvac-modes",
|
||||
hvac_modes: ["fan_only", "off"],
|
||||
},
|
||||
{
|
||||
type: "climate-fan-modes",
|
||||
style: "icons",
|
||||
fan_modes: ["low", "high"],
|
||||
},
|
||||
{
|
||||
type: "climate-swing-modes",
|
||||
style: "icons",
|
||||
swing_modes: ["both", "rangefull", "off"],
|
||||
},
|
||||
{
|
||||
type: "climate-swing-horizontal-modes",
|
||||
style: "icons",
|
||||
swing_horizontal_modes: ["both", "rangefull", "off"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non existing",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.nonexisting
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.nonexisting",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<ThermostatCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-thermostat-card")
|
||||
class DemoThermostatEntity extends LitElement {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { CoverEntityFeature } from "../../../../src/data/cover";
|
||||
import { LightColorMode } from "../../../../src/data/light";
|
||||
import { LockEntityFeature } from "../../../../src/data/lock";
|
||||
@@ -11,6 +12,7 @@ import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { ClimateEntityFeature } from "../../../../src/data/climate";
|
||||
import { FanEntityFeature } from "../../../../src/data/fan";
|
||||
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
@@ -166,190 +168,179 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Vertical example",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
vertical: true
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
vertical: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom color",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
color: pink
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
color: "pink",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Whole tile tap action",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
color: pink
|
||||
tap_action:
|
||||
action: toggle
|
||||
icon_tap_action:
|
||||
action: none
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
color: "pink",
|
||||
tap_action: {
|
||||
action: "toggle",
|
||||
},
|
||||
icon_tap_action: {
|
||||
action: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unknown entity",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.unknown
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable entity",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Climate",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: climate.thermostat
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.thermostat",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "person.paulus",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light brightness feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.bed_light
|
||||
features:
|
||||
- type: "light-brightness"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-brightness" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color temperature feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.bed_light
|
||||
features:
|
||||
- type: "color-temp"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color-temp" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Lock commands feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: lock.front_door
|
||||
features:
|
||||
- type: "lock-commands"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "lock.front_door",
|
||||
features: [{ type: "lock-commands" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Lock open door feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: lock.front_door
|
||||
features:
|
||||
- type: "lock-open-door"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "lock.front_door",
|
||||
features: [{ type: "lock-open-door" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Media player volume slider feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: media_player.living_room
|
||||
features:
|
||||
- type: "media-player-volume-slider"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "media_player.living_room",
|
||||
features: [{ type: "media-player-volume-slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Vacuum commands feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: vacuum.first_floor_vacuum
|
||||
features:
|
||||
- type: "vacuum-commands"
|
||||
commands:
|
||||
- start_pause
|
||||
- stop
|
||||
- return_home
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "vacuum.first_floor_vacuum",
|
||||
features: [
|
||||
{
|
||||
type: "vacuum-commands",
|
||||
commands: ["start_pause", "stop", "return_home"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Cover open close feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: cover.kitchen_shutter
|
||||
features:
|
||||
- type: "cover-open-close"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "cover.kitchen_shutter",
|
||||
features: [{ type: "cover-open-close" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Cover tilt feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: cover.pergola_roof
|
||||
features:
|
||||
- type: "cover-tilt"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "cover.pergola_roof",
|
||||
features: [{ type: "cover-tilt" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Number buttons feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: input_number.counter
|
||||
features:
|
||||
- type: numeric-input
|
||||
style: buttons
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "input_number.counter",
|
||||
features: [{ type: "numeric-input", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Dual thermostat feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: climate.dual_thermostat
|
||||
features:
|
||||
- type: target-temperature
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features: [{ type: "target-temperature" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan direction feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: fan.fan_demo
|
||||
features:
|
||||
- type: fan-direction
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.fan_demo",
|
||||
features: [{ type: "fan-direction" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: fan.fan_demo
|
||||
features:
|
||||
- type: fan-speed
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.fan_demo",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan oscillate feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: fan.fan_demo
|
||||
features:
|
||||
- type: fan-oscillate
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.fan_demo",
|
||||
features: [{ type: "fan-oscillate" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
class DemoTile extends LitElement {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { customElement, query } from "lit/decorators";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { mockTodo } from "../../../../demo/src/stubs/todo";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { TodoListCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -27,20 +29,20 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "List example",
|
||||
config: `
|
||||
- type: todo-list
|
||||
entity: todo.shopping_list
|
||||
`,
|
||||
config: {
|
||||
type: "todo-list",
|
||||
entity: "todo.shopping_list",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "List with title example",
|
||||
config: `
|
||||
- type: todo-list
|
||||
title: Shopping List
|
||||
entity: todo.read_only
|
||||
`,
|
||||
config: {
|
||||
type: "todo-list",
|
||||
title: "Shopping List",
|
||||
entity: "todo.read_only",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<TodoListCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-todo-list-card")
|
||||
class DemoTodoListEntity extends LitElement {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Cloud account
|
||||
---
|
||||
|
||||
The [Home Assistant Cloud](https://www.nabucasa.com/) account page, rendered from
|
||||
mocked cloud data. The controls at the top flip the mocked subscription, remote,
|
||||
backup, onboarding, and feature state so every UI state can be previewed here
|
||||
without a real cloud account.
|
||||
@@ -0,0 +1,568 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
|
||||
import "../../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../../src/components/ha-switch";
|
||||
import type { BackupConfig } from "../../../../src/data/backup";
|
||||
import { BackupScheduleRecurrence } from "../../../../src/data/backup";
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
RemoteCertificateStatus,
|
||||
SubscriptionInfo,
|
||||
} from "../../../../src/data/cloud";
|
||||
import { ONBOARDING_ITEMS } from "../../../../src/data/cloud";
|
||||
import type { Webhook } from "../../../../src/data/webhook";
|
||||
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
|
||||
import "../../../../src/panels/config/cloud/account/cloud-account-onboarding";
|
||||
import "../../../../src/panels/config/cloud/account/cloud-account-overview";
|
||||
import { onboardingComplete } from "../../../../src/panels/config/cloud/account/cloud-account-status";
|
||||
import { showCloudOnboardingDialog } from "../../../../src/panels/config/cloud/account/show-dialog-cloud-onboarding";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
// This demo renders the self-contained cloud account cards (overview +
|
||||
// onboarding) directly, driven by mocked data, with controls to flip the
|
||||
// subscription, remote, backup, onboarding, and feature state so every UI state
|
||||
// can be previewed. It intentionally does NOT render the <cloud-account> panel
|
||||
// wrapper, which adds a hass-subpage toolbar/back button and route links that
|
||||
// have no home in the gallery. See src/panels/config/cloud/account.
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups run, but only to the local agent (no cloud copy).
|
||||
// "none": no automatic backups at all.
|
||||
type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
// Onboarding not completed and streaming (WebRTC) left off, so the onboarding
|
||||
// card shows by default with streaming as the remaining step.
|
||||
onboarded: false,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: false,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_OPTIONS: { value: DemoCloudAccount; label: string }[] = [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "trialing", label: "Trialing" },
|
||||
{ value: "canceled", label: "Canceled" },
|
||||
{ value: "expired", label: "Expired" },
|
||||
{ value: "unknown", label: "Unknown" },
|
||||
];
|
||||
|
||||
const REMOTE_STATUS_OPTIONS: {
|
||||
value: RemoteCertificateStatus;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "ready", label: "Ready" },
|
||||
{ value: "generating", label: "Preparing" },
|
||||
{ value: "loading", label: "Loading" },
|
||||
{ value: "loaded", label: "Loaded" },
|
||||
{ value: "error", label: "Error" },
|
||||
];
|
||||
|
||||
const BACKUP_OPTIONS: { value: DemoCloudBackup; label: string }[] = [
|
||||
{ value: "fresh", label: "Recent" },
|
||||
{ value: "stale", label: "Old" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "local", label: "Local only" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const TOGGLES: [keyof CloudDemoScenario, string][] = [
|
||||
["onboarded", "Onboarded"],
|
||||
["postponed", "Onboarding postponed"],
|
||||
["remote", "Remote access"],
|
||||
["alexa", "Alexa linked"],
|
||||
["google", "Google linked"],
|
||||
["webrtc", "Cameras (WebRTC)"],
|
||||
["webhooks", "Has webhooks"],
|
||||
];
|
||||
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
include_entities: [],
|
||||
exclude_domains: [],
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
const demoWebhooks: Webhook[] = [
|
||||
{
|
||||
webhook_id: "demo_front_door",
|
||||
domain: "automation",
|
||||
name: "Front door motion",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
webhook_id: "demo_companion_app",
|
||||
domain: "mobile_app",
|
||||
name: "Companion app",
|
||||
local_only: false,
|
||||
},
|
||||
];
|
||||
|
||||
const buildSubscription = (scenario: CloudDemoScenario): SubscriptionInfo => ({
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
subscription: { status: scenario.account },
|
||||
});
|
||||
|
||||
const buildCloudStatus = (scenario: CloudDemoScenario): CloudStatusLoggedIn => {
|
||||
const active =
|
||||
scenario.account !== "canceled" && scenario.account !== "expired";
|
||||
const cloudhooks = scenario.webhooks
|
||||
? Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
)
|
||||
: {};
|
||||
return {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "[email protected]",
|
||||
google_registered: scenario.google,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: scenario.alexa,
|
||||
alexa_entities: emptyFilter(),
|
||||
remote_domain: "demo-instance.ui.nabu.casa",
|
||||
remote_connected: scenario.remote,
|
||||
remote_certificate: {
|
||||
common_name: "demo-instance.ui.nabu.casa",
|
||||
expire_date: "2099-01-01T00:00:00+00:00",
|
||||
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
|
||||
alternative_names: ["demo-instance.ui.nabu.casa"],
|
||||
},
|
||||
remote_certificate_status: scenario.remoteStatus,
|
||||
http_use_ssl: false,
|
||||
active_subscription: active,
|
||||
onboarding_postponed: scenario.postponed,
|
||||
onboarding_completed: scenario.onboarded,
|
||||
prefs: {
|
||||
google_enabled: scenario.google,
|
||||
alexa_enabled: scenario.alexa,
|
||||
remote_enabled: scenario.remote,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks,
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: scenario.webrtc,
|
||||
onboarded_items: scenario.onboarded ? [...ONBOARDING_ITEMS] : [],
|
||||
onboarding_postponed_until: scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildBackupConfig = (scenario: CloudDemoScenario): BackupConfig => {
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
const cloudEnabled =
|
||||
scenario.backup === "fresh" ||
|
||||
scenario.backup === "stale" ||
|
||||
scenario.backup === "failed";
|
||||
|
||||
let configured = true;
|
||||
let lastCompleted: string | null = null;
|
||||
let lastAttempted: string | null = null;
|
||||
let next: string | null = null;
|
||||
switch (scenario.backup) {
|
||||
case "fresh":
|
||||
case "local":
|
||||
lastCompleted = recent;
|
||||
lastAttempted = recent;
|
||||
next = future;
|
||||
break;
|
||||
case "stale":
|
||||
lastCompleted = old;
|
||||
lastAttempted = old;
|
||||
next = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
lastCompleted = old;
|
||||
lastAttempted = recent;
|
||||
next = future;
|
||||
break;
|
||||
case "none":
|
||||
configured = false;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
automatic_backups_configured: configured,
|
||||
last_attempted_automatic_backup: lastAttempted,
|
||||
last_completed_automatic_backup: lastCompleted,
|
||||
next_automatic_backup: next,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
include_folders: [],
|
||||
name: null,
|
||||
password: null,
|
||||
},
|
||||
retention: { copies: 3, days: null },
|
||||
schedule: {
|
||||
recurrence: BackupScheduleRecurrence.DAILY,
|
||||
time: null,
|
||||
days: [],
|
||||
},
|
||||
agents: {
|
||||
"backup.local": { protected: true, retention: null },
|
||||
"cloud.cloud": { protected: true, retention: null },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@customElement("demo-misc-cloud-account")
|
||||
export class DemoMiscCloudAccount
|
||||
extends LitElement
|
||||
implements ProvideHassElement
|
||||
{
|
||||
@state() private hass!: HomeAssistant;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = { ...DEFAULT_SCENARIO };
|
||||
|
||||
@state() private _cloudStatus!: CloudStatusLoggedIn;
|
||||
|
||||
@state() private _subscription!: SubscriptionInfo;
|
||||
|
||||
@state() private _backupConfig!: BackupConfig;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("config", "en");
|
||||
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"cloud",
|
||||
"backup",
|
||||
"webhook",
|
||||
],
|
||||
},
|
||||
});
|
||||
this._registerMocks(hass);
|
||||
this._applyScenario();
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._showDialog);
|
||||
this.addEventListener("cloud-open-onboarding", this._openOnboarding);
|
||||
this.addEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
|
||||
// The overview and onboarding dialog contain real <a href="/config/..">
|
||||
// links to panel routes that do not exist in the gallery. Keep them inert.
|
||||
this.addEventListener("click", this._neutralizeNavigation);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._showDialog);
|
||||
this.removeEventListener("cloud-open-onboarding", this._openOnboarding);
|
||||
this.removeEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
|
||||
this.removeEventListener("click", this._neutralizeNavigation);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
const showOnboarding =
|
||||
this._cloudStatus.active_subscription &&
|
||||
!this._cloudStatus.onboarding_completed &&
|
||||
!this._cloudStatus.onboarding_postponed &&
|
||||
!onboardingComplete(this._cloudStatus, this._backupConfig);
|
||||
|
||||
return html`
|
||||
<div class="options">
|
||||
<div class="selects">
|
||||
${this._select("Subscription", "account", SUBSCRIPTION_OPTIONS)}
|
||||
${this._select("Remote status", "remoteStatus", REMOTE_STATUS_OPTIONS)}
|
||||
${this._select("Backups", "backup", BACKUP_OPTIONS)}
|
||||
</div>
|
||||
<div class="switches">
|
||||
${TOGGLES.map(([field, label]) => this._toggle(label, field))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview">
|
||||
${
|
||||
showOnboarding
|
||||
? html`
|
||||
<cloud-account-onboarding
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this._cloudStatus}
|
||||
.backupConfig=${this._backupConfig}
|
||||
></cloud-account-onboarding>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<cloud-account-overview
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this._cloudStatus}
|
||||
.subscription=${this._subscription}
|
||||
.backupConfig=${this._backupConfig}
|
||||
.webhooks=${demoWebhooks}
|
||||
></cloud-account-overview>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _select(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: { value: string; label: string }[]
|
||||
) {
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${label}
|
||||
.value=${String(this._scenario[field])}
|
||||
.options=${options}
|
||||
data-field=${field}
|
||||
@selected=${this._selectChanged}
|
||||
></ha-select>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<ha-formfield .label=${label}>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._switchChanged}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
`;
|
||||
}
|
||||
|
||||
private _selectChanged(ev: HaSelectSelectEvent) {
|
||||
const field = (ev.currentTarget as HTMLElement).dataset
|
||||
.field as keyof CloudDemoScenario;
|
||||
this._setField(field, ev.detail.value as string);
|
||||
}
|
||||
|
||||
private _switchChanged(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._setField(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.checked
|
||||
);
|
||||
}
|
||||
|
||||
private _setField(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
if (this._scenario[field] === value) {
|
||||
return;
|
||||
}
|
||||
this._scenario = { ...this._scenario, [field]: value };
|
||||
this._applyScenario();
|
||||
}
|
||||
|
||||
private _applyScenario() {
|
||||
this._cloudStatus = buildCloudStatus(this._scenario);
|
||||
this._subscription = buildSubscription(this._scenario);
|
||||
this._backupConfig = buildBackupConfig(this._scenario);
|
||||
}
|
||||
|
||||
private _openOnboarding = () => {
|
||||
showCloudOnboardingDialog(this, {
|
||||
cloudStatus: this._cloudStatus,
|
||||
backupConfig: this._backupConfig,
|
||||
onChanged: () => this._refreshFromMocks(),
|
||||
});
|
||||
};
|
||||
|
||||
private _showDialog = (ev: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
ev.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
private _refreshFromMocks = () => {
|
||||
this._cloudStatus = {
|
||||
...this._cloudStatus,
|
||||
prefs: { ...this._cloudStatus.prefs },
|
||||
};
|
||||
this._backupConfig = { ...this._backupConfig };
|
||||
const cloudBackup =
|
||||
this._backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
this._scenario = {
|
||||
...this._scenario,
|
||||
onboarded: this._cloudStatus.onboarding_completed,
|
||||
postponed: this._cloudStatus.onboarding_postponed,
|
||||
remote: this._cloudStatus.prefs.remote_enabled,
|
||||
webrtc: this._cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
backup: !this._backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudBackup
|
||||
? this._scenario.backup === "fresh" ||
|
||||
this._scenario.backup === "stale" ||
|
||||
this._scenario.backup === "failed"
|
||||
? this._scenario.backup
|
||||
: "fresh"
|
||||
: "local",
|
||||
};
|
||||
};
|
||||
|
||||
private _neutralizeNavigation = (ev: MouseEvent) => {
|
||||
const anchor = ev
|
||||
.composedPath()
|
||||
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement);
|
||||
if (anchor?.getAttribute("href")?.startsWith("/")) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private _registerMocks(hass: MockHomeAssistant) {
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...this._cloudStatus,
|
||||
prefs: { ...this._cloudStatus.prefs },
|
||||
}));
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
this._cloudStatus.prefs = { ...this._cloudStatus.prefs, ...prefs };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/postpone", () => {
|
||||
this._cloudStatus.onboarding_postponed = true;
|
||||
this._cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
return { ...this._cloudStatus, prefs: { ...this._cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
this._cloudStatus.remote_connected = true;
|
||||
this._cloudStatus.prefs.remote_enabled = true;
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
this._cloudStatus.remote_connected = false;
|
||||
this._cloudStatus.prefs.remote_enabled = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("backup/config/info", () => ({
|
||||
config: { ...this._backupConfig },
|
||||
}));
|
||||
hass.mockWS("backup/config/update", (msg) => {
|
||||
const { type, ...update } = msg;
|
||||
if (update.create_backup) {
|
||||
this._backupConfig.create_backup = {
|
||||
...this._backupConfig.create_backup,
|
||||
...update.create_backup,
|
||||
};
|
||||
}
|
||||
if (update.automatic_backups_configured !== undefined) {
|
||||
this._backupConfig.automatic_backups_configured =
|
||||
update.automatic_backups_configured;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.options {
|
||||
max-width: 600px;
|
||||
margin: 16px auto 0;
|
||||
padding: 0 16px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.selects {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.selects ha-select {
|
||||
min-width: 160px;
|
||||
flex: 1;
|
||||
}
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
.preview {
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6, 24px);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-misc-cloud-account": DemoMiscCloudAccount;
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -114,7 +114,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.7",
|
||||
"marked": "18.0.9",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -147,13 +147,13 @@
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.7",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -186,9 +186,8 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.8.0",
|
||||
"globals": "17.9.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
@@ -211,7 +210,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"typescript-eslint": "8.66.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -224,7 +223,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.8.0",
|
||||
"globals": "17.9.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { getHistoryState, updateHistoryState } from "../navigate";
|
||||
import { throttle } from "../util/throttle";
|
||||
|
||||
const throttleReplaceState = throttle((value) => {
|
||||
updateHistoryState({ scrollPosition: value });
|
||||
history.replaceState({ scrollPosition: value }, "");
|
||||
}, 300);
|
||||
|
||||
export function restoreScroll(selector: string) {
|
||||
@@ -40,8 +39,7 @@ export function restoreScroll(selector: string) {
|
||||
newDescriptor = {
|
||||
get(this: ReactiveElement) {
|
||||
return (
|
||||
this[`__${String(propertyKey)}`] ||
|
||||
getHistoryState()?.scrollPosition
|
||||
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
|
||||
);
|
||||
},
|
||||
set(this: ReactiveElement, value) {
|
||||
|
||||
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let href = anchor.href;
|
||||
if (!href || href.indexOf("mailto:") !== -1) {
|
||||
let url: URL;
|
||||
try {
|
||||
// anchor.href is always absolute; an empty or unparseable value throws.
|
||||
url = new URL(anchor.href);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const location = window.location;
|
||||
const origin = location.origin || location.protocol + "//" + location.host;
|
||||
if (!href.startsWith(origin)) {
|
||||
return undefined;
|
||||
}
|
||||
href = href.slice(origin.length);
|
||||
|
||||
if (href === "#") {
|
||||
// Only intercept same-origin links. A different scheme, host, or port is a
|
||||
// different origin (e.g. another port like ":8123") and must trigger a full
|
||||
// browser navigation instead of an in-app route change. Non-http(s) schemes
|
||||
// such as mailto: resolve to a null origin and are excluded here too.
|
||||
if (url.origin !== window.location.origin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return href;
|
||||
return url.pathname + url.search + url.hash;
|
||||
};
|
||||
|
||||
+41
-78
@@ -1,7 +1,6 @@
|
||||
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
|
||||
import { fireEvent } from "./dom/fire_event";
|
||||
import { mainWindow } from "./dom/get_main_window";
|
||||
import { currentPath } from "./url/current-path";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -12,38 +11,12 @@ declare global {
|
||||
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
const DIALOG_WAIT_TIMEOUT = 500;
|
||||
|
||||
/**
|
||||
* State of the current history entry. Always read through this, the app writes
|
||||
* to the main window and a panel running in an iframe has its own history.
|
||||
*/
|
||||
export const getHistoryState = (): any => mainWindow.history.state;
|
||||
|
||||
/**
|
||||
* Merge into the current history entry's state, keeping what is already there.
|
||||
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
|
||||
* they must never be replaced wholesale.
|
||||
*/
|
||||
export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, ...patch },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite the URL of the current history entry without navigating and without
|
||||
* touching its state. For query parameter cleanup.
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stash a destination URL in the current history entry's state. If the page
|
||||
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
|
||||
@@ -51,7 +24,10 @@ export const replaceCurrentUrl = (url: string) => {
|
||||
* The current URL is not changed.
|
||||
*/
|
||||
export const setRefreshUrl = (path: string) => {
|
||||
updateHistoryState({ refreshUrl: path });
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, refreshUrl: path },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,17 +56,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
) => {
|
||||
const state = typeof data === "object" ? data : undefined;
|
||||
if (from === undefined) {
|
||||
return state ?? null;
|
||||
}
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
@@ -98,32 +63,37 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
}
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__ && !path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
history.replaceState(buildHistoryState(data, from), "", path);
|
||||
} else {
|
||||
history.pushState(
|
||||
buildHistoryState(options?.data, currentPath()),
|
||||
if (__DEMO__) {
|
||||
if (!path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
@@ -131,17 +101,8 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
* `location.assign`, which leaves /auth/authorize right behind the requested
|
||||
* page, and going back there would bounce the user out of the app.
|
||||
*/
|
||||
export const canGoBack = (): boolean =>
|
||||
mainWindow.history.state?.from !== undefined;
|
||||
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
* Navigate back in history, with fallback to a default path if no history exists.
|
||||
* This prevents a user from getting stuck when they navigate directly to a page with no history.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -149,12 +110,14 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
mainWindow.history.back();
|
||||
// Check if we have history to go back to
|
||||
const { history } = mainWindow;
|
||||
if (history.length > 1) {
|
||||
history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
// No history available, navigate to fallback path
|
||||
const fallback = fallbackPath || "/";
|
||||
navigate(fallback, { replace: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// RFC 1123 hostname label: 1-63 chars, alphanumeric, with hyphens allowed
|
||||
// only between the first and last character. The hyphen is escaped because a
|
||||
// `pattern` attribute is compiled with the `v` flag, under which a trailing
|
||||
// unescaped hyphen is an invalid character class — and a pattern that fails to
|
||||
// compile is silently ignored, disabling validation altogether.
|
||||
const LABEL = "[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
|
||||
|
||||
// Hostname such as "localhost" or "homeassistant.lan", as dot-separated
|
||||
// labels. Unanchored, for use as an HTML `pattern` attribute (the browser
|
||||
// anchors it as `^(?:…)$`). The final label may not be all digits, so a
|
||||
// mistyped IP address like "300.1.1.1" is rejected rather than accepted as a
|
||||
// hostname. Deliberately excludes underscores and a trailing dot.
|
||||
export const HOSTNAME_PATTERN = `(?:${LABEL}\\.)*(?!\\d+$)${LABEL}`;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
|
||||
/**
|
||||
* The path of the page currently shown by the app. The demo routes with the
|
||||
* hash instead of the pathname, see navigate().
|
||||
*/
|
||||
export const currentPath = (): string =>
|
||||
__DEMO__
|
||||
? mainWindow.location.hash.substring(1)
|
||||
: mainWindow.location.pathname;
|
||||
@@ -53,8 +53,10 @@ export class HaControlNumberButton extends LitElement {
|
||||
});
|
||||
|
||||
private _boundedValue(value: number) {
|
||||
const clamped = conditionalClamp(value, this.min, this.max);
|
||||
return Math.round(clamped / this._step) * this._step;
|
||||
// Clamp after snapping: when the step does not divide the range evenly,
|
||||
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
|
||||
const stepped = Math.round(value / this._step) * this._step;
|
||||
return conditionalClamp(stepped, this.min, this.max);
|
||||
}
|
||||
|
||||
private get _step() {
|
||||
@@ -67,10 +69,7 @@ export class HaControlNumberButton extends LitElement {
|
||||
|
||||
private get _tenPercentStep() {
|
||||
if (this.max == null || this.min == null) return this._step;
|
||||
const range = this.max - this.min / 10;
|
||||
|
||||
if (range <= this._step) return this._step;
|
||||
return Math.max(range / 10);
|
||||
return Math.max((this.max - this.min) / 10, this._step);
|
||||
}
|
||||
|
||||
private _handlePlusButton() {
|
||||
|
||||
@@ -115,7 +115,9 @@ export class HaControlSlider extends LitElement {
|
||||
}
|
||||
|
||||
steppedValue(value: number) {
|
||||
return Math.round(value / this.step) * this.step;
|
||||
// Clamp after snapping: when the step does not divide the range evenly,
|
||||
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
|
||||
return this.boundedValue(Math.round(value / this.step) * this.step);
|
||||
}
|
||||
|
||||
private _displayedValue(value: number) {
|
||||
@@ -254,13 +256,9 @@ export class HaControlSlider extends LitElement {
|
||||
} else if (e.code === "End") {
|
||||
this.value = this.max;
|
||||
} else if (e.code === "PageUp") {
|
||||
this.value = this.steppedValue(
|
||||
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
|
||||
);
|
||||
this.value = this.steppedValue((this.value ?? 0) + this._tenPercentStep);
|
||||
} else if (e.code === "PageDown") {
|
||||
this.value = this.steppedValue(
|
||||
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
|
||||
);
|
||||
this.value = this.steppedValue((this.value ?? 0) - this._tenPercentStep);
|
||||
} else {
|
||||
const isRtl = mainWindow.document.dir === "rtl";
|
||||
let multiplier = 1;
|
||||
|
||||
@@ -18,6 +18,8 @@ type HlsLite = Omit<
|
||||
"subtitleTrackController" | "audioTrackController" | "emeController"
|
||||
>;
|
||||
|
||||
const HIDDEN_CLEANUP_DELAY = 60000;
|
||||
|
||||
@customElement("ha-hls-player")
|
||||
class HaHLSPlayer extends LitElement {
|
||||
@state()
|
||||
@@ -76,13 +78,22 @@ class HaHLSPlayer extends LitElement {
|
||||
|
||||
private static streamCount = 0;
|
||||
|
||||
private _hiddenCleanupTimeout?: number;
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (document.pictureInPictureElement) {
|
||||
// video is playing in picture-in-picture mode, don't do anything
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
this._cleanUp();
|
||||
this._hiddenCleanupTimeout = window.setTimeout(() => {
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
this._cleanUp();
|
||||
}, HIDDEN_CLEANUP_DELAY);
|
||||
} else if (this._hiddenCleanupTimeout) {
|
||||
// stream was not cleaned up yet, just cancel the cleanup
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
} else {
|
||||
this._resetError();
|
||||
this._startHls();
|
||||
@@ -105,6 +116,8 @@ class HaHLSPlayer extends LitElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
HaHLSPlayer.streamCount -= 1;
|
||||
this._cleanUp();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export class HaSelectBox extends LitElement {
|
||||
class="list"
|
||||
style=${styleMap({ "--columns": columns })}
|
||||
.value=${this.value}
|
||||
?disabled=${this.disabled}
|
||||
@change=${this._radioChanged}
|
||||
>
|
||||
${this.options.map((option) => this._renderOption(option))}
|
||||
@@ -100,7 +101,7 @@ export class HaSelectBox extends LitElement {
|
||||
)}
|
||||
aria-labelledby=${`label-${option.value}`}
|
||||
.value=${option.value}
|
||||
.disabled=${disabled}
|
||||
.disabled=${option.disabled || false}
|
||||
></ha-radio-option>
|
||||
<div class="text">
|
||||
<span id=${`label-${option.value}`} class="label"
|
||||
|
||||
@@ -53,7 +53,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
value: behavior,
|
||||
label: this._localizeOption(behavior, "label"),
|
||||
description: this._localizeOption(behavior, "description"),
|
||||
disabled: this.disabled,
|
||||
...(isTrigger && {
|
||||
image: {
|
||||
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
|
||||
@@ -66,6 +65,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
<ha-select-box
|
||||
.options=${options}
|
||||
.value=${this.value ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
max_columns="1"
|
||||
?stacked_image=${isTrigger}
|
||||
@value-changed=${this._valueChanged}
|
||||
|
||||
@@ -131,6 +131,7 @@ export class HaNumberSelector extends LitElement {
|
||||
.hint=${isBox ? this.helper : undefined}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.validationMessage=${this.selector.number?.validation_message}
|
||||
type="number"
|
||||
autoValidate
|
||||
.withoutSpinButtons=${!isBox}
|
||||
|
||||
@@ -104,6 +104,7 @@ export class HaSelectSelector extends LitElement {
|
||||
<ha-select-box
|
||||
.options=${options}
|
||||
.value=${this.value as string | undefined}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._selectChanged}
|
||||
.maxColumns=${this.selector.select?.box_max_columns}
|
||||
></ha-select-box>
|
||||
|
||||
@@ -31,6 +31,8 @@ export class HaToast extends LitElement {
|
||||
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
|
||||
0;
|
||||
|
||||
@property({ type: Boolean }) public stacked = false;
|
||||
|
||||
@query(".toast")
|
||||
private _toast?: HTMLDivElement;
|
||||
|
||||
@@ -148,7 +150,12 @@ export class HaToast extends LitElement {
|
||||
}
|
||||
|
||||
private _showToastPopover(): void {
|
||||
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
|
||||
if (
|
||||
!this._toast ||
|
||||
this.stacked ||
|
||||
!popoverSupported ||
|
||||
this._isPopoverOpen()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,7 +163,12 @@ export class HaToast extends LitElement {
|
||||
}
|
||||
|
||||
private _hideToastPopover(): void {
|
||||
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
|
||||
if (
|
||||
!this._toast ||
|
||||
this.stacked ||
|
||||
!popoverSupported ||
|
||||
!this._isPopoverOpen()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,12 +199,15 @@ export class HaToast extends LitElement {
|
||||
class=${classMap({
|
||||
toast: true,
|
||||
active: this._active,
|
||||
stacked: this.stacked,
|
||||
visible: this._visible,
|
||||
})}
|
||||
style=${styleMap({
|
||||
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
|
||||
})}
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
popover=${ifDefined(
|
||||
popoverSupported && !this.stacked ? "manual" : undefined
|
||||
)}
|
||||
>
|
||||
<span class="message">${this.labelText}</span>
|
||||
<div class=${classMap({ actions: true, "has-action": hasAction })}>
|
||||
@@ -253,6 +268,15 @@ export class HaToast extends LitElement {
|
||||
transform: translate(calc(-50% * var(--scale-direction)), 0);
|
||||
}
|
||||
|
||||
.toast.stacked {
|
||||
position: static;
|
||||
transform: translateY(var(--ha-space-2));
|
||||
}
|
||||
|
||||
.toast.stacked.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { apiContext, connectionContext } from "../data/context";
|
||||
import "./ha-alert";
|
||||
|
||||
const HIDDEN_CLEANUP_DELAY = 60000;
|
||||
|
||||
/**
|
||||
* A WebRTC stream is established by first sending an offer through a signal
|
||||
* path via an integration. An answer is returned, then the rest of the stream
|
||||
@@ -68,13 +70,22 @@ class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
private _candidatesList: RTCIceCandidate[] = [];
|
||||
|
||||
private _hiddenCleanupTimeout?: number;
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (document.pictureInPictureElement) {
|
||||
// video is playing in picture-in-picture mode, don't do anything
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
this._cleanUp();
|
||||
this._hiddenCleanupTimeout = window.setTimeout(() => {
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
this._cleanUp();
|
||||
}, HIDDEN_CLEANUP_DELAY);
|
||||
} else if (this._hiddenCleanupTimeout) {
|
||||
// stream was not cleaned up yet, just cancel the cleanup
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
} else {
|
||||
this._startWebRtc();
|
||||
}
|
||||
@@ -116,6 +127,8 @@ class HaWebRtcPlayer extends LitElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
this._cleanUp();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
Circle,
|
||||
CircleMarker,
|
||||
Control,
|
||||
LatLngExpression,
|
||||
LatLngTuple,
|
||||
Layer,
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
import { isTouch } from "../../util/is_touch";
|
||||
import "../ha-icon-button";
|
||||
import "./ha-entity-marker";
|
||||
import { UNIT_KM } from "../../common/const";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -147,6 +149,9 @@ export class HaMap extends ReactiveElement {
|
||||
@property({ attribute: "cluster-markers", type: Boolean })
|
||||
public clusterMarkers = true;
|
||||
|
||||
@property({ attribute: "scale-ruler", type: Boolean })
|
||||
public scaleRuler = false;
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
@query("#map") private _mapElement?: HTMLElement;
|
||||
@@ -167,6 +172,8 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _mapCluster: MarkerClusterGroup | undefined;
|
||||
|
||||
private _scaleRulerControl?: Control.Scale;
|
||||
|
||||
private _mapPaths: (Polyline | CircleMarker)[] = [];
|
||||
|
||||
private _clickCount = 0;
|
||||
@@ -206,6 +213,8 @@ export class HaMap extends ReactiveElement {
|
||||
this.Leaflet = undefined;
|
||||
}
|
||||
|
||||
// the control went away with the map, so don't hold on to it
|
||||
this._scaleRulerControl = undefined;
|
||||
this._pendingFit = undefined;
|
||||
this._loaded = false;
|
||||
|
||||
@@ -243,6 +252,16 @@ export class HaMap extends ReactiveElement {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
|
||||
if (
|
||||
changedProps.has("_loaded") ||
|
||||
changedProps.has("scaleRuler") ||
|
||||
(changedProps.has("_config") &&
|
||||
oldConfig?.unit_system?.length !== this._config?.unit_system?.length)
|
||||
) {
|
||||
this._drawScaleRuler();
|
||||
}
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("paths")) {
|
||||
this._drawPaths();
|
||||
}
|
||||
@@ -806,6 +825,25 @@ export class HaMap extends ReactiveElement {
|
||||
this._mapZones.forEach((marker) => map.addLayer(marker));
|
||||
}
|
||||
|
||||
private _drawScaleRuler(): void {
|
||||
if (this._scaleRulerControl) {
|
||||
this.leafletMap?.removeControl(this._scaleRulerControl);
|
||||
this._scaleRulerControl = undefined;
|
||||
}
|
||||
|
||||
if (!this.scaleRuler || !this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metric = this._config?.unit_system?.length === UNIT_KM;
|
||||
this._scaleRulerControl = this.Leaflet.control.scale({
|
||||
position: "bottomleft",
|
||||
metric,
|
||||
imperial: !metric,
|
||||
});
|
||||
this._scaleRulerControl.addTo(this.leafletMap);
|
||||
}
|
||||
|
||||
private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
|
||||
const markerSizeVarValue =
|
||||
computedStyles.getPropertyValue("--ha-marker-size");
|
||||
@@ -886,6 +924,37 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-bottom {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
.leaflet-control-scale {
|
||||
cursor: unset !important;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
--scale-ruler-color: var(--ha-color-on-surface-default);
|
||||
--scale-ruler-surface: var(--ha-color-surface-default);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-family: var(--ha-font-family-body);
|
||||
color: var(--scale-ruler-color) !important;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--scale-ruler-surface) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
/* the theme tokens follow the page, so forced modes need the opposite values */
|
||||
#map.forced-light .leaflet-control-scale-line {
|
||||
--scale-ruler-color: var(--ha-color-neutral-05);
|
||||
--scale-ruler-surface: var(--ha-color-white);
|
||||
}
|
||||
#map.forced-dark .leaflet-control-scale-line {
|
||||
--scale-ruler-color: var(--ha-color-neutral-95);
|
||||
--scale-ruler-surface: var(--ha-color-neutral-10);
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 10px !important;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
.leaflet-tooltip {
|
||||
padding: 8px;
|
||||
font-size: var(--ha-font-size-s);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type {
|
||||
HassEntity,
|
||||
HassEntityAttributeBase,
|
||||
HassEntityBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { stateActive } from "../common/entity/state_active";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { HomeAssistant, ServiceCallResponse } from "../types";
|
||||
|
||||
@@ -66,6 +70,75 @@ export type SceneMetaData = Record<
|
||||
{ entity_only?: boolean | undefined }
|
||||
>;
|
||||
|
||||
// Hand-edited scenes.yaml is parsed as YAML 1.1 by the backend, so unquoted
|
||||
// on/off arrive here as booleans. The backend applies boolean states as
|
||||
// on/off; mirror that so the badge reflects what activating the scene will
|
||||
// actually do. Everything else the backend rejects (numbers, null, arrays,
|
||||
// objects) yields undefined.
|
||||
const normalizeSceneEntityState = (sceneState: unknown): string | undefined => {
|
||||
if (typeof sceneState === "boolean") {
|
||||
return sceneState ? "on" : "off";
|
||||
}
|
||||
if (typeof sceneState === "string") {
|
||||
return sceneState;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Builds a state object from the scene's stored target state, so the scene
|
||||
// editor can render the icon the entity will have once the scene is applied
|
||||
// rather than its current live icon. The parameter is typed unknown because
|
||||
// the scene config API returns raw YAML: booleans, numbers, nulls, and
|
||||
// malformed shapes occur in hand-edited files beyond what the SceneEntities
|
||||
// union declares. Entries that hold no usable target state (an entity left
|
||||
// without a value in the YAML editor parses as null, a dict may lack a state
|
||||
// key) yield undefined so the caller renders no badge instead of guessing.
|
||||
//
|
||||
// The result is a partial HassEntity meant for badge rendering only - it has
|
||||
// no last_changed, last_updated, or context.
|
||||
export const sceneEntityStateObj = (
|
||||
entityId: string,
|
||||
sceneEntity: unknown
|
||||
): HassEntity | undefined => {
|
||||
if (
|
||||
typeof sceneEntity !== "object" ||
|
||||
sceneEntity === null ||
|
||||
Array.isArray(sceneEntity)
|
||||
) {
|
||||
const state = normalizeSceneEntityState(sceneEntity);
|
||||
return state === undefined
|
||||
? undefined
|
||||
: ({ entity_id: entityId, state, attributes: {} } as HassEntity);
|
||||
}
|
||||
const { state: sceneState, ...attributes } = sceneEntity as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const state = normalizeSceneEntityState(sceneState);
|
||||
if (state === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// Media-derived entity pictures are snapshotted with an access token that
|
||||
// is stale by the time review mode renders, which would leave the badge
|
||||
// showing a broken image instead of an icon. Stable pictures on other
|
||||
// domains are kept - the same policy createHistoricState applies for the
|
||||
// logbook.
|
||||
if (DOMAINS_WITH_DYNAMIC_PICTURE.has(computeDomain(entityId))) {
|
||||
delete attributes.entity_picture;
|
||||
delete attributes.entity_picture_local;
|
||||
}
|
||||
const stateObj = { entity_id: entityId, state, attributes } as HassEntity;
|
||||
// A live entity never carries color attributes while off, and state-badge
|
||||
// applies rgb_color and brightness without checking activity; drop them for
|
||||
// inactive targets so a scene that turns a light off does not render an
|
||||
// active-looking colored icon.
|
||||
if (!stateActive(stateObj)) {
|
||||
delete attributes.rgb_color;
|
||||
delete attributes.brightness;
|
||||
}
|
||||
return stateObj;
|
||||
};
|
||||
|
||||
export const activateScene = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string
|
||||
|
||||
@@ -397,6 +397,9 @@ export interface NumberSelector {
|
||||
unit_of_measurement?: string;
|
||||
slider_ticks?: boolean;
|
||||
translation_key?: string;
|
||||
// Shown instead of the browser's native message when the value fails
|
||||
// min/max/step constraint validation.
|
||||
validation_message?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,7 @@ import {
|
||||
getEntityEntryContext,
|
||||
} from "../../common/entity/context/get_entity_context";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -272,12 +268,16 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _setView(view: MoreInfoView) {
|
||||
updateHistoryState({
|
||||
dialogParams: {
|
||||
...getHistoryState()?.dialogParams,
|
||||
view,
|
||||
history.replaceState(
|
||||
{
|
||||
...history.state,
|
||||
dialogParams: {
|
||||
...history.state?.dialogParams,
|
||||
view,
|
||||
},
|
||||
},
|
||||
});
|
||||
""
|
||||
);
|
||||
this._currView = view;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
|
||||
/**
|
||||
* Shared behavior of the toolbar back arrow. The arrow is a link to the
|
||||
* declared parent page so it can be opened in a new tab, but a plain click
|
||||
* returns to the page the user came from instead.
|
||||
*/
|
||||
export const handleBackClick = (
|
||||
ev: MouseEvent,
|
||||
backPath?: string,
|
||||
backCallback?: () => void
|
||||
): void => {
|
||||
const path = sanitizeNavigationPath(backPath);
|
||||
|
||||
// Ctrl, cmd and shift click open the parent in a new tab or window: let
|
||||
// the anchor handle those. A plain click is handled here instead, and
|
||||
// isNavigationClick calls preventDefault so the anchor stays inert.
|
||||
if (path && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backCallback) {
|
||||
backCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
goBack(path);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { getHistoryState, goBack } from "../common/navigate";
|
||||
import { goBack } from "../common/navigate";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -27,7 +27,7 @@ class HassErrorScreen extends LitElement {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!(this.rootnav || getHistoryState()?.root)}
|
||||
.backButton=${!(this.rootnav || history.state?.root)}
|
||||
>
|
||||
${this._renderContent()}
|
||||
</ha-top-app-bar-fixed>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import "../components/animation/ha-fade-in";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import "../components/ha-spinner";
|
||||
@@ -28,7 +27,7 @@ class HassLoadingScreen extends LitElement {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!(this.rootnav || getHistoryState()?.root)}
|
||||
.backButton=${!(this.rootnav || history.state?.root)}
|
||||
>
|
||||
${this._renderContent()}
|
||||
</ha-top-app-bar-fixed>
|
||||
|
||||
+19
-11
@@ -4,9 +4,8 @@ import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -38,14 +37,19 @@ class HassSubpage extends LitElement {
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
this.mainPage || history.state?.root
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="main-title">
|
||||
@@ -75,8 +79,12 @@ class HassSubpage extends LitElement {
|
||||
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
|
||||
}
|
||||
|
||||
private _backTapped(ev: MouseEvent): void {
|
||||
handleBackClick(ev, this.backPath, this.backCallback);
|
||||
private _backTapped(): void {
|
||||
if (this.backCallback) {
|
||||
this.backCallback();
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -14,10 +14,9 @@ import { canShowPage } from "../common/config/can_show_page";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { getHistoryState, navigate } from "../common/navigate";
|
||||
import { goBack, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -174,14 +173,19 @@ export class HassTabsSubpage extends LitElement {
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
this.mainPage || (!backPath && history.state?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
${
|
||||
this._narrow || !this.showTabs
|
||||
@@ -242,8 +246,12 @@ export class HassTabsSubpage extends LitElement {
|
||||
this._content.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
private _backTapped(ev: MouseEvent): void {
|
||||
handleBackClick(ev, this.backPath, this.backCallback);
|
||||
private _backTapped(): void {
|
||||
if (this.backCallback) {
|
||||
this.backCallback();
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
}
|
||||
|
||||
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
|
||||
|
||||
@@ -102,13 +102,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
) {
|
||||
this.checkDataBaseMigration();
|
||||
}
|
||||
// Wait for `hass.user` to populate so the admin guard can run; it arrives
|
||||
// asynchronously after `hass.config`.
|
||||
if (
|
||||
changedProps.has("hass") &&
|
||||
this.hass?.user &&
|
||||
oldHass?.user !== this.hass.user
|
||||
) {
|
||||
// Wait for `hass.user` to first populate so the admin guard can run; it
|
||||
// arrives asynchronously after `hass.config`. `hass.user` also gets a fresh
|
||||
// reference at runtime (reconnect, profile refresh via subscribeUser), so
|
||||
// only trigger on the initial population (null -> user). Reconnect re-checks
|
||||
// come from connection-mixin, the launch-screen swap re-check from update().
|
||||
if (changedProps.has("hass") && this.hass?.user && !oldHass?.user) {
|
||||
this.checkHttpPendingConfig();
|
||||
}
|
||||
if (
|
||||
@@ -125,12 +124,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
this.hass?.states &&
|
||||
this.hass.config &&
|
||||
this.hass.services &&
|
||||
this._databaseMigration === false
|
||||
) {
|
||||
const removingLaunchScreen =
|
||||
!!this.hass?.states &&
|
||||
!!this.hass.config &&
|
||||
!!this.hass.services &&
|
||||
this._databaseMigration === false;
|
||||
if (removingLaunchScreen) {
|
||||
this.render = this.renderHass;
|
||||
this.update = super.update;
|
||||
// partial-panel-resolver removes the launch screen after the first panel
|
||||
@@ -138,6 +137,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// screen covers the frontend until frontend/loaded is sent.
|
||||
}
|
||||
super.update(changedProps);
|
||||
if (removingLaunchScreen) {
|
||||
// Surface the HTTP pending config dialog only after super.update() has
|
||||
// committed the render swap above, which clears the launch screen from
|
||||
// the shadow root. Appending the dialog before that render would let it
|
||||
// tear the freshly-added dialog straight back out of the DOM.
|
||||
this.checkHttpPendingConfig();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
@@ -256,6 +262,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
if (__DEMO__ || this._httpPendingDialogOpen) {
|
||||
return;
|
||||
}
|
||||
// Only show once the main UI is rendered. During startup the root swaps
|
||||
// the launch screen for the app, which clears its shadow root and would
|
||||
// tear the freshly-appended dialog straight back out of the DOM (closing
|
||||
// it). When called too early we skip; the swap in update() re-runs this.
|
||||
if (this.render !== this.renderHass) {
|
||||
return;
|
||||
}
|
||||
if (!this.hass?.user?.is_admin) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
} from "../common/dom/fire_event";
|
||||
import { popoverSupported } from "../common/feature-detect/support-popover";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-icon-button";
|
||||
@@ -10,7 +23,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface ShowToastParams {
|
||||
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
|
||||
// Unique ID for updating or closing a specific toast without flickering.
|
||||
id?: string;
|
||||
message:
|
||||
| string
|
||||
@@ -34,105 +47,150 @@ export interface ToastActionParams {
|
||||
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
key: string;
|
||||
parameters: ShowToastParams;
|
||||
}
|
||||
|
||||
@customElement("notification-manager")
|
||||
class NotificationManager extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _parameters?: ShowToastParams;
|
||||
@state() private _notifications: Notification[] = [];
|
||||
|
||||
@query("ha-toast")
|
||||
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
|
||||
@query(".stack") private _stack?: HTMLDivElement;
|
||||
|
||||
private _showDialogId = 0;
|
||||
@queryAll("ha-toast")
|
||||
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
|
||||
|
||||
private _anonymousId = 0;
|
||||
|
||||
public async showDialog(parameters: ShowToastParams) {
|
||||
const showId = ++this._showDialogId;
|
||||
|
||||
if (!parameters.id || this._parameters?.id !== parameters.id) {
|
||||
await this._toast?.hide();
|
||||
}
|
||||
|
||||
if (showId !== this._showDialogId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parameters.duration === 0) {
|
||||
this._parameters = undefined;
|
||||
if (parameters.id) {
|
||||
await this._closeNotification(`identified-${parameters.id}`);
|
||||
} else {
|
||||
const notification = [...this._notifications]
|
||||
.reverse()
|
||||
.find(({ parameters: { id } }) => !id);
|
||||
if (notification) {
|
||||
await this._closeNotification(notification.key);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._parameters = parameters;
|
||||
const normalizedParameters = {
|
||||
...parameters,
|
||||
duration:
|
||||
parameters.duration === undefined ||
|
||||
(parameters.duration > 0 && parameters.duration <= 4000)
|
||||
? 4000
|
||||
: parameters.duration,
|
||||
};
|
||||
const key = parameters.id
|
||||
? `identified-${parameters.id}`
|
||||
: `anonymous-${++this._anonymousId}`;
|
||||
const existingIndex = parameters.id
|
||||
? this._notifications.findIndex(
|
||||
(notification) => notification.key === key
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (
|
||||
this._parameters.duration === undefined ||
|
||||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
|
||||
) {
|
||||
this._parameters.duration = 4000;
|
||||
}
|
||||
this._notifications =
|
||||
existingIndex === -1
|
||||
? [...this._notifications, { key, parameters: normalizedParameters }]
|
||||
: this._notifications.map((notification, index) =>
|
||||
index === existingIndex
|
||||
? { key, parameters: normalizedParameters }
|
||||
: notification
|
||||
);
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
if (showId !== this._showDialogId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._toast?.show();
|
||||
this._showStack();
|
||||
this._getToast(key)?.show();
|
||||
}
|
||||
|
||||
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
|
||||
private _toastClosed(
|
||||
ev: HASSDomEvent<ToastClosedEventDetail> &
|
||||
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
|
||||
) {
|
||||
const key = ev.currentTarget.dataset.notificationKey!;
|
||||
if (ev.detail.reason === "dismiss") {
|
||||
this._parameters?.dismiss?.();
|
||||
this._getNotification(key)?.parameters.dismiss?.();
|
||||
}
|
||||
this._parameters = undefined;
|
||||
this._removeNotification(key);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._parameters) {
|
||||
if (!this._notifications.length) {
|
||||
return nothing;
|
||||
}
|
||||
const bottomOffset = Math.max(
|
||||
...this._notifications.map(
|
||||
({ parameters }) => parameters.bottomOffset ?? 0
|
||||
)
|
||||
);
|
||||
return html`
|
||||
<ha-toast
|
||||
.labelText=${
|
||||
typeof this._parameters.message !== "string"
|
||||
? this.hass.localize(
|
||||
this._parameters.message.translationKey,
|
||||
this._parameters.message.args
|
||||
)
|
||||
: this._parameters.message
|
||||
}
|
||||
.announceText=${
|
||||
this._parameters.announceMessage
|
||||
? typeof this._parameters.announceMessage !== "string"
|
||||
? this.hass.localize(
|
||||
this._parameters.announceMessage.translationKey,
|
||||
this._parameters.announceMessage.args
|
||||
)
|
||||
: this._parameters.announceMessage
|
||||
: undefined
|
||||
}
|
||||
.timeoutMs=${this._parameters.duration!}
|
||||
.bottomOffset=${this._parameters.bottomOffset ?? 0}
|
||||
@toast-closed=${this._toastClosed}
|
||||
<div
|
||||
class="stack"
|
||||
style=${styleMap({
|
||||
"--notification-stack-bottom-offset": `${bottomOffset}px`,
|
||||
})}
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
>
|
||||
${this._renderAction(this._parameters.secondaryAction, true)}
|
||||
${this._renderAction(this._parameters.action, false)}
|
||||
${
|
||||
this._parameters?.dismissable
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
slot="dismiss"
|
||||
@click=${this._dismissClicked}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-toast>
|
||||
${repeat(
|
||||
this._notifications,
|
||||
(notification) => notification.key,
|
||||
({ key, parameters }) => html`
|
||||
<ha-toast
|
||||
data-notification-key=${key}
|
||||
.labelText=${
|
||||
typeof parameters.message !== "string"
|
||||
? this.hass.localize(
|
||||
parameters.message.translationKey,
|
||||
parameters.message.args
|
||||
)
|
||||
: parameters.message
|
||||
}
|
||||
.announceText=${
|
||||
parameters.announceMessage
|
||||
? typeof parameters.announceMessage !== "string"
|
||||
? this.hass.localize(
|
||||
parameters.announceMessage.translationKey,
|
||||
parameters.announceMessage.args
|
||||
)
|
||||
: parameters.announceMessage
|
||||
: undefined
|
||||
}
|
||||
.timeoutMs=${parameters.duration!}
|
||||
.stacked=${true}
|
||||
@toast-closed=${this._toastClosed}
|
||||
>
|
||||
${this._renderAction(key, parameters.secondaryAction, true)}
|
||||
${this._renderAction(key, parameters.action, false)}
|
||||
${
|
||||
parameters.dismissable
|
||||
? html`
|
||||
<ha-icon-button
|
||||
data-notification-key=${key}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
slot="dismiss"
|
||||
@click=${this._dismissClicked}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-toast>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAction(
|
||||
key: string,
|
||||
action: ToastActionParams | undefined,
|
||||
secondary: boolean
|
||||
) {
|
||||
@@ -141,6 +199,7 @@ class NotificationManager extends LitElement {
|
||||
}
|
||||
return html`
|
||||
<ha-button
|
||||
data-notification-key=${key}
|
||||
appearance=${action.primary ? "filled" : "plain"}
|
||||
size="s"
|
||||
slot="action"
|
||||
@@ -155,19 +214,99 @@ class NotificationManager extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _buttonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.action?.action();
|
||||
private _buttonClicked(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
|
||||
) {
|
||||
const key = ev.currentTarget.dataset.notificationKey!;
|
||||
this._getToast(key)?.hide("action");
|
||||
this._getNotification(key)?.parameters.action?.action();
|
||||
}
|
||||
|
||||
private _secondaryButtonClicked() {
|
||||
this._toast?.hide("action");
|
||||
this._parameters?.secondaryAction?.action();
|
||||
private _secondaryButtonClicked(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
|
||||
) {
|
||||
const key = ev.currentTarget.dataset.notificationKey!;
|
||||
this._getToast(key)?.hide("action");
|
||||
this._getNotification(key)?.parameters.secondaryAction?.action();
|
||||
}
|
||||
|
||||
private _dismissClicked() {
|
||||
this._toast?.hide("dismiss");
|
||||
private _dismissClicked(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
|
||||
) {
|
||||
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
|
||||
}
|
||||
|
||||
private _getNotification(key: string) {
|
||||
return this._notifications.find((notification) => notification.key === key);
|
||||
}
|
||||
|
||||
private _getToast(key: string) {
|
||||
return [...this._toasts].find(
|
||||
(toast) => toast.dataset.notificationKey === key
|
||||
);
|
||||
}
|
||||
|
||||
private async _closeNotification(key: string) {
|
||||
const notification = this._getNotification(key);
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
await this._getToast(key)?.hide();
|
||||
if (this._getNotification(key) === notification) {
|
||||
this._removeNotification(key);
|
||||
}
|
||||
}
|
||||
|
||||
private _removeNotification(key: string) {
|
||||
this._notifications = this._notifications.filter(
|
||||
(notification) => notification.key !== key
|
||||
);
|
||||
if (!this._notifications.length) {
|
||||
this._hideStack();
|
||||
}
|
||||
}
|
||||
|
||||
private _showStack() {
|
||||
if (!popoverSupported || !this._stack) {
|
||||
return;
|
||||
}
|
||||
// Top-layer order is order of entry — re-enter so we paint above any
|
||||
// dialog backdrop opened since the stack was first shown.
|
||||
if (this._stack.matches(":popover-open")) {
|
||||
this._stack.hidePopover();
|
||||
}
|
||||
this._stack.showPopover();
|
||||
}
|
||||
|
||||
private _hideStack() {
|
||||
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
|
||||
return;
|
||||
}
|
||||
this._stack.hidePopover();
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
.stack {
|
||||
position: fixed;
|
||||
inset-block-start: auto;
|
||||
inset-inline-end: auto;
|
||||
inset-block-end: calc(
|
||||
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
|
||||
var(--notification-stack-bottom-offset, 0px)
|
||||
);
|
||||
inset-inline-start: 50%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
transform: translateX(calc(-50% * var(--scale-direction)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
@@ -455,7 +456,7 @@ class DialogAreaDetail
|
||||
return deviceReg && deviceReg.area_id === areaId;
|
||||
};
|
||||
|
||||
private _nameChanged(ev: InputEvent) {
|
||||
private _nameChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
|
||||
this._error = undefined;
|
||||
this._name = (ev.target as HaInput).value ?? "";
|
||||
this._updateDirtyState(this._currentState());
|
||||
@@ -479,7 +480,9 @@ class DialogAreaDetail
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
|
||||
private _pictureChanged(
|
||||
ev: ValueChangedEvent<string | null> & HASSDomTargetEvent<HaPictureUpload>
|
||||
) {
|
||||
this._error = undefined;
|
||||
this._picture = (ev.target as HaPictureUpload).value;
|
||||
this._updateDirtyState(this._currentState());
|
||||
@@ -490,7 +493,9 @@ class DialogAreaDetail
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private _sensorChanged(ev: CustomEvent): void {
|
||||
private _sensorChanged(
|
||||
ev: ValueChangedEvent<string> & HASSDomTargetEvent<HaEntityPicker>
|
||||
): void {
|
||||
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
|
||||
const key = `_${deviceClass}Entity`;
|
||||
this[key] = ev.detail.value || null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/chips/ha-chip-set";
|
||||
import "../../../components/chips/ha-input-chip";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -336,13 +337,13 @@ class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private _nameChanged(ev: InputEvent) {
|
||||
private _nameChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
|
||||
this._error = undefined;
|
||||
this._name = (ev.target as HaInput).value ?? "";
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private _levelChanged(ev: InputEvent) {
|
||||
private _levelChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
|
||||
this._error = undefined;
|
||||
this._level =
|
||||
(ev.target as HaInput).value === ""
|
||||
|
||||
@@ -644,7 +644,6 @@ class HaConfigAreaPage extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/areas/dashboard"
|
||||
.header=${html`${
|
||||
area.icon
|
||||
? html`<ha-icon
|
||||
|
||||
@@ -86,6 +86,8 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
|
||||
@state() private _hierarchy?: AreasFloorHierarchy;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _blockHierarchyUpdate = false;
|
||||
|
||||
private _blockHierarchyUpdateTimeout?: number;
|
||||
@@ -166,7 +168,9 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.isWide=${this.isWide}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.tabs=${configSections.areas}
|
||||
.route=${this.route}
|
||||
has-fab
|
||||
|
||||
@@ -49,10 +49,7 @@ export default class HaAutomationActionEditor extends LitElement {
|
||||
<div
|
||||
class=${classMap({
|
||||
"card-content": true,
|
||||
disabled:
|
||||
!this.indent &&
|
||||
(this.disabled ||
|
||||
(this.action.enabled === false && !this.yamlMode)),
|
||||
disabled: !this.indent && this.disabled,
|
||||
yaml: yamlMode,
|
||||
indent: this.indent,
|
||||
card: !this.inSidebar,
|
||||
|
||||
@@ -128,8 +128,12 @@ class DialogAutomationSave
|
||||
`;
|
||||
}
|
||||
|
||||
private get _isDiscardDialog(): boolean {
|
||||
return this._params?.onDiscard !== undefined;
|
||||
}
|
||||
|
||||
protected _renderDiscard() {
|
||||
if (!this._params?.onDiscard) {
|
||||
if (!this._isDiscardDialog) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
@@ -324,10 +328,14 @@ class DialogAutomationSave
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${!!this._params.config.alias && !this.isDirtyState}
|
||||
.disabled=${
|
||||
!!this._params.config.alias &&
|
||||
!this._isDiscardDialog &&
|
||||
!this.isDirtyState
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
this._params.config.alias && !this._params.onDiscard
|
||||
this._params.config.alias && !this._isDiscardDialog
|
||||
? "ui.panel.config.automation.editor.rename"
|
||||
: "ui.common.save"
|
||||
)}
|
||||
|
||||
@@ -56,10 +56,7 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
<div
|
||||
class=${classMap({
|
||||
"card-content": true,
|
||||
disabled:
|
||||
!this.indent &&
|
||||
(this.disabled ||
|
||||
(this.condition.enabled === false && !this.yamlMode)),
|
||||
disabled: !this.indent && this.disabled,
|
||||
yaml: yamlMode,
|
||||
indent: this.indent,
|
||||
card: !this.inSidebar,
|
||||
|
||||
@@ -986,7 +986,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
private async _delete() {
|
||||
if (this.automationId) {
|
||||
await deleteAutomation(this.hass, this.automationId);
|
||||
goBack(this.dashboardPath);
|
||||
goBack("/config");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,7 +443,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
id="entity_id"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
|
||||
@@ -147,10 +147,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
|
||||
protected domainHooks!: EditorDomainHooks<TConfig>;
|
||||
|
||||
protected get dashboardPath(): string {
|
||||
return `/config/${this.domainHooks.domain}/dashboard`;
|
||||
}
|
||||
|
||||
protected entityRegCreated?: (
|
||||
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
|
||||
) => void;
|
||||
@@ -256,7 +252,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
protected backTapped = async () => {
|
||||
const result = await this.confirmUnsavedChanged();
|
||||
if (result) {
|
||||
afterNextRender(() => goBack(this.dashboardPath));
|
||||
afterNextRender(() => goBack("/config"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,7 +300,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
),
|
||||
text: html`<pre>${alertText}</pre>`,
|
||||
});
|
||||
goBack(this.dashboardPath);
|
||||
goBack("/config");
|
||||
return;
|
||||
}
|
||||
const entity = this.entityRegistry?.find(
|
||||
@@ -321,7 +317,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
`ui.panel.config.${domain}.editor.load_error_not_editable`
|
||||
),
|
||||
});
|
||||
goBack(this.dashboardPath);
|
||||
goBack("/config");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -110,7 +110,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/automation/dashboard"
|
||||
.header=${title}
|
||||
.scrollable=${this.narrow}
|
||||
>
|
||||
@@ -453,7 +452,11 @@ export class HaAutomationTrace extends LitElement {
|
||||
if (runId) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("run_id");
|
||||
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${location.pathname}?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
await showAlertDialog(this, {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
|
||||
import {
|
||||
extractSearchParam,
|
||||
@@ -36,6 +35,8 @@ import type HaAutomationSidebar from "./ha-automation-sidebar";
|
||||
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 500;
|
||||
|
||||
export const PASTED_CONFIG_TOAST_ID = "pasted-config";
|
||||
|
||||
export const ManualEditorMixin = <TConfig>(
|
||||
superClass: Constructor<LitElement>
|
||||
) => {
|
||||
@@ -172,7 +173,11 @@ export const ManualEditorMixin = <TConfig>(
|
||||
}
|
||||
|
||||
protected clearParam(param: string) {
|
||||
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
constructUrlCurrentPath(removeSearchParam(param))
|
||||
);
|
||||
}
|
||||
|
||||
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
|
||||
@@ -234,6 +239,7 @@ export const ManualEditorMixin = <TConfig>(
|
||||
this.pastedConfig = undefined;
|
||||
|
||||
showToast(this, {
|
||||
id: PASTED_CONFIG_TOAST_ID,
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
@@ -37,7 +37,10 @@ import "./action/ha-automation-action";
|
||||
import type HaAutomationAction from "./action/ha-automation-action";
|
||||
import "./condition/ha-automation-condition";
|
||||
import type HaAutomationCondition from "./condition/ha-automation-condition";
|
||||
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
|
||||
import {
|
||||
ManualEditorMixin,
|
||||
PASTED_CONFIG_TOAST_ID,
|
||||
} from "./ha-manual-editor-mixin";
|
||||
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
|
||||
import { manualEditorStyles, saveFabStyles } from "./styles";
|
||||
import "./trigger/ha-automation-trigger";
|
||||
@@ -431,6 +434,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
|
||||
protected showPastedToastWithUndo() {
|
||||
showEditorToast(this, {
|
||||
id: PASTED_CONFIG_TOAST_ID,
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.paste_toast_message"
|
||||
),
|
||||
|
||||
@@ -48,11 +48,7 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
<div
|
||||
class=${classMap({
|
||||
"card-content": true,
|
||||
disabled:
|
||||
this.disabled ||
|
||||
("enabled" in this.trigger &&
|
||||
this.trigger.enabled === false &&
|
||||
!this.yamlMode),
|
||||
disabled: this.disabled,
|
||||
yaml: yamlMode,
|
||||
card: !this.inSidebar,
|
||||
})}
|
||||
|
||||
@@ -74,6 +74,8 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
@state() private _config?: BackupConfig;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("config") && !this._config) {
|
||||
@@ -204,7 +206,9 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -119,7 +118,7 @@ class HaConfigBackupSettings extends LitElement {
|
||||
}
|
||||
|
||||
private _clearHash() {
|
||||
replaceCurrentUrl(window.location.pathname);
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mdiClose, mdiOpenInNew } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { withViewTransition } from "../../../common/util/view-transition";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -279,7 +280,7 @@ class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState
|
||||
});
|
||||
}
|
||||
|
||||
private _inputChanged(ev: Event) {
|
||||
private _inputChanged(ev: HASSDomTargetEvent<HaInput>) {
|
||||
this._updateDirtyState({
|
||||
value: (ev.target as HaInput).value ?? "",
|
||||
hasResult: !!this._result,
|
||||
|
||||
@@ -13,7 +13,10 @@ import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
@@ -483,7 +486,7 @@ class HaBlueprintOverview extends LitElement {
|
||||
this._createNew(blueprint);
|
||||
}
|
||||
|
||||
private _handleUsageClick = (ev: Event) => {
|
||||
private _handleUsageClick = (ev: HASSDomCurrentTargetEvent<HTMLElement>) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const target = ev.currentTarget as HTMLElement | null;
|
||||
|
||||
@@ -21,7 +21,6 @@ export class CloudForgotPassword extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.forgot_password.title"
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,6 @@ export class CloudLoginPanel extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
|
||||
|
||||
@@ -38,7 +38,6 @@ export class CloudRegister extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
|
||||
>
|
||||
<div class="content">
|
||||
|
||||
@@ -66,6 +66,8 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _showSkipped = false;
|
||||
|
||||
@state() private _supervisorInfo?: HassioSupervisorInfo;
|
||||
@@ -153,7 +155,9 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.updates.caption")}
|
||||
|
||||
@@ -986,7 +986,6 @@ export class HaConfigDevicePage extends LitElement {
|
||||
return html`<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/devices/dashboard"
|
||||
.header=${deviceName}
|
||||
>
|
||||
<ha-tooltip for="edit-settings-button" slot="toolbar-icon">
|
||||
|
||||
@@ -23,11 +23,7 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
@@ -146,7 +142,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter: string = getHistoryState()?.filter || "";
|
||||
private _filter: string = history.state?.filter || "";
|
||||
|
||||
@state()
|
||||
private _filters: DataTableFilters = {};
|
||||
@@ -266,7 +262,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
this._filter = history.state?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-states": {
|
||||
@@ -782,7 +778,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.tabs=${configSections.devices}
|
||||
.route=${this.route}
|
||||
.searchLabel=${this.hass.localize(
|
||||
@@ -1045,7 +1043,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
updateHistoryState({ filter: this._filter });
|
||||
history.replaceState({ filter: this._filter }, "");
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
@@ -340,7 +341,7 @@ export class DialogEnergyBatterySettings
|
||||
}
|
||||
|
||||
private _handlePowerConfigChanged(
|
||||
ev: CustomEvent<{ powerType: PowerType; powerConfig: PowerConfig }>
|
||||
ev: HASSDomEvent<HASSDomEvents["power-config-changed"]>
|
||||
) {
|
||||
this._powerType = ev.detail.powerType;
|
||||
this._powerConfig = ev.detail.powerConfig;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user