mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-07 16:03:15 +00:00
Compare commits
63 Commits
fix-52921
...
e2e-config
| Author | SHA1 | Date | |
|---|---|---|---|
| 591fb6426b | |||
| 0536e2cd2a | |||
| 0c25f061ef | |||
| 62e5c9dee9 | |||
| 36fa14ee26 | |||
| 14294998e7 | |||
| 60549695b0 | |||
| 41068657bd | |||
| 7d11f283d2 | |||
| 0927a55a0c | |||
| 9e75f9282e | |||
| 56af189032 | |||
| 02c99dd61d | |||
| f00342d522 | |||
| 5538be0d0a | |||
| 96b5c2910e | |||
| 30c44403de | |||
| 43c16f0c77 | |||
| 42fe3c6f97 | |||
| 903f954947 | |||
| 50e6691c30 | |||
| b0e8d45003 | |||
| 14e7f434e0 | |||
| 535b1b03f2 | |||
| 14db6c99fe | |||
| 97226acab9 | |||
| df0d1a945f | |||
| dccd798c26 | |||
| 757c7a3e7f | |||
| 7ccd1371bf | |||
| fe9017fbdf | |||
| 86181e293a | |||
| 0879d01dee | |||
| de358e4834 | |||
| d4855bfddd | |||
| ce3b38f2b4 | |||
| 18608a17ca | |||
| 80bffd605f | |||
| f4778bef29 | |||
| 0533b11816 | |||
| 5f1e495203 | |||
| 6f1b4fdc6c | |||
| 67e1ca3f43 | |||
| 36330d8220 | |||
| 0df3919a49 | |||
| b5e03b23b9 | |||
| ca1ab06384 | |||
| fb7ed8bfd4 | |||
| b72b6c77bf | |||
| 7f0ddae91e | |||
| 71e4303fa5 | |||
| 9bb7704a3a | |||
| 3cc9817b90 | |||
| 674755e430 | |||
| f84664909f | |||
| 4fd631f229 | |||
| 18cf41b793 | |||
| e28788cb95 | |||
| f81b43491d | |||
| 23335fffdb | |||
| 0a93a681e3 | |||
| 7bc2cad83e | |||
| 39ee60a8ef |
@@ -67,7 +67,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
|
||||
<!--
|
||||
If your issue is about how an entity is shown in the UI, please add the state
|
||||
and attributes for all situations with a screenshot of the UI.
|
||||
You can find this information at `/config/developer-tools/state`
|
||||
You can find this information at `/config/tools/state`
|
||||
-->
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -94,8 +94,8 @@ body:
|
||||
label: State of relevant entities
|
||||
description: >
|
||||
If your issue is about how an entity is shown in the UI, please add the
|
||||
state and attributes for all situations. You can find this information
|
||||
at Developer Tools -> States.
|
||||
state and attributes for all situations. You can find this
|
||||
information in the Details view of the More info dialog.
|
||||
render: txt
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Build frontend target
|
||||
description: Run a gulp build target
|
||||
|
||||
inputs:
|
||||
target:
|
||||
description: gulp target to run
|
||||
required: true
|
||||
github-token:
|
||||
description: GitHub token for fetching nightly translations; omit to build English-only
|
||||
default: ""
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Build ${{ inputs.target }}
|
||||
shell: bash
|
||||
run: ./node_modules/.bin/gulp ${{ inputs.target }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Deploy to Netlify
|
||||
description: Deploy a directory to Netlify (production when alias is empty, otherwise to the alias)
|
||||
|
||||
inputs:
|
||||
dir:
|
||||
description: Directory to deploy
|
||||
required: true
|
||||
alias:
|
||||
description: Deploy alias; leave empty to deploy to production
|
||||
default: ""
|
||||
auth-token:
|
||||
description: NETLIFY_AUTH_TOKEN
|
||||
required: true
|
||||
site-id:
|
||||
description: NETLIFY_SITE_ID
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
netlify_url:
|
||||
description: The deployed URL
|
||||
value: ${{ steps.deploy.outputs.netlify_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
shell: bash
|
||||
env:
|
||||
DIR: ${{ inputs.dir }}
|
||||
ALIAS: ${{ inputs.alias }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
|
||||
NETLIFY_SITE_ID: ${{ inputs.site-id }}
|
||||
run: |
|
||||
if [ -n "$ALIAS" ]; then
|
||||
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
else
|
||||
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
|
||||
fi
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Setup Node and install
|
||||
description: Set up Node from .nvmrc and install yarn dependencies
|
||||
|
||||
inputs:
|
||||
immutable:
|
||||
description: Pass --immutable to yarn install
|
||||
default: "true"
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
@@ -25,7 +25,8 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
|
||||
yarn test # Vitest
|
||||
script/develop # Development server
|
||||
yarn dev # Dev server (app; --background/--status/--stop/--logs)
|
||||
yarn dev:serve # Dev server with serve (-c core URL, -p port; --background/--status/--stop/--logs)
|
||||
```
|
||||
|
||||
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
|
||||
@@ -495,6 +496,26 @@ this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
4. **Test**: `yarn test` - Add and run tests
|
||||
5. **Build**: `script/build_frontend` - Test production build
|
||||
|
||||
### Dev servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core (`development_repo` setting). `yarn dev:serve` also serves it locally (`-c` core URL, `-p` port, default 8124).
|
||||
|
||||
These and the e2e dev servers below take `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
### End-to-end (e2e) tests
|
||||
|
||||
Each Playwright suite has a dev server on its own port. Playwright reuses a server already on the port (`reuseExistingServer` locally); otherwise it does a slow full build. The rspack watcher recompiles on save, so re-runs need no restart.
|
||||
|
||||
Start the suite's dev server, then run the suite:
|
||||
|
||||
- **App** (8095): `yarn test:e2e:app:dev`, then `yarn test:e2e:app`
|
||||
- **Demo** (8090): `yarn dev:demo`, then `yarn test:e2e:demo`
|
||||
- **Gallery** (8100): `yarn dev:gallery`, then `yarn test:e2e:gallery`
|
||||
|
||||
Server reuse and `--stop` key off a `/__ha_dev_status` health check, so starting or stopping twice is harmless. The app suite uses a stripped-down harness built only for e2e; demo and gallery use their normal dev servers.
|
||||
|
||||
Add `-g "<title>" --project=chromium` to narrow a run; `yarn test:e2e` runs all three. Run the suite directly, since piping through `tail`/`head` hides progress and truncates results.
|
||||
|
||||
### Gallery
|
||||
|
||||
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
# Dependabot only scans .github/workflows by default; composite actions
|
||||
# under .github/actions must be listed explicitly to stay updated.
|
||||
# https://github.com/dependabot/dependabot-core/issues/6704
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/setup"
|
||||
- "/.github/actions/build"
|
||||
- "/.github/actions/netlify-deploy"
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
|
||||
@@ -42,5 +42,36 @@ Dependencies:
|
||||
GitHub Actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/actions/**
|
||||
- .github/workflows/**
|
||||
- .github/*.yml
|
||||
|
||||
"Tests: E2E":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/**
|
||||
|
||||
"Tests: App":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/app.spec.ts
|
||||
- test/e2e/app/**
|
||||
- test/e2e/playwright.app.config.ts
|
||||
|
||||
"Tests: Demo":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/demo.spec.ts
|
||||
- test/e2e/playwright.demo.config.ts
|
||||
|
||||
"Tests: Design":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/gallery.spec.ts
|
||||
- test/e2e/playwright.gallery.config.ts
|
||||
|
||||
"Tests: Unit":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/*.ts
|
||||
- "test/!(e2e)/**"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Restricts Task issues to organization members: closes and labels the issue with
|
||||
// an explanatory comment when the author is not an org member. Invoked from the
|
||||
// `check-authorization` job in .github/workflows/restrict-task-creation.yml via
|
||||
// `check-authorization` job in .github/workflows/restrict-task-creation.yaml via
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkTaskAuthorization } =
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Lint workflow files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: Check workflow files
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# renovate: datasource=github-releases depName=rhysd/actionlint
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run actionlint
|
||||
run: |
|
||||
curl -sSfL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz actionlint
|
||||
./actionlint -color
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
if: github.event_name != 'push'
|
||||
environment:
|
||||
name: Cast Development
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -29,27 +29,23 @@ jobs:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
run: ./node_modules/.bin/gulp build-cast
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --alias dev
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
alias: dev
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,7 +53,7 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
environment:
|
||||
name: Cast Production
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -65,24 +61,19 @@ jobs:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
run: ./node_modules/.bin/gulp build-cast
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
+24
-27
@@ -12,7 +12,6 @@ on:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -30,19 +29,17 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
id: build_resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup lint cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
@@ -53,12 +50,16 @@ jobs:
|
||||
- name: Run eslint
|
||||
run: yarn run lint:eslint --quiet
|
||||
- name: Run tsc
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:types
|
||||
- name: Run lit-analyzer
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
@@ -68,37 +69,33 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Run Tests
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs: [lint, test]
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Application
|
||||
run: ./node_modules/.bin/gulp build-app
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
security-events: write # To upload CodeQL results
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -1,65 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, master]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Override automatic language detection by changing the below list
|
||||
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
|
||||
language: ["javascript"]
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
if: github.event_name != 'push' || github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -30,27 +30,22 @@ jobs:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -58,7 +53,7 @@ jobs:
|
||||
if: github.event_name == 'push' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Demo Production
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -66,24 +61,19 @@ jobs:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
|
||||
@@ -16,31 +16,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: Design
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
@@ -28,30 +28,23 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
--json > deploy_output.json
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
alias: deploy-preview-${{ github.event.number }}
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
- name: Generate summary
|
||||
run: |
|
||||
NETLIFY_LIVE_URL=$(jq -r '.deploy_url' deploy_output.json)
|
||||
echo "$NETLIFY_LIVE_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
run: echo "${{ steps.deploy.outputs.netlify_url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+31
-52
@@ -32,19 +32,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -64,19 +59,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build e2e test app
|
||||
run: ./node_modules/.bin/gulp build-e2e-test-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -96,19 +86,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -133,22 +118,22 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cache the downloaded browser build keyed on the pinned Playwright
|
||||
# version (yarn.lock), so re-runs skip the ~170 MB download.
|
||||
# Cache the downloaded browser build keyed on the installed Playwright
|
||||
# version, so re-runs skip the ~170 MB download unless Playwright changes.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
@@ -199,14 +184,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -25,18 +25,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
|
||||
- name: Download translations
|
||||
run: ./script/translations_download
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
|
||||
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -31,20 +31,18 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
cache: false
|
||||
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
@@ -97,7 +95,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -116,12 +114,11 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
cache: false
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
|
||||
@@ -22,15 +22,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- .github/workflows/translations.yaml
|
||||
- src/translations/en.json
|
||||
|
||||
permissions:
|
||||
@@ -22,6 +23,6 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Upload Translations
|
||||
run: |
|
||||
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
|
||||
./script/translations_upload_base
|
||||
run: ./script/translations_upload_base
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
@@ -6,6 +6,8 @@ build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
// Manage a Home Assistant frontend dev server with an agent-friendly interface.
|
||||
//
|
||||
// node build-scripts/dev-server.mjs --suite <suite> [mode] [extra args]
|
||||
//
|
||||
// (no mode) Run in the foreground.
|
||||
// --background Start detached, wait until it is ready, print the URL
|
||||
// (when it has one) and pid, then exit and leave it running.
|
||||
// --status Report whether the suite's dev server is running.
|
||||
// --stop Stop a running background dev server.
|
||||
// --logs [--follow] Print (or follow) the background dev server log.
|
||||
//
|
||||
// Extra args (for example -p or -c on app-serve) are forwarded to the underlying
|
||||
// script. Suites use one of two liveness models:
|
||||
//
|
||||
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
|
||||
// endpoint each dev server exposes (see runDevServer in
|
||||
// build-scripts/gulp/rspack.js). The port is the source of truth and
|
||||
// the pid is found from it; no state file.
|
||||
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
|
||||
// no health endpoint, and plain yarn dev has no port at all, so these
|
||||
// track a pidfile and treat the first "Build done" log line as ready.
|
||||
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const developAndServeScript = path.join(
|
||||
repoRoot,
|
||||
"script",
|
||||
"develop_and_serve"
|
||||
);
|
||||
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
||||
|
||||
// Each suite names its yarn alias (for hints), a liveness model, and how to
|
||||
// spawn it. health suites carry a fixed port; process suites carry the log line
|
||||
// that means "ready" and, for app-serve, forward extra args to the script.
|
||||
const SUITES = {
|
||||
"e2e-app": {
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
demo: {
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
gallery: {
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
app: {
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
},
|
||||
"app-serve": {
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// Cover a cold build on a slow machine before the server starts listening.
|
||||
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
|
||||
const READY_TIMEOUT_MS =
|
||||
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
|
||||
|
||||
// Detect a coding agent from a small set of environment markers set by common
|
||||
// agent CLIs (env-only; no process-ancestry detection).
|
||||
const detectAgent = () => {
|
||||
const env = process.env;
|
||||
const has = (name) => Boolean(env[name]);
|
||||
const eq = (name, value) => env[name] === value;
|
||||
const signals = {
|
||||
opencode: () =>
|
||||
[
|
||||
"OPENCODE",
|
||||
"OPENCODE_BIN_PATH",
|
||||
"OPENCODE_SERVER",
|
||||
"OPENCODE_APP_INFO",
|
||||
].some(has),
|
||||
"claude-code": () => has("CLAUDECODE"),
|
||||
cursor: () => has("CURSOR_TRACE_ID"),
|
||||
"github-copilot": () =>
|
||||
eq("TERM_PROGRAM", "vscode") && eq("GIT_PAGER", "cat"),
|
||||
// Convention shared by several agents (Crush, Amp, ...).
|
||||
generic: () => has("AGENT") || has("AI_AGENT"),
|
||||
};
|
||||
return Object.keys(signals).find((id) => signals[id]());
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
const suites = Object.keys(SUITES).join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
suite: undefined,
|
||||
passthrough: [],
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--suite":
|
||||
args.suite = argv[++i];
|
||||
break;
|
||||
case "--background":
|
||||
args.mode = "background";
|
||||
break;
|
||||
case "--status":
|
||||
args.mode = "status";
|
||||
break;
|
||||
case "--stop":
|
||||
args.mode = "stop";
|
||||
break;
|
||||
case "--logs":
|
||||
args.mode = "logs";
|
||||
break;
|
||||
case "--follow":
|
||||
args.follow = true;
|
||||
break;
|
||||
default:
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
|
||||
|
||||
const hints = (suite) => {
|
||||
const alias = `yarn ${SUITES[suite].alias}`;
|
||||
return (
|
||||
` Stop: ${alias} --stop\n` +
|
||||
` Status: ${alias} --status\n` +
|
||||
` Logs: ${alias} --logs\n`
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared spawning and lifecycle ------------------------------------------
|
||||
|
||||
// Signal the whole process group (the background server is its group leader),
|
||||
// falling back to the bare pid if that is not permitted.
|
||||
const killProcessTree = (pid, sig) => {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, sig);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
||||
|
||||
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
|
||||
const spawnInherit = (cmd, args) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
|
||||
// Spawn a detached server that writes stdout and stderr to the suite's log file.
|
||||
const spawnDetachedToLog = (suite, cmd, args) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = logFileFor(suite);
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
fs.closeSync(fd);
|
||||
child.unref();
|
||||
return { child, logFile };
|
||||
};
|
||||
|
||||
// Poll until the server is ready, the child exits, or we time out. Prints the
|
||||
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
|
||||
// the child dies before it is ready (used to clear a stale pidfile).
|
||||
const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
let childExited = false;
|
||||
child.on("exit", () => {
|
||||
childExited = true;
|
||||
});
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
process.stdout.write(`Starting ${suite} dev server`);
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is ready */
|
||||
while (Date.now() < deadline) {
|
||||
if (childExited) {
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) exited before it was ready. See ${logFile}\n`
|
||||
);
|
||||
onExit?.();
|
||||
return 1;
|
||||
}
|
||||
if (await isReady()) {
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(port)} ` +
|
||||
`(pid ${child.pid})\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
process.stdout.write(".");
|
||||
await sleep(1000);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) did not become ready within ${
|
||||
READY_TIMEOUT_MS / 1000
|
||||
}s. See ${logFile}\n`
|
||||
);
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
|
||||
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
|
||||
const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
killProcessTree(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is gone */
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(300);
|
||||
if (await isStopped()) {
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
// Escalate if it is still up.
|
||||
killProcessTree(pid, "SIGKILL");
|
||||
await sleep(300);
|
||||
if (!(await isStopped())) {
|
||||
process.stderr.write(
|
||||
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
// --- health liveness (port + /__ha_dev_status) ------------------------------
|
||||
|
||||
/**
|
||||
* Probe the health endpoint. Dev servers bind IPv4 or IPv6 localhost depending
|
||||
* on the OS, so try each; the port is "free" only if every address refuses.
|
||||
* @returns {Promise<{state: "ours" | "foreign" | "free", suite?: string}>}
|
||||
*/
|
||||
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
const probe = async (port, timeoutMs = 1000) => {
|
||||
let sawResponse = false;
|
||||
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
|
||||
for (const host of PROBE_HOSTS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/__ha_dev_status`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
sawResponse = true;
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (body && body.server === "ha-frontend-dev") {
|
||||
return { state: "ours", suite: body.suite };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try the next address.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
};
|
||||
|
||||
// Find the pid listening on a port via the first available tool (no state file).
|
||||
const pidFromPort = (port) => {
|
||||
const attempts = [
|
||||
[
|
||||
"lsof",
|
||||
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
|
||||
(out) => out.trim().split("\n")[0],
|
||||
],
|
||||
[
|
||||
"ss",
|
||||
["-ltnpH", `sport = :${port}`],
|
||||
(out) => out.match(/pid=(\d+)/)?.[1],
|
||||
],
|
||||
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
|
||||
];
|
||||
for (const [cmd, cmdArgs, extract] of attempts) {
|
||||
try {
|
||||
const out = execFileSync(cmd, cmdArgs, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const pid = Number(extract(out));
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
} catch {
|
||||
// Try the next tool.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const runForegroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) is already running at http://localhost:${port}\n`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (status.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const preflight = await probe(port);
|
||||
if (preflight.state === "ours" && preflight.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (preflight.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
cfg.spawn.args
|
||||
);
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () => {
|
||||
const status = await probe(port, 1000);
|
||||
return status.state === "ours" && status.suite === suite;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n`
|
||||
);
|
||||
} else if (status.state === "ours") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
|
||||
);
|
||||
} else if (status.state === "foreign") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (!(status.state === "ours" && status.suite === suite)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const pid = pidFromPort(port);
|
||||
if (!pid) {
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) is running but its pid could not be found ` +
|
||||
`(no lsof/ss/fuser?). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
async () => (await probe(port, 800)).state === "free"
|
||||
);
|
||||
};
|
||||
|
||||
// --- process liveness (pidfile + log-readiness) -----------------------------
|
||||
|
||||
const isAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
const readPidFile = (suite) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
|
||||
if (data && Number.isInteger(data.pid)) {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt.
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const writePidFile = (suite, data) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
|
||||
};
|
||||
|
||||
const removePidFile = (suite) => {
|
||||
try {
|
||||
fs.rmSync(pidFileFor(suite));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
const logIsReady = (logFile, readyLog) => {
|
||||
try {
|
||||
return readyLog.test(fs.readFileSync(logFile, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
|
||||
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
|
||||
const resolveServePort = (passthrough) => {
|
||||
const i = passthrough.indexOf("-p");
|
||||
if (i !== -1) {
|
||||
const port = Number(passthrough[i + 1]);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return process.env.DEVCONTAINER ? 8123 : 8124;
|
||||
};
|
||||
|
||||
const spawnArgs = (cfg, passthrough) => [
|
||||
...cfg.spawn.args,
|
||||
...(cfg.acceptsArgs ? passthrough : []),
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running in the background ` +
|
||||
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
spawnArgs(cfg, passthrough)
|
||||
);
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
writePidFile(suite, { pid: child.pid, port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => removePidFile(suite),
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n`
|
||||
);
|
||||
} else {
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (!existing || !isAlive(existing.pid)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const { pid } = existing;
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
() => !isAlive(pid),
|
||||
() => removePidFile(suite)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const logFile = logFileFor(suite);
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(
|
||||
`No log for the ${suite} dev server yet (${logFile}).\n`
|
||||
);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
tail.on("error", () => {
|
||||
// No tail available; fall back to a one-shot dump.
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
});
|
||||
tail.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg = SUITES[args.suite];
|
||||
if (!cfg) {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.passthrough.length && !cfg.acceptsArgs) {
|
||||
process.stderr.write(
|
||||
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
||||
);
|
||||
}
|
||||
|
||||
// A plain dev:<suite> under a coding agent backgrounds itself; explicit modes
|
||||
// are untouched.
|
||||
let { mode } = args;
|
||||
if (
|
||||
mode === "foreground" &&
|
||||
!["0", "false"].includes(process.env.HA_DEV_BACKGROUND)
|
||||
) {
|
||||
const agent = detectAgent();
|
||||
if (agent) {
|
||||
process.stdout.write(
|
||||
`Detected coding agent (${agent}); starting in the background. ` +
|
||||
`Set HA_DEV_BACKGROUND=0 to force foreground.\n`
|
||||
);
|
||||
mode = "background";
|
||||
}
|
||||
}
|
||||
|
||||
const health = cfg.liveness === "health";
|
||||
switch (mode) {
|
||||
case "background":
|
||||
return health
|
||||
? runBackgroundHealth(args.suite, cfg)
|
||||
: runBackgroundProcess(args.suite, cfg, args.passthrough);
|
||||
case "status":
|
||||
return health
|
||||
? runStatusHealth(args.suite, cfg)
|
||||
: runStatusProcess(args.suite);
|
||||
case "stop":
|
||||
return health
|
||||
? runStopHealth(args.suite, cfg)
|
||||
: runStopProcess(args.suite);
|
||||
case "logs":
|
||||
return runLogs(args.suite, args.follow);
|
||||
default:
|
||||
return health
|
||||
? runForegroundHealth(args.suite, cfg)
|
||||
: runForegroundProcess(args.suite, cfg, args.passthrough);
|
||||
}
|
||||
};
|
||||
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
@@ -37,6 +37,7 @@ const isWsl =
|
||||
* listenHost?: string,
|
||||
* open?: boolean,
|
||||
* logUrlAfterFirstBuild?: boolean,
|
||||
* suite?: string,
|
||||
* }}
|
||||
*/
|
||||
const runDevServer = async ({
|
||||
@@ -47,6 +48,7 @@ const runDevServer = async ({
|
||||
open = true,
|
||||
logUrlAfterFirstBuild = false,
|
||||
proxy = undefined,
|
||||
suite = undefined,
|
||||
}) => {
|
||||
if (listenHost === undefined) {
|
||||
// For dev container, we need to listen on all hosts
|
||||
@@ -81,6 +83,19 @@ const runDevServer = async ({
|
||||
!error?.message?.includes("ResizeObserver loop"),
|
||||
},
|
||||
},
|
||||
setupMiddlewares: (middlewares) => {
|
||||
// Status endpoint so the dev-server manager can confirm this is our
|
||||
// server for the expected suite. Unshifted to beat the static handler.
|
||||
middlewares.unshift({
|
||||
name: "ha-dev-status",
|
||||
path: "/__ha_dev_status",
|
||||
middleware: (_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ server: "ha-frontend-dev", suite, port }));
|
||||
},
|
||||
});
|
||||
return middlewares;
|
||||
},
|
||||
proxy,
|
||||
},
|
||||
compiler
|
||||
@@ -152,6 +167,8 @@ gulp.task("rspack-dev-server-demo", () =>
|
||||
),
|
||||
contentBase: paths.demo_output_root,
|
||||
port: 8090,
|
||||
open: false,
|
||||
suite: "demo",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -173,6 +190,7 @@ gulp.task("rspack-dev-server-cast", () =>
|
||||
port: 8080,
|
||||
// Accessible from the network, because that's how Cast hits it.
|
||||
listenHost: "0.0.0.0",
|
||||
suite: "cast",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -194,6 +212,7 @@ gulp.task("rspack-dev-server-gallery", () =>
|
||||
listenHost: "0.0.0.0",
|
||||
open: false,
|
||||
logUrlAfterFirstBuild: true,
|
||||
suite: "gallery",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -241,6 +260,7 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
open: false,
|
||||
suite: "e2e-app",
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Develop the demo
|
||||
# Develop the demo. Pass --background/--status/--stop/--logs to manage a
|
||||
# detached instance (see build-scripts/dev-server.mjs).
|
||||
|
||||
# Stop on errors
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
./node_modules/.bin/gulp develop-demo
|
||||
exec node build-scripts/dev-server.mjs --suite demo "$@"
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,13 @@ import { mockSystemLog } from "./stubs/system_log";
|
||||
import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
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.
|
||||
const CONFIG_PANEL_COMMANDS = [
|
||||
"cloud/",
|
||||
"webhook/list",
|
||||
"validate_config",
|
||||
"config_entries/",
|
||||
"device_automation/",
|
||||
@@ -69,6 +71,22 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
// `false` for contexts: HomeAssistantAppEl already provides them via
|
||||
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
|
||||
const hass = provideHass(this, initial, true, false);
|
||||
|
||||
// The cloud account page only fetches backup config and the webhook count
|
||||
// when those integrations are loaded. Enable them here (demo only) so the
|
||||
// mocked backup/config/info and webhook/list are queried.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...(hass.config?.components ?? []), "backup", "webhook"],
|
||||
},
|
||||
});
|
||||
|
||||
// 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(
|
||||
|
||||
+139
-25
@@ -7,42 +7,34 @@ 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 lastBackupDate = new Date(Date.now() - 86400000).toISOString();
|
||||
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
|
||||
|
||||
const backups: BackupContent[] = [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: lastBackupDate,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups,
|
||||
backups: [],
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
next_automatic_backup: nextBackupDate,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", "cloud.cloud"],
|
||||
agent_ids: ["backup.local", CLOUD_AGENT],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
@@ -69,10 +61,132 @@ 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) => {
|
||||
hass.mockWS("backup/info", () => backupInfo);
|
||||
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
|
||||
// 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
|
||||
// objects too).
|
||||
hass.mockWS("backup/info", () => ({ ...backupInfo }));
|
||||
hass.mockWS("backup/config/info", () => ({ config: { ...backupConfig } }));
|
||||
hass.mockWS("backup/agents/info", () => agentsInfo);
|
||||
hass.mockWS("backup/config/update", (msg) => {
|
||||
const { type, ...update } = msg;
|
||||
if (update.create_backup) {
|
||||
backupConfig.create_backup = {
|
||||
...backupConfig.create_backup,
|
||||
...update.create_backup,
|
||||
};
|
||||
}
|
||||
if (update.automatic_backups_configured !== undefined) {
|
||||
backupConfig.automatic_backups_configured =
|
||||
update.automatic_backups_configured;
|
||||
}
|
||||
if (update.schedule) {
|
||||
backupConfig.schedule = { ...backupConfig.schedule, ...update.schedule };
|
||||
}
|
||||
if (update.retention) {
|
||||
backupConfig.retention = update.retention;
|
||||
}
|
||||
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(
|
||||
"backup/subscribe_events",
|
||||
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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));
|
||||
};
|
||||
+157
-2
@@ -2,8 +2,15 @@ import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../src/data/cloud";
|
||||
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: [],
|
||||
@@ -12,8 +19,23 @@ const emptyFilter = () => ({
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// are reflected back in the UI.
|
||||
// (both via the real UI and the demo scenario controls) are reflected back.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
@@ -35,6 +57,8 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
alexa_enabled: true,
|
||||
@@ -47,6 +71,8 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
onboarded_items: [...ONBOARDING_ITEMS],
|
||||
onboarding_postponed_until: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,6 +80,7 @@ const subscription: SubscriptionInfo = {
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
subscription: { status: "active" },
|
||||
};
|
||||
|
||||
const ttsInfo: CloudTTSInfo = {
|
||||
@@ -66,17 +93,140 @@ 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);
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...cloudStatus,
|
||||
prefs: { ...cloudStatus.prefs },
|
||||
}));
|
||||
hass.mockWS("cloud/subscription", () => subscription);
|
||||
hass.mockWS("cloud/tts/info", () => ttsInfo);
|
||||
hass.mockWS("webhook/list", () => demoWebhooks);
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
syncScenarioFromStatus();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/postpone", () => {
|
||||
cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/complete", (msg) => {
|
||||
const items: string[] = msg.items ?? [];
|
||||
const missing = items.filter(
|
||||
(item) => !cloudStatus.prefs.onboarded_items.includes(item)
|
||||
);
|
||||
if (missing.length) {
|
||||
cloudStatus.prefs.onboarded_items = [
|
||||
...cloudStatus.prefs.onboarded_items,
|
||||
...missing,
|
||||
];
|
||||
}
|
||||
cloudStatus.onboarding_completed = ONBOARDING_ITEMS.every(
|
||||
(onboardingItem) =>
|
||||
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
|
||||
);
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/create", (msg) => {
|
||||
const webhook = {
|
||||
webhook_id: msg.webhook_id,
|
||||
@@ -95,15 +245,20 @@ 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;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Run the gallery
|
||||
# Run the gallery. Pass --background/--status/--stop/--logs to manage a
|
||||
# detached instance (see build-scripts/dev-server.mjs).
|
||||
|
||||
# Stop on errors
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
./node_modules/.bin/gulp develop-gallery
|
||||
exec node build-scripts/dev-server.mjs --suite gallery "$@"
|
||||
|
||||
+16
-12
@@ -24,6 +24,10 @@
|
||||
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"check-bundlesize": "node build-scripts/check-bundle-size.cjs",
|
||||
"dev": "node build-scripts/dev-server.mjs --suite app",
|
||||
"dev:serve": "node build-scripts/dev-server.mjs --suite app-serve",
|
||||
"dev:demo": "demo/script/develop_demo",
|
||||
"dev:gallery": "gallery/script/develop_gallery",
|
||||
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
|
||||
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
|
||||
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
@@ -45,7 +49,7 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.0",
|
||||
"@codemirror/view": "6.43.4",
|
||||
"@codemirror/view": "6.43.5",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
@@ -79,8 +83,8 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.0",
|
||||
"@tsparticles/preset-links": "4.3.0",
|
||||
"@tsparticles/engine": "4.3.1",
|
||||
"@tsparticles/preset-links": "4.3.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -102,9 +106,9 @@
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.5",
|
||||
"idb-keyval": "6.2.6",
|
||||
"intl-messageformat": "11.2.9",
|
||||
"js-yaml": "5.2.0",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -147,8 +151,8 @@
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.16",
|
||||
"@rspack/core": "2.1.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.17",
|
||||
"@rspack/core": "2.1.2",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -172,13 +176,13 @@
|
||||
"eslint": "10.6.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.0",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.6",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.7.0",
|
||||
@@ -196,9 +200,9 @@
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.0.2",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.1",
|
||||
"prettier": "3.9.4",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
@@ -206,7 +210,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.62.0",
|
||||
"typescript-eslint": "8.62.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
+10
-1
@@ -11,7 +11,7 @@
|
||||
"group:recommended",
|
||||
"security:minimumReleaseAgeNpm"
|
||||
],
|
||||
"enabledManagers": ["npm", "nvm"],
|
||||
"enabledManagers": ["npm", "nvm", "custom.regex"],
|
||||
"postUpdateOptions": ["yarnDedupeHighest"],
|
||||
"lockFileMaintenance": {
|
||||
"description": ["Run after patch releases but before next beta"],
|
||||
@@ -49,6 +49,15 @@
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python",
|
||||
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
|
||||
},
|
||||
{
|
||||
"description": "Keep actionlint used in CI up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/actionlint\\.yaml$/"],
|
||||
"matchStrings": ["ACTIONLINT_VERSION: (?<currentValue>\\S+)"],
|
||||
"depNameTemplate": "rhysd/actionlint",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
|
||||
@@ -176,7 +176,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
tabindex="-1"
|
||||
slot="next"
|
||||
></ha-icon-button-next>
|
||||
<calendar-month></calendar-month>
|
||||
<calendar-month dir=${this.dir}></calendar-month>
|
||||
</calendar-range>
|
||||
${
|
||||
this.timePicker
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mdiCalendar } from "@mdi/js";
|
||||
import "cally";
|
||||
import { isThisYear } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
@@ -26,6 +26,8 @@ import "../ha-icon-button-prev";
|
||||
import "../ha-textarea";
|
||||
import type { HaTextArea } from "../ha-textarea";
|
||||
import "./date-range-picker";
|
||||
import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl";
|
||||
import { translationMetadata } from "../../resources/translations-metadata";
|
||||
|
||||
export type DateRangePickerRanges = Record<string, [Date, Date]>;
|
||||
|
||||
@@ -110,17 +112,28 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
this._handleResize();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
}
|
||||
|
||||
const rangeKeys = this.extendedPresets
|
||||
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
|
||||
: RANGE_KEYS;
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
this._ranges = {};
|
||||
rangeKeys.forEach((key) => {
|
||||
this._ranges![
|
||||
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
|
||||
});
|
||||
if (
|
||||
changedProps.has("_i18n") ||
|
||||
changedProps.has("_hassConfig") ||
|
||||
changedProps.has("extendedPresets")
|
||||
) {
|
||||
const rangeKeys = this.extendedPresets
|
||||
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
|
||||
: RANGE_KEYS;
|
||||
|
||||
const ranges: DateRangePickerRanges = {};
|
||||
rangeKeys.forEach((key) => {
|
||||
ranges[
|
||||
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
|
||||
});
|
||||
this._ranges = ranges;
|
||||
}
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
@@ -239,8 +252,13 @@ export class HaDateRangePicker extends LitElement {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
const dir = emitRTLDirection(
|
||||
computeRTL(this._i18n.locale.language, translationMetadata.translations)
|
||||
);
|
||||
|
||||
return html`
|
||||
<date-range-picker
|
||||
dir=${dir}
|
||||
.ranges=${this.ranges === false ? false : this.ranges || this._ranges}
|
||||
.startDate=${this.startDate}
|
||||
.endDate=${this.endDate}
|
||||
|
||||
@@ -103,13 +103,15 @@ export const dateRangePickerStyles = css`
|
||||
}
|
||||
calendar-month::part(range-start),
|
||||
calendar-month::part(range-start):hover {
|
||||
border-top-left-radius: var(--ha-border-radius-circle);
|
||||
border-bottom-left-radius: var(--ha-border-radius-circle);
|
||||
/* logical: rounds the inline-start corners (works in LTR and RTL) */
|
||||
border-start-start-radius: var(--ha-border-radius-circle);
|
||||
border-end-start-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
calendar-month::part(range-end),
|
||||
calendar-month::part(range-end):hover {
|
||||
border-top-right-radius: var(--ha-border-radius-circle);
|
||||
border-bottom-right-radius: var(--ha-border-radius-circle);
|
||||
/* logical: rounds the inline-end corners (works in LTR and RTL) */
|
||||
border-start-end-radius: var(--ha-border-radius-circle);
|
||||
border-end-end-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
calendar-month::part(range-start):hover,
|
||||
calendar-month::part(range-end):hover,
|
||||
|
||||
@@ -71,6 +71,17 @@ export class HaControlSlider extends LitElement {
|
||||
@property({ type: Number })
|
||||
public step = 1;
|
||||
|
||||
/**
|
||||
* Round the value shown in the tooltip and announced to assistive
|
||||
* technologies to the nearest integer. The handle still snaps to `step`, so
|
||||
* the number of steps is unchanged. Useful when `step` is fractional but the
|
||||
* value is conceptually a whole number — e.g. a fan whose `percentage_step`
|
||||
* is 100 / speed_count (like ~1.0989 for 91 speeds), which would otherwise
|
||||
* display fractional percentages such as "28.57%".
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "round-value" })
|
||||
public roundValue = false;
|
||||
|
||||
@property({ type: Number })
|
||||
public min = 0;
|
||||
|
||||
@@ -107,6 +118,11 @@ export class HaControlSlider extends LitElement {
|
||||
return Math.round(value / this.step) * this.step;
|
||||
}
|
||||
|
||||
private _displayedValue(value: number) {
|
||||
const stepped = this.steppedValue(value);
|
||||
return this.roundValue ? Math.round(stepped) : stepped;
|
||||
}
|
||||
|
||||
boundedValue(value: number) {
|
||||
return Math.min(Math.max(value, this.min), this.max);
|
||||
}
|
||||
@@ -118,8 +134,8 @@ export class HaControlSlider extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("value")) {
|
||||
const valuenow = this.steppedValue(this.value ?? 0);
|
||||
if (changedProps.has("value") || changedProps.has("roundValue")) {
|
||||
const valuenow = this._displayedValue(this.value ?? 0);
|
||||
this.setAttribute("aria-valuenow", valuenow.toString());
|
||||
this.setAttribute("aria-valuetext", this._formatValue(valuenow));
|
||||
}
|
||||
@@ -312,7 +328,7 @@ export class HaControlSlider extends LitElement {
|
||||
this.tooltipMode === "always" ||
|
||||
(this.tooltipVisible && this.tooltipMode === "interaction");
|
||||
|
||||
const value = this.steppedValue(this.value ?? 0);
|
||||
const value = this._displayedValue(this.value ?? 0);
|
||||
|
||||
return html`
|
||||
<span
|
||||
@@ -330,7 +346,7 @@ export class HaControlSlider extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const valuenow = this.steppedValue(this.value ?? 0);
|
||||
const valuenow = this._displayedValue(this.value ?? 0);
|
||||
return html`
|
||||
<div
|
||||
class="container${classMap({
|
||||
|
||||
@@ -135,8 +135,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@state()
|
||||
private _bodyScrolled = false;
|
||||
|
||||
private _escapePressed = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener(
|
||||
@@ -306,7 +304,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Escape") {
|
||||
this._escapePressed = true;
|
||||
if (this.preventScrimClose) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
@@ -316,13 +313,23 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleHide(ev: DialogHideEvent) {
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
|
||||
ev.preventDefault();
|
||||
// Ignore wa-hide events bubbling up from nested overlays (pickers,
|
||||
// popovers, bottom sheets, nested dialogs); only handle this dialog's own
|
||||
// hide, like the sibling wa-* handlers above.
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._escapePressed = false;
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
// A dialog-sourced hide (Escape, native cancel, or scrim) while the host
|
||||
// still wants the dialog open is a user dismissal, not a programmatic
|
||||
// close. Block it when closing must be guarded, regardless of where focus
|
||||
// currently is — e.g. after a picker overlay took focus and dropped it
|
||||
// outside the dialog, the Escape keydown never reaches this element.
|
||||
if (this.preventScrimClose && sourceIsDialog && this.open) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { NumberSelector } from "../../data/selector";
|
||||
import { isSafari } from "../../util/is_safari";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-slider";
|
||||
import "../input/ha-input";
|
||||
@@ -66,6 +67,16 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
|
||||
// so negatives can only be typed with the full "text" keyboard. Other
|
||||
// platforms include a minus on their number keypads, so restrict this
|
||||
// workaround to Safari/WebKit and only when the selector allows negatives
|
||||
// (e.g. numeric_state triggers/conditions).
|
||||
const useTextInputMode =
|
||||
isSafari &&
|
||||
this.selector.number?.min !== undefined &&
|
||||
this.selector.number.min < 0;
|
||||
|
||||
const translationKey = this.selector.number?.translation_key;
|
||||
let unit = this.selector.number?.unit_of_measurement;
|
||||
if (isBox && unit && this.localizeValue && translationKey) {
|
||||
@@ -100,11 +111,13 @@ export class HaNumberSelector extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
<ha-input
|
||||
.inputMode=${
|
||||
this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"
|
||||
.inputmode=${
|
||||
useTextInputMode
|
||||
? "text"
|
||||
: this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"
|
||||
}
|
||||
.label=${!isBox ? undefined : this.label}
|
||||
.placeholder=${
|
||||
|
||||
@@ -525,10 +525,10 @@ export class HaServiceControl extends LitElement {
|
||||
this._manifest
|
||||
? html` <a
|
||||
href=${
|
||||
this._manifest.is_built_in
|
||||
this._manifest.is_built_in && this._value?.action
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/actions/${this._value.action}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
|
||||
@@ -479,6 +479,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
<div class="add-target-wrapper">
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
popover-placement="bottom-start"
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { uid } from "../../common/util/uid";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import "../ha-button";
|
||||
@@ -69,6 +70,25 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@query("ha-input[data-last]") private _lastInput?: HaInput;
|
||||
|
||||
// Stable key per row, kept in sync with `value`. Because items are plain
|
||||
// strings we cannot use a WeakMap (as the object-based sortable lists do),
|
||||
// so we track keys in a parallel array. Keys stay fixed while a row is
|
||||
// edited (preserving input focus) and travel with the row when reordered.
|
||||
@state() private _keys: string[] = [];
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("value") && this._keys.length !== this._items.length) {
|
||||
// Reconcile keys when `value` is (re)set from outside, reusing existing
|
||||
// keys and minting new ones for added rows. Internal add/remove/reorder
|
||||
// keep `_keys` in sync themselves, so this is skipped in those cases.
|
||||
this._keys = Array.from(
|
||||
{ length: this._items.length },
|
||||
(_, i) => this._keys[i] ?? uid()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -80,7 +100,7 @@ class HaInputMulti extends LitElement {
|
||||
<div class="items">
|
||||
${repeat(
|
||||
this._items,
|
||||
(item, index) => `${item}-${index}`,
|
||||
(_item, index) => this._keys[index],
|
||||
(item, index) => {
|
||||
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
|
||||
return html`
|
||||
@@ -136,7 +156,7 @@ class HaInputMulti extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<div class="layout horizontal">
|
||||
<div class="layout horizontal add-row">
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance="filled"
|
||||
@@ -176,6 +196,7 @@ class HaInputMulti extends LitElement {
|
||||
if (this.max != null && this._items.length >= this.max) {
|
||||
return;
|
||||
}
|
||||
this._keys = [...this._keys, uid()];
|
||||
const items = [...this._items, ""];
|
||||
this._fireChanged(items);
|
||||
await this.updateComplete;
|
||||
@@ -208,11 +229,17 @@ class HaInputMulti extends LitElement {
|
||||
const items = [...this._items];
|
||||
const [moved] = items.splice(oldIndex, 1);
|
||||
items.splice(newIndex, 0, moved);
|
||||
// Move the row's key with it so its DOM (and identity) is preserved.
|
||||
const keys = [...this._keys];
|
||||
const [movedKey] = keys.splice(oldIndex, 1);
|
||||
keys.splice(newIndex, 0, movedKey);
|
||||
this._keys = keys;
|
||||
this._fireChanged(items);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
const index = (ev.target as any).index;
|
||||
this._keys = this._keys.filter((_, i) => i !== index);
|
||||
const items = [...this._items];
|
||||
items.splice(index, 1);
|
||||
this._fireChanged(items);
|
||||
@@ -231,6 +258,9 @@ class HaInputMulti extends LitElement {
|
||||
margin-bottom: 8px;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
.add-row:has(+ ha-input-helper-text) {
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-icon-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ export interface RestoreBackupParams {
|
||||
restore_homeassistant?: boolean;
|
||||
}
|
||||
|
||||
export type CloudBackupHealth = "success" | "failed" | "old" | "none";
|
||||
|
||||
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
|
||||
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
|
||||
|
||||
@@ -314,9 +316,61 @@ export const CORE_LOCAL_AGENT = "backup.local";
|
||||
export const HASSIO_LOCAL_AGENT = "hassio.local";
|
||||
export const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
// How many hours a scheduled automatic backup may be behind before it reads as
|
||||
// overdue, so a few hours of scheduler lag (or a daylight saving shift) doesn't
|
||||
// show a warning.
|
||||
export const BACKUP_OVERDUE_MARGIN_HOURS = 3;
|
||||
|
||||
export const isLocalAgent = (agentId: string) =>
|
||||
[CORE_LOCAL_AGENT, HASSIO_LOCAL_AGENT].includes(agentId);
|
||||
|
||||
export const getLastCloudBackup = (
|
||||
backups?: BackupContent[]
|
||||
): BackupContent | undefined =>
|
||||
backups
|
||||
?.filter((backup) => CLOUD_AGENT in backup.agents)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0];
|
||||
|
||||
export const cloudBackupEnabled = (backupConfig?: BackupConfig): boolean =>
|
||||
!!backupConfig?.automatic_backups_configured &&
|
||||
backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
|
||||
const BACKUP_OVERDUE_MARGIN_MS = BACKUP_OVERDUE_MARGIN_HOURS * 60 * 60 * 1000;
|
||||
|
||||
export const cloudBackupHealth = (
|
||||
backupConfig?: BackupConfig
|
||||
): CloudBackupHealth => {
|
||||
if (!cloudBackupEnabled(backupConfig)) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const completed = backupConfig?.last_completed_automatic_backup
|
||||
? new Date(backupConfig.last_completed_automatic_backup).getTime()
|
||||
: 0;
|
||||
|
||||
const attempted = backupConfig?.last_attempted_automatic_backup
|
||||
? new Date(backupConfig.last_attempted_automatic_backup).getTime()
|
||||
: 0;
|
||||
|
||||
if (!completed && !attempted) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (attempted > completed) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const next =
|
||||
backupConfig?.next_automatic_backup &&
|
||||
new Date(backupConfig.next_automatic_backup).getTime();
|
||||
|
||||
if (next && next < Date.now() - BACKUP_OVERDUE_MARGIN_MS) {
|
||||
return "old";
|
||||
}
|
||||
|
||||
return "success";
|
||||
};
|
||||
|
||||
export const isNetworkMountAgent = (agentId: string) => {
|
||||
const [domain, name] = agentId.split(".");
|
||||
return domain === "hassio" && name !== "local";
|
||||
|
||||
+39
-2
@@ -28,8 +28,13 @@ export interface CloudPreferences {
|
||||
google_report_state: boolean;
|
||||
tts_default_voice: [string, string];
|
||||
cloud_ice_servers_enabled: boolean;
|
||||
onboarded_items: string[];
|
||||
onboarding_postponed_until: string | null;
|
||||
}
|
||||
|
||||
export type RemoteCertificateStatus =
|
||||
"error" | "generating" | "loading" | "loaded" | "ready";
|
||||
|
||||
export interface CloudStatusLoggedIn {
|
||||
logged_in: true;
|
||||
cloud: "disconnected" | "connecting" | "connected";
|
||||
@@ -44,18 +49,36 @@ export interface CloudStatusLoggedIn {
|
||||
remote_domain: string | undefined;
|
||||
remote_connected: boolean;
|
||||
remote_certificate: undefined | CertificateInformation;
|
||||
remote_certificate_status:
|
||||
null | "error" | "generating" | "loaded" | "loading" | "ready";
|
||||
remote_certificate_status: RemoteCertificateStatus | null;
|
||||
http_use_ssl: boolean;
|
||||
active_subscription: boolean;
|
||||
onboarding_postponed: boolean;
|
||||
onboarding_completed: boolean;
|
||||
}
|
||||
|
||||
export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn;
|
||||
|
||||
// Onboarding items the backend tracks. Mirrors ONBOARDING_ITEMS in the cloud
|
||||
// integration; onboarding is complete once every item has been onboarded.
|
||||
export const ONBOARDING_ITEMS = [
|
||||
"remote",
|
||||
"backup",
|
||||
"voice",
|
||||
"streaming",
|
||||
] as const;
|
||||
|
||||
export type CloudOnboardingItem = (typeof ONBOARDING_ITEMS)[number];
|
||||
|
||||
type SubscriptionStatus =
|
||||
"active" | "canceled" | "expired" | "trialing" | "unknown";
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
human_description: string;
|
||||
provider: string;
|
||||
plan_renewal_date?: number;
|
||||
subscription?: {
|
||||
status?: SubscriptionStatus;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CloudWebhook {
|
||||
@@ -159,6 +182,20 @@ export const updateCloudPref = (
|
||||
...prefs,
|
||||
});
|
||||
|
||||
export const postponeCloudOnboarding = (hass: HomeAssistant) =>
|
||||
hass.callWS<CloudStatusLoggedIn>({
|
||||
type: "cloud/onboarding/postpone",
|
||||
});
|
||||
|
||||
export const completeCloudOnboarding = (
|
||||
hass: HomeAssistant,
|
||||
items: CloudOnboardingItem[]
|
||||
) =>
|
||||
hass.callWS<CloudStatusLoggedIn>({
|
||||
type: "cloud/onboarding/complete",
|
||||
items,
|
||||
});
|
||||
|
||||
export const removeCloudData = (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "cloud/remove_data",
|
||||
|
||||
@@ -57,8 +57,8 @@ export const CONFIG_SUB_ROUTES: Record<
|
||||
translationKey: "ui.components.navigation-picker.route.scripts",
|
||||
iconPath: mdiScriptText,
|
||||
},
|
||||
"developer-tools": {
|
||||
translationKey: "ui.components.navigation-picker.route.developer_tools",
|
||||
tools: {
|
||||
translationKey: "ui.components.navigation-picker.route.tools",
|
||||
iconPath: mdiHammer,
|
||||
},
|
||||
integrations: {
|
||||
|
||||
@@ -842,6 +842,8 @@ export const getEnergyDataCollection = (
|
||||
if (err.code === "not_found") {
|
||||
return {
|
||||
prefs: EMPTY_PREFERENCES,
|
||||
start: collection.start,
|
||||
end: collection.end,
|
||||
} as EnergyData;
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface HttpConfig {
|
||||
server_host?: string[];
|
||||
server_port?: number;
|
||||
ssl_certificate?: string;
|
||||
ssl_peer_certificate?: string;
|
||||
ssl_key?: string;
|
||||
cors_allowed_origins?: string[];
|
||||
use_x_forwarded_for?: boolean;
|
||||
trusted_proxies?: string[];
|
||||
use_x_frame_options?: boolean;
|
||||
ip_ban_enabled?: boolean;
|
||||
login_attempts_threshold?: number;
|
||||
ssl_profile?: "modern" | "intermediate";
|
||||
}
|
||||
|
||||
export interface HttpConfigState {
|
||||
stable: HttpConfig;
|
||||
pending: HttpConfig | null;
|
||||
revert_at: string | null;
|
||||
}
|
||||
|
||||
export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
export const fetchHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<HttpConfigState>({ type: "http/config" });
|
||||
|
||||
export const saveHttpConfig = (
|
||||
hass: HomeAssistant,
|
||||
config: HttpConfig | null
|
||||
) =>
|
||||
hass.callWS<SaveHttpConfigResult>({
|
||||
type: "http/config/configure",
|
||||
config,
|
||||
});
|
||||
|
||||
export const promoteHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<undefined>({ type: "http/config/promote" });
|
||||
+16
-6
@@ -27,6 +27,7 @@ export interface LogbookEntry {
|
||||
source?: string; // The trigger source (English phrase, parsed for the cause)
|
||||
domain?: string;
|
||||
state?: string; // The state of the entity
|
||||
attributes?: { event_type?: string }; // Selected attributes the backend surfaces
|
||||
// Context data
|
||||
context_id?: string;
|
||||
context_user_id?: string;
|
||||
@@ -244,13 +245,13 @@ export const parseTriggerSource = (source: string): ParsedTriggerSource => {
|
||||
};
|
||||
|
||||
// Short label shown instead of the bare timestamp for each timestamp-state
|
||||
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value), so a
|
||||
// new timestamp domain won't compile until it gets a label here.
|
||||
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value) and
|
||||
// event (handled separately via its event type), so a new timestamp domain
|
||||
// won't compile until it gets a label here.
|
||||
type LogbookActionMessage =
|
||||
| "pressed"
|
||||
| "activated"
|
||||
| "scanned"
|
||||
| "detected_event_no_type"
|
||||
| "updated"
|
||||
| "sent"
|
||||
| "detected"
|
||||
@@ -261,14 +262,13 @@ type LogbookActionMessage =
|
||||
| "command_sent";
|
||||
|
||||
const STATE_ACTION_MESSAGES: Record<
|
||||
Exclude<TimestampStateDomain, "datetime">,
|
||||
Exclude<TimestampStateDomain, "datetime" | "event">,
|
||||
LogbookActionMessage
|
||||
> = {
|
||||
button: "pressed",
|
||||
input_button: "pressed",
|
||||
scene: "activated",
|
||||
tag: "scanned",
|
||||
event: "detected_event_no_type",
|
||||
image: "updated",
|
||||
notify: "sent",
|
||||
wake_word: "detected",
|
||||
@@ -284,8 +284,18 @@ export const localizeStateMessage = (
|
||||
hass: HomeAssistant,
|
||||
state: string,
|
||||
stateObj: HassEntity,
|
||||
domain: string
|
||||
domain: string,
|
||||
attributes?: LogbookEntry["attributes"]
|
||||
): string => {
|
||||
// Events show the triggered event type, falling back to a generic label when
|
||||
// the type is unknown (the timestamp state is meaningless on its own).
|
||||
if (domain === "event") {
|
||||
const eventType = attributes?.event_type;
|
||||
if (eventType != null) {
|
||||
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
|
||||
}
|
||||
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
|
||||
}
|
||||
const actionKey: LogbookActionMessage | undefined =
|
||||
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
|
||||
if (actionKey) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
"precipitation",
|
||||
"precipitation_intensity",
|
||||
"pressure",
|
||||
"radon",
|
||||
"reactive_energy",
|
||||
"reactive_power",
|
||||
"signal_strength",
|
||||
|
||||
@@ -15,3 +15,7 @@ export const fetchWebhooks = (hass: HomeAssistant): Promise<Webhook[]> =>
|
||||
hass.callWS({
|
||||
type: "webhook/list",
|
||||
});
|
||||
|
||||
export const isActiveCloudWebhook = (hook: Webhook): boolean =>
|
||||
!hook.local_only &&
|
||||
(hook.domain !== "mobile_app" || hook.name !== "Deleted Webhook");
|
||||
|
||||
@@ -272,7 +272,7 @@ export interface ZWaveJSNodeConfigParam {
|
||||
property: number;
|
||||
property_key: number | null;
|
||||
endpoint: number;
|
||||
value: any;
|
||||
value: number | null;
|
||||
configuration_value_type: string;
|
||||
metadata: ZWaveJSNodeConfigParamMetadata;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { formatNumericDuration } from "../../common/datetime/format_duration";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import type { HttpConfig } from "../../data/http";
|
||||
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
|
||||
|
||||
const HTTP_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
@customElement("dialog-http-pending-config")
|
||||
export class DialogHttpPendingConfig
|
||||
extends LitElement
|
||||
implements HassDialog<HttpPendingConfigDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: HttpPendingConfigDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _busy: "confirm" | "revert" | undefined;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _secondsRemaining?: number;
|
||||
|
||||
@state() private _reverted = false;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
public showDialog(params: HttpPendingConfigDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._startCountdown();
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
this._stopCountdown();
|
||||
return true;
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopCountdown();
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._secondsRemaining = undefined;
|
||||
this._stopCountdown();
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _startCountdown(): void {
|
||||
this._stopCountdown();
|
||||
const revertAt = this._params?.state.revert_at;
|
||||
if (!revertAt) {
|
||||
this._secondsRemaining = undefined;
|
||||
return;
|
||||
}
|
||||
const target = new Date(revertAt).getTime();
|
||||
const tick = () => {
|
||||
const remaining = Math.max(0, Math.ceil((target - Date.now()) / 1000));
|
||||
this._secondsRemaining = remaining;
|
||||
if (remaining === 0) {
|
||||
this._stopCountdown();
|
||||
this._reverted = true;
|
||||
}
|
||||
};
|
||||
tick();
|
||||
if (this._secondsRemaining && this._secondsRemaining > 0) {
|
||||
this._interval = window.setInterval(tick, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _stopCountdown(): void {
|
||||
if (this._interval) {
|
||||
window.clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private get _changedFields(): (keyof HttpConfig)[] {
|
||||
if (!this._params?.state.pending) {
|
||||
return [];
|
||||
}
|
||||
const { stable, pending } = this._params.state;
|
||||
return HTTP_FIELDS.filter(
|
||||
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const changes = this._changedFields;
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.headerTitle=${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.title"
|
||||
)}
|
||||
prevent-scrim-close
|
||||
width="medium"
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<span slot="headerNavigationIcon"></span>
|
||||
<div class="content">
|
||||
${
|
||||
this._reverted
|
||||
? html`
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.reverted"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.description"
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
this._secondsRemaining !== undefined
|
||||
? html`
|
||||
<p class="countdown">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.auto_revert",
|
||||
{
|
||||
time:
|
||||
formatNumericDuration(this.hass.locale, {
|
||||
minutes: Math.floor(
|
||||
this._secondsRemaining / 60
|
||||
),
|
||||
seconds: this._secondsRemaining % 60,
|
||||
}) ?? "0",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
}
|
||||
${
|
||||
changes.length
|
||||
? html`
|
||||
<p class="changes-label">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.changes_label"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
${changes.map(
|
||||
(key) => html`
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
${
|
||||
this._reverted
|
||||
? html`
|
||||
<ha-button slot="primaryAction" @click=${this._close}>
|
||||
${this.hass.localize("ui.dialogs.http_pending_config.close")}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
.loading=${this._busy === "revert"}
|
||||
.disabled=${this._busy === "confirm"}
|
||||
@click=${this._revert}
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.http_pending_config.revert")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.loading=${this._busy === "confirm"}
|
||||
.disabled=${this._busy === "revert"}
|
||||
@click=${this._confirm}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.confirm"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
}
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _confirm(): Promise<void> {
|
||||
if (this._busy) {
|
||||
return;
|
||||
}
|
||||
this._busy = "confirm";
|
||||
this._error = undefined;
|
||||
try {
|
||||
await promoteHttpConfig(this.hass);
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
} catch (err: any) {
|
||||
// A confirm fired right as the backend auto-reverts may race the
|
||||
// restart and surface as a connection-lost error. Treat it as resolved;
|
||||
// the disconnected overlay takes over.
|
||||
if (err?.error?.code === ERR_CONNECTION_LOST) {
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
return;
|
||||
}
|
||||
this._error = this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.confirm_error",
|
||||
{ error: err.message ?? "" }
|
||||
);
|
||||
this._busy = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private async _revert(): Promise<void> {
|
||||
if (this._busy || !this._params) {
|
||||
return;
|
||||
}
|
||||
this._busy = "revert";
|
||||
this._error = undefined;
|
||||
try {
|
||||
await saveHttpConfig(this.hass, null);
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
} catch (err: any) {
|
||||
// The restart triggered by clearing pending may cut the WS connection
|
||||
// before we get a reply. The disconnected overlay takes over from here.
|
||||
if (err?.error?.code === ERR_CONNECTION_LOST) {
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
return;
|
||||
}
|
||||
this._error = this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.revert_error",
|
||||
{ error: err.message ?? "" }
|
||||
);
|
||||
this._busy = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyResolved(): void {
|
||||
this._params?.onResolved?.();
|
||||
// The form on Settings > System > Network may be mounted and showing
|
||||
// stale state; let it know to refetch.
|
||||
window.dispatchEvent(new Event("http-config-resolved"));
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
.content {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
}
|
||||
.countdown {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.changes-label {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ul {
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
li {
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-http-pending-config": DialogHttpPendingConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HttpConfigState } from "../../data/http";
|
||||
|
||||
export interface HttpPendingConfigDialogParams {
|
||||
state: HttpConfigState;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
export const loadHttpPendingConfigDialog = () =>
|
||||
import("./dialog-http-pending-config");
|
||||
|
||||
export const showHttpPendingConfigDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: HttpPendingConfigDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-http-pending-config",
|
||||
dialogImport: loadHttpPendingConfigDialog,
|
||||
dialogParams,
|
||||
addHistory: false,
|
||||
});
|
||||
};
|
||||
@@ -5,9 +5,12 @@ import { customElement, state } from "lit/decorators";
|
||||
import { storage } from "../common/decorators/storage";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { fetchHttpConfig } from "../data/http";
|
||||
import type { HttpConfigState } from "../data/http";
|
||||
import type { WindowWithPreloads } from "../data/preloads";
|
||||
import type { RecorderInfo } from "../data/recorder";
|
||||
import { getRecorderInfo } from "../data/recorder";
|
||||
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
|
||||
import "../resources/custom-card-support";
|
||||
import { HassElement } from "../state/hass-element";
|
||||
import QuickBarMixin from "../state/quick-bar-mixin";
|
||||
@@ -28,6 +31,21 @@ const useHash = __DEMO__;
|
||||
const curPath = () =>
|
||||
useHash ? location.hash.substring(1) : location.pathname;
|
||||
|
||||
// Developer tools was renamed to Tools (/config/tools) in 2026.8; it had moved
|
||||
// from /developer-tools to /config in 2026.2. Redirect both old locations to
|
||||
// the new one. Applied on the initial route and on every navigation so
|
||||
// bookmarks and external links to the old URLs resolve too, not just in-app
|
||||
// navigation.
|
||||
const redirectLegacyToolsPath = (path: string): string => {
|
||||
if (path.startsWith("/config/developer-tools")) {
|
||||
return path.replace("/config/developer-tools", "/config/tools");
|
||||
}
|
||||
if (path.startsWith("/developer-tools")) {
|
||||
return path.replace("/developer-tools", "/config/tools");
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const panelUrl = (path: string) => {
|
||||
const dividerPos = path.indexOf("/", 1);
|
||||
return dividerPos === -1 ? path.substring(1) : path.substring(1, dividerPos);
|
||||
@@ -39,6 +57,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
@state() private _databaseMigration?: boolean;
|
||||
|
||||
private _httpPendingDialogOpen = false;
|
||||
|
||||
private _panelUrl: string;
|
||||
|
||||
@storage({ key: "ha-version", state: false, subscribe: false })
|
||||
@@ -50,7 +70,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const path = curPath();
|
||||
const path = redirectLegacyToolsPath(curPath());
|
||||
|
||||
this._route = {
|
||||
prefix: "",
|
||||
@@ -70,14 +90,24 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (
|
||||
this._databaseMigration === undefined &&
|
||||
changedProps.has("hass") &&
|
||||
this.hass?.config &&
|
||||
changedProps.get("hass")?.config !== this.hass?.config
|
||||
oldHass?.config !== this.hass.config
|
||||
) {
|
||||
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
|
||||
) {
|
||||
this.checkHttpPendingConfig();
|
||||
}
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
@@ -106,10 +136,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
// Navigation
|
||||
const updateRoute = (path = curPath()) => {
|
||||
// Developer tools panel was moved to config in 2026.2
|
||||
if (path.startsWith("/developer-tools")) {
|
||||
path = path.replace("/developer-tools", "/config/developer-tools");
|
||||
}
|
||||
path = redirectLegacyToolsPath(path);
|
||||
if (this._route && path === this._route.path) {
|
||||
return;
|
||||
}
|
||||
@@ -208,6 +235,32 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected async checkHttpPendingConfig() {
|
||||
if (__DEMO__ || this._httpPendingDialogOpen) {
|
||||
return;
|
||||
}
|
||||
if (!this.hass?.user?.is_admin) {
|
||||
return;
|
||||
}
|
||||
let httpConfig: HttpConfigState;
|
||||
try {
|
||||
httpConfig = await fetchHttpConfig(this.hass);
|
||||
} catch (_err) {
|
||||
// The check re-runs on the next reconnect; ignore transient failures.
|
||||
return;
|
||||
}
|
||||
if (!httpConfig.pending || this._httpPendingDialogOpen) {
|
||||
return;
|
||||
}
|
||||
this._httpPendingDialogOpen = true;
|
||||
showHttpPendingConfigDialog(this, {
|
||||
state: httpConfig,
|
||||
onResolved: () => {
|
||||
this._httpPendingDialogOpen = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async checkDataBaseMigration() {
|
||||
if (__DEMO__) {
|
||||
this._databaseMigration = false;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
@@ -23,8 +24,14 @@ import type {
|
||||
StoreAddon,
|
||||
SupervisorStore,
|
||||
} from "../../../data/supervisor/store";
|
||||
import { fetchSupervisorStore } from "../../../data/supervisor/store";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
addStoreRepository,
|
||||
fetchSupervisorStore,
|
||||
} from "../../../data/supervisor/store";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-error-screen";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
@@ -82,7 +89,15 @@ export class HaConfigAppsAvailable extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadData();
|
||||
const repositoryUrl = extractSearchParam("repository_url");
|
||||
if (repositoryUrl) {
|
||||
navigate("/config/apps/available", { replace: true });
|
||||
}
|
||||
this._loadData().then(() => {
|
||||
if (repositoryUrl) {
|
||||
this._addRepository(repositoryUrl);
|
||||
}
|
||||
});
|
||||
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
|
||||
}
|
||||
|
||||
@@ -228,6 +243,40 @@ export class HaConfigAppsAvailable extends LitElement {
|
||||
navigate("/config/apps/registries");
|
||||
}
|
||||
|
||||
private async _addRepository(repositoryUrl: string): Promise<void> {
|
||||
if (
|
||||
!this._store ||
|
||||
this._store.repositories.some((repo) => repo.source === repositoryUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.apps.my.add_repository_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.apps.my.add_repository_store_description",
|
||||
{ repository: repositoryUrl }
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.add"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addStoreRepository(this.hass, repositoryUrl);
|
||||
await this._loadData();
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadData(): Promise<void> {
|
||||
try {
|
||||
const [addon, store] = await Promise.all([
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/data-table/ha-data-table";
|
||||
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/ha-button";
|
||||
@@ -56,12 +55,7 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
@state() private _error?: string;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._loadData().then(() => {
|
||||
const repositoryUrl = extractSearchParam("repository_url");
|
||||
if (repositoryUrl) {
|
||||
this._addRepository(repositoryUrl);
|
||||
}
|
||||
});
|
||||
this._loadData();
|
||||
}
|
||||
|
||||
private _columns = memoizeOne(
|
||||
@@ -224,18 +218,6 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async _addRepository(url: string) {
|
||||
try {
|
||||
await addStoreRepository(this.hass, url);
|
||||
await this._loadData();
|
||||
fireEvent(this, "apps-collection-refresh", { collection: "store" });
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _removeRepository = async (ev: Event) => {
|
||||
const slug = (ev.currentTarget as any).slug;
|
||||
const repo = this._repositories?.find((r) => r.slug === slug);
|
||||
|
||||
@@ -195,7 +195,7 @@ export class HaPlatformCondition extends LitElement {
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/conditions/${this.condition.condition}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
|
||||
@@ -688,8 +688,17 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
...baseConfig,
|
||||
...(initData ? normalizeAutomationConfig(initData) : initData),
|
||||
} as AutomationConfig;
|
||||
this._initDirtyTracking({ type: "deep" }, baseConfig as AutomationConfig);
|
||||
this._updateDirtyState(this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: baseConfig as AutomationConfig,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.currentEntityId = undefined;
|
||||
this.readOnly = false;
|
||||
}
|
||||
@@ -697,7 +706,13 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
|
||||
this.config = normalizeAutomationConfig(c.config);
|
||||
this._initDirtyTracking({ type: "deep" }, this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._checkValidation();
|
||||
});
|
||||
this.currentEntityId = this.entityId;
|
||||
@@ -763,7 +778,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
@@ -844,7 +862,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
id: this.config?.id,
|
||||
...normalizeAutomationConfig(ev.detail.value),
|
||||
};
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
@@ -860,7 +881,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
|
||||
const id = this.automationId || String(Date.now());
|
||||
@@ -974,7 +998,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve(true);
|
||||
},
|
||||
@@ -991,7 +1018,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
config: this.config!,
|
||||
updateConfig: (config) => {
|
||||
this.config = config;
|
||||
this._updateDirtyState(config);
|
||||
this._updateDirtyState({
|
||||
config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve();
|
||||
},
|
||||
@@ -1011,7 +1041,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
|
||||
const id = this.automationId || String(Date.now());
|
||||
if (!this.automationId) {
|
||||
if (!this.automationId && !this.config?.alias) {
|
||||
const saved = await this._promptAutomationAlias();
|
||||
if (!saved) {
|
||||
return;
|
||||
@@ -1142,7 +1172,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
private _applyUndoRedo(config: AutomationConfig) {
|
||||
this._manualEditor?.triggerCloseSidebar();
|
||||
this.config = config;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private _undo() {
|
||||
|
||||
@@ -85,12 +85,17 @@ export interface EditorDomainHooks<TConfig> {
|
||||
domain: "automation" | "script";
|
||||
}
|
||||
|
||||
interface AutomationEditorConfig<TConfig> {
|
||||
config: TConfig;
|
||||
entityRegistryUpdate?: EntityRegistryUpdate;
|
||||
}
|
||||
|
||||
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
superClass: Constructor<LitElement>
|
||||
) => {
|
||||
class AutomationScriptEditorClass extends DirtyStateProviderMixin<TConfig>()(
|
||||
superClass
|
||||
) {
|
||||
class AutomationScriptEditorClass extends DirtyStateProviderMixin<
|
||||
AutomationEditorConfig<TConfig>
|
||||
>()(superClass) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
@@ -220,8 +225,17 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
protected takeControlSave() {
|
||||
this.readOnly = false;
|
||||
// Force dirty: set baseline to null so current config always differs
|
||||
this._initDirtyTracking({ type: "deep" }, null as unknown as TConfig);
|
||||
this._updateDirtyState(this.config!);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: null as unknown as TConfig,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.blueprintConfig = undefined;
|
||||
}
|
||||
|
||||
@@ -266,7 +280,13 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
// looks dirty. Surface an alert offering to save when deprecated
|
||||
// options were migrated.
|
||||
this.deprecatedConfigMigrated = report.deprecated;
|
||||
this._initDirtyTracking({ type: "deep" }, this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
hooks.checkValidation();
|
||||
} catch (err: any) {
|
||||
if (err.status_code !== 404) {
|
||||
|
||||
@@ -190,7 +190,7 @@ export class HaPlatformTrigger extends LitElement {
|
||||
this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/triggers/${this.trigger.trigger}`
|
||||
)
|
||||
: this._manifest.documentation
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import "../../../../../components/item/ha-list-item-base";
|
||||
import "../../../../../components/list/ha-list-base";
|
||||
import type { BackupConfig, BackupContent } from "../../../../../data/backup";
|
||||
import {
|
||||
BACKUP_OVERDUE_MARGIN_HOURS,
|
||||
BackupScheduleRecurrence,
|
||||
getFormattedBackupTime,
|
||||
} from "../../../../../data/backup";
|
||||
@@ -26,8 +27,6 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
import { showAlertDialog } from "../../../../lovelace/custom-card-helpers";
|
||||
import "../ha-backup-summary-card";
|
||||
|
||||
const OVERDUE_MARGIN_HOURS = 3;
|
||||
|
||||
@customElement("ha-backup-overview-summary")
|
||||
class HaBackupOverviewBackups extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -74,26 +73,33 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
</ha-list-item-base>
|
||||
${
|
||||
description || description === null
|
||||
? html`<ha-list-item-base>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span
|
||||
slot="headline"
|
||||
class=${description === null ? "skeleton" : ""}
|
||||
>${description}</span
|
||||
>
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiCalendar}
|
||||
></ha-svg-icon>
|
||||
<span
|
||||
slot="headline"
|
||||
class=${description === null ? "skeleton" : ""}
|
||||
>${description}</span
|
||||
>
|
||||
|
||||
${
|
||||
lastCompletedDate
|
||||
? html` <ha-icon-button
|
||||
slot="end"
|
||||
@click=${this._createAdditionalBackupDescription(
|
||||
lastCompletedDate
|
||||
)}
|
||||
.path=${mdiInformationOutline}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-base>`
|
||||
${
|
||||
lastCompletedDate
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
@click=${this._createAdditionalBackupDescription(
|
||||
lastCompletedDate
|
||||
)}
|
||||
.path=${mdiInformationOutline}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-base>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
@@ -293,7 +299,7 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
|
||||
const numberOfDays = differenceInDays(
|
||||
// Subtract a few hours to avoid showing as overdue if it's just a few hours (e.g. daylight saving)
|
||||
addHours(now, -OVERDUE_MARGIN_HOURS),
|
||||
addHours(now, -BACKUP_OVERDUE_MARGIN_HOURS),
|
||||
lastBackupDate
|
||||
);
|
||||
|
||||
|
||||
@@ -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")}
|
||||
@@ -229,24 +233,27 @@ class HaConfigBackupOverview extends LitElement {
|
||||
<div class="content">
|
||||
${
|
||||
this.info && Object.keys(this.info.agent_errors).length
|
||||
? html`${Object.entries(this.info.agent_errors).map(
|
||||
([agentId, error]) =>
|
||||
html`<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.agent_error",
|
||||
{
|
||||
name: computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
this.agents
|
||||
),
|
||||
}
|
||||
)}
|
||||
>
|
||||
${error}
|
||||
</ha-alert>`
|
||||
)}`
|
||||
? html`
|
||||
${Object.entries(this.info.agent_errors).map(
|
||||
([agentId, error]) => html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.agent_error",
|
||||
{
|
||||
name: computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agentId,
|
||||
this.agents
|
||||
),
|
||||
}
|
||||
)}
|
||||
>
|
||||
${error}
|
||||
</ha-alert>
|
||||
`
|
||||
)}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
mdiBackupRestore,
|
||||
mdiCheckCircle,
|
||||
mdiEarth,
|
||||
mdiMicrophoneMessage,
|
||||
mdiVideo,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { BackupConfig } from "../../../../data/backup";
|
||||
import type {
|
||||
CloudOnboardingItem,
|
||||
CloudStatusLoggedIn,
|
||||
} from "../../../../data/cloud";
|
||||
import { postponeCloudOnboarding } from "../../../../data/cloud";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { onboardingPanelCompleted } from "./cloud-account-status";
|
||||
|
||||
@customElement("cloud-account-onboarding")
|
||||
export class CloudAccountOnboarding extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public cloudStatus!: CloudStatusLoggedIn;
|
||||
|
||||
@property({ attribute: false }) public backupConfig?: BackupConfig;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card outlined class="onboarding-card">
|
||||
<div class="card-content ready-card">
|
||||
<div class="ready-left">
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.ready_title"
|
||||
)}
|
||||
</h2>
|
||||
<p class="muted">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.ready_text"
|
||||
)}
|
||||
</p>
|
||||
<div class="ready-actions">
|
||||
<ha-button appearance="filled" @click=${this._startSetup}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.start_setup"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._onboardingPostpone}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.review_later"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ready-grid">
|
||||
${this._readyChip(
|
||||
mdiEarth,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.chip_remote"
|
||||
),
|
||||
"remote"
|
||||
)}
|
||||
${this._readyChip(
|
||||
mdiBackupRestore,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.chip_backups"
|
||||
),
|
||||
"backup"
|
||||
)}
|
||||
${this._readyChip(
|
||||
mdiMicrophoneMessage,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.chip_voice"
|
||||
),
|
||||
"voice"
|
||||
)}
|
||||
${this._readyChip(
|
||||
mdiVideo,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.chip_streaming"
|
||||
),
|
||||
"streaming"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _readyChip(
|
||||
icon: string,
|
||||
label: string,
|
||||
key: CloudOnboardingItem
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="ready-chip ${key}">
|
||||
<div class="ready-chip-icon">
|
||||
<ha-svg-icon .path=${icon}></ha-svg-icon>
|
||||
${
|
||||
this._panelCompleted(key)
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class="chip-badge on"
|
||||
.path=${mdiCheckCircle}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span>${label}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _panelCompleted(key: CloudOnboardingItem): boolean {
|
||||
return onboardingPanelCompleted(key, this.cloudStatus, this.backupConfig);
|
||||
}
|
||||
|
||||
// Delegate opening to the parent (cloud-account), which stays mounted while
|
||||
// the dialog is open — so completing the last step can't tear the dialog down
|
||||
// and the dialog can refresh the page through a stable element.
|
||||
private _startSetup() {
|
||||
fireEvent(this, "cloud-open-onboarding");
|
||||
}
|
||||
|
||||
private async _onboardingPostpone() {
|
||||
try {
|
||||
await postponeCloudOnboarding(this.hass);
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
} catch (err: any) {
|
||||
showToast(this, { message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-card.onboarding-card {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.ready-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6);
|
||||
}
|
||||
.ready-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.ready-left h2 {
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.ready-left p {
|
||||
margin: 0;
|
||||
}
|
||||
.ready-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.ready-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
@container (max-width: 450px) {
|
||||
.ready-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.ready-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
.ready-chip-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-badge {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
--mdc-icon-size: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
.chip-badge.on {
|
||||
color: var(--success-color);
|
||||
}
|
||||
.ready-chip.remote .ready-chip-icon {
|
||||
color: var(--blue-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--blue-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.ready-chip.backup .ready-chip-icon {
|
||||
color: var(--green-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--green-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.ready-chip.voice .ready-chip-icon {
|
||||
color: var(--purple-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--purple-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.ready-chip.streaming .ready-chip-icon {
|
||||
color: var(--cyan-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--cyan-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.muted {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
p.muted {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-account-onboarding": CloudAccountOnboarding;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"cloud-open-onboarding": undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
import {
|
||||
mdiBackupRestore,
|
||||
mdiCellphone,
|
||||
mdiEarth,
|
||||
mdiEye,
|
||||
mdiEyeOff,
|
||||
mdiMicrophone,
|
||||
mdiMicrophoneMessage,
|
||||
mdiVideo,
|
||||
mdiWebhook,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { formatDate } from "../../../../common/datetime/format_date";
|
||||
import { relativeTime } from "../../../../common/datetime/relative_time";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { BackupConfig } from "../../../../data/backup";
|
||||
import { cloudBackupHealth, cloudBackupEnabled } from "../../../../data/backup";
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../../data/cloud";
|
||||
import type { Webhook } from "../../../../data/webhook";
|
||||
import { isActiveCloudWebhook } from "../../../../data/webhook";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
@customElement("cloud-account-overview")
|
||||
export class CloudAccountOverview extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public cloudStatus!: CloudStatusLoggedIn;
|
||||
|
||||
@property({ attribute: false }) public subscription?: SubscriptionInfo;
|
||||
|
||||
@property({ attribute: false }) public backupConfig?: BackupConfig;
|
||||
|
||||
@property({ attribute: false }) public webhooks?: Webhook[];
|
||||
|
||||
@state() private _emailRevealed = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${this._renderTopCard()} ${this._renderFeaturesCard()}
|
||||
${this._renderAccountCard()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTopCard(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content">
|
||||
<div
|
||||
class="thank-you-header"
|
||||
role="heading"
|
||||
aria-level="1"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.thank_you_title"
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="163"
|
||||
height="30"
|
||||
viewBox="0 0 163 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M156.478 16.0647C156.254 16.0686 156.052 15.9841 155.873 15.8112C155.71 15.622 155.624 15.2795 155.615 14.7836C155.609 14.4316 155.664 13.8786 155.778 13.1245C155.893 12.3703 156.054 11.5034 156.261 10.5236C156.484 9.52761 156.738 8.49102 157.023 7.41388C157.308 6.32074 157.617 5.25118 157.951 4.20519C158.285 3.14321 158.628 2.18507 158.981 1.33078C159.15 0.911768 159.416 0.595068 159.781 0.380677C160.161 0.15001 160.575 0.0307665 161.023 0.0229478C161.487 0.0148499 161.826 0.224953 162.042 0.653257C162.257 1.08156 162.369 1.55167 162.378 2.0636C162.382 2.25557 162.292 2.61719 162.109 3.14846C161.943 3.67945 161.714 4.32354 161.423 5.08073C161.132 5.83792 160.81 6.65966 160.458 7.54595C160.121 8.41596 159.776 9.30211 159.424 10.2044C159.087 11.0904 158.774 11.936 158.484 12.7412C158.194 13.5464 157.959 14.2546 157.777 14.8658C157.657 15.3 157.486 15.607 157.265 15.7869C157.06 15.9665 156.798 16.0591 156.478 16.0647ZM155.347 22.7815C155.155 22.7848 154.93 22.7407 154.673 22.6492C154.415 22.5577 154.187 22.3296 153.988 21.9651C153.806 21.6162 153.708 21.0498 153.695 20.2659C153.684 19.658 153.868 19.1907 154.246 18.8641C154.625 18.5374 155.134 18.3685 155.774 18.3573C156.318 18.3479 156.736 18.4926 157.03 18.7915C157.339 19.0901 157.498 19.5194 157.508 20.0793C157.517 20.5913 157.397 21.0494 157.148 21.4538C156.915 21.858 156.625 22.1751 156.276 22.4052C155.945 22.651 155.635 22.7764 155.347 22.7815Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M148.022 22.7654C147.654 22.7718 147.315 22.6257 147.006 22.327C146.713 22.0441 146.56 21.5587 146.548 20.8708C146.539 20.3589 146.567 19.6463 146.631 18.733C146.711 17.8035 146.866 16.6086 147.097 15.1484C146.557 16.31 145.991 17.344 145.398 18.2505C144.806 19.1569 144.218 19.8713 143.636 20.3936C143.068 20.8995 142.521 21.1571 141.993 21.1664C141.145 21.1812 140.469 20.9369 139.964 20.4337C139.459 19.9144 139.193 18.9349 139.168 17.4951C139.156 16.8072 139.199 16.0303 139.296 15.1645C139.408 14.2984 139.553 13.4317 139.73 12.5645C139.907 11.6973 140.086 10.9261 140.266 10.2508C140.462 9.5593 140.637 9.04417 140.791 8.70543C140.914 8.41524 141.07 8.18848 141.259 8.02515C141.464 7.86155 141.727 7.77695 142.047 7.77137C142.559 7.76243 142.954 7.92356 143.232 8.25477C143.509 8.58597 143.651 8.92754 143.658 9.27949C143.662 9.55145 143.605 9.9445 143.486 10.4587C143.383 10.9725 143.249 11.543 143.084 12.1699C142.935 12.7966 142.778 13.4395 142.613 14.0984C142.449 14.7574 142.308 15.376 142.19 15.9541C142.088 16.532 142.04 17.0209 142.047 17.4208C142.056 17.9168 142.252 18.1614 142.636 18.1547C142.86 18.1508 143.144 17.9298 143.489 17.4917C143.849 17.0533 144.223 16.4707 144.611 15.7438C145.014 15.0007 145.416 14.1936 145.816 13.3224C146.233 12.435 146.609 11.5403 146.946 10.6383C147.298 9.72002 147.595 8.87471 147.838 8.10235C147.989 7.61964 148.199 7.27992 148.468 7.0832C148.737 6.88648 149.047 6.78505 149.399 6.77891C149.943 6.76941 150.361 6.91413 150.655 7.21306C150.964 7.51171 151.123 7.94099 151.133 8.5009C151.138 8.75686 151.105 9.1895 151.036 9.7988C150.966 10.4081 150.875 11.1218 150.761 11.9399C150.663 12.7577 150.542 13.624 150.398 14.5386C150.254 15.4533 150.11 16.352 149.965 17.2346C149.837 18.117 149.707 18.9274 149.576 19.6658C149.444 20.3882 149.334 20.9662 149.246 21.3998C149.097 22.0425 148.912 22.4298 148.69 22.5617C148.468 22.6936 148.246 22.7615 148.022 22.7654Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M128.996 22.8094C127.86 22.8292 126.942 22.4452 126.24 21.6573C125.538 20.8534 125.175 19.7716 125.151 18.4118C125.143 17.9799 125.192 17.539 125.296 17.0891C125.416 16.6389 125.561 16.2203 125.73 15.8333C125.632 15.691 125.548 15.5084 125.48 15.2856C125.428 15.0465 125.399 14.7669 125.394 14.447C125.376 13.4071 125.543 12.4521 125.896 11.5818C126.248 10.6955 126.731 9.92695 127.344 9.27616C127.957 8.62537 128.636 8.11744 129.382 7.75236C130.127 7.38729 130.884 7.19806 131.652 7.18465C132.964 7.16175 134.043 7.58298 134.891 8.44833C135.754 9.31339 136.202 10.6738 136.234 12.5295C136.25 13.4574 136.123 14.4037 135.851 15.3686C135.58 16.3335 135.204 17.2602 134.724 18.1487C134.259 19.021 133.713 19.8066 133.085 20.5057C132.473 21.2045 131.81 21.7601 131.098 22.1726C130.401 22.5848 129.7 22.7971 128.996 22.8094ZM128.471 14.7053C128.554 14.8799 128.597 15.0872 128.602 15.3271C128.606 15.5671 128.562 15.8159 128.471 16.0735C128.379 16.3312 128.289 16.6768 128.201 17.1104C128.112 17.528 128.074 18.1288 128.088 18.9126C128.092 19.1206 128.119 19.2962 128.169 19.4393C128.236 19.5662 128.373 19.6278 128.581 19.6242C129.125 19.6147 129.633 19.4297 130.107 19.0694C130.597 18.7088 131.029 18.2372 131.403 17.6546C131.793 17.0717 132.117 16.4259 132.377 15.7173C132.652 14.9924 132.856 14.2607 132.987 13.5223C133.134 12.7676 133.202 12.0704 133.19 11.4305C133.183 11.0145 133.098 10.728 132.935 10.5708C132.773 10.4136 132.491 10.3385 132.091 10.3455C131.547 10.355 130.99 10.5407 130.421 10.9027C129.867 11.2484 129.403 11.7206 129.029 12.3192C128.655 12.9018 128.475 13.5611 128.488 14.297C128.49 14.4409 128.485 14.5771 128.471 14.7053Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M113.297 28.8921C112.401 28.9078 111.584 28.866 110.846 28.7669C110.109 28.6837 109.386 28.5363 108.678 28.3247C107.971 28.113 107.206 27.8303 106.383 27.4766C106.045 27.3225 105.762 27.1594 105.535 26.9873C105.308 26.8153 105.191 26.5533 105.185 26.2013C105.176 25.6894 105.329 25.2947 105.644 25.0171C105.944 24.7558 106.365 24.6205 106.909 24.611C107.197 24.6059 107.567 24.6955 108.018 24.8797C108.47 25.0798 108.986 25.3028 109.566 25.5487C110.163 25.8104 110.823 26.0309 111.546 26.2103C112.269 26.4057 113.047 26.4961 113.879 26.4816C114.727 26.4668 115.513 26.101 116.236 25.3843C116.96 24.6676 117.534 23.6654 117.96 22.3778C118.402 21.1059 118.608 19.606 118.578 17.8783C118.571 17.4784 118.555 17.0466 118.531 16.5829C118.507 16.1033 118.482 15.5996 118.457 15.072C117.9 16.1699 117.294 17.1966 116.638 18.1522C115.983 19.1078 115.3 19.8878 114.591 20.4923C113.897 21.0805 113.19 21.3809 112.47 21.3934C111.59 21.4088 110.947 21.212 110.539 20.803C110.148 20.3778 109.944 19.7013 109.928 18.7734C109.914 17.9895 109.955 17.1327 110.051 16.2029C110.163 15.2568 110.314 14.302 110.505 13.3385C110.696 12.375 110.92 11.459 111.177 10.5903C111.45 9.70545 111.741 8.94026 112.049 8.29478C112.281 7.82666 112.645 7.58827 113.141 7.57962C113.461 7.57403 113.784 7.72042 114.109 8.01879C114.45 8.31688 114.625 8.72189 114.634 9.23381C114.639 9.52176 114.574 9.93896 114.44 10.4854C114.305 11.0318 114.14 11.6508 113.944 12.3423C113.764 13.0176 113.577 13.733 113.382 14.4885C113.187 15.228 113.023 15.9589 112.892 16.6814C112.76 17.3878 112.699 18.0289 112.709 18.6048C112.711 18.6688 112.759 18.7 112.855 18.6983C113.047 18.6949 113.3 18.5305 113.615 18.205C113.945 17.8632 114.305 17.4248 114.696 16.8899C115.102 16.3387 115.508 15.7396 115.913 15.0924C116.333 14.429 116.738 13.7738 117.126 13.1269C117.515 12.48 117.865 11.8898 118.176 11.3563C118.123 11.1012 118.087 10.8778 118.068 10.6861C118.064 10.4781 118.061 10.3022 118.059 10.1582C118.047 9.50228 118.15 8.95641 118.366 8.52057C118.599 8.08444 119.059 7.86038 119.747 7.84837C120.195 7.84055 120.542 8.00252 120.787 8.33428C121.033 8.65005 121.161 9.10388 121.171 9.69579C121.176 9.95175 121.141 10.2484 121.067 10.5858C120.992 10.9071 120.841 11.4218 120.614 12.1299L120.865 11.3814C121.034 12.8187 121.176 14.0964 121.292 15.2145C121.423 16.3324 121.498 17.4433 121.518 18.5471C121.554 20.6268 121.234 22.4326 120.556 23.9647C119.895 25.5125 118.94 26.7133 117.691 27.5673C116.457 28.4209 114.993 28.8625 113.297 28.8921Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M91.1422 24.4303C90.0544 24.4493 89.0425 24.2349 88.1065 23.7872C87.1709 23.3554 86.3127 22.7783 85.5319 22.0558C84.7509 21.3174 84.0488 20.5135 83.4255 19.6442C82.818 18.7587 82.2987 17.8876 81.8677 17.031C81.6911 17.9142 81.5282 18.6692 81.3791 19.2959C81.2461 19.9223 81.1351 20.4443 81.0464 20.8619C80.972 21.1833 80.8895 21.4968 80.7988 21.8024C80.7241 22.1078 80.6005 22.3579 80.4278 22.553C80.2552 22.748 80.0089 22.8483 79.689 22.8539C79.0011 22.8659 78.5571 22.6417 78.357 22.1811C78.1567 21.7045 78.0501 21.0983 78.0372 20.3624C78.0258 19.7065 78.1147 18.8408 78.304 17.7653C78.5092 16.6896 78.7842 15.4846 79.129 14.1504C79.4738 12.8162 79.8738 11.441 80.3292 10.0248C80.8002 8.59236 81.304 7.19936 81.8405 5.84579C82.3767 4.47622 82.9151 3.23464 83.4557 2.12103C83.7959 1.44299 84.1005 1.01361 84.3694 0.832891C84.638 0.636173 84.9562 0.534603 85.3242 0.52818C85.6921 0.521758 85.9738 0.612857 86.1691 0.801477C86.3644 0.990096 86.4649 1.24438 86.4705 1.56433C86.4741 1.7723 86.4297 1.98111 86.3374 2.19075C86.245 2.40039 86.1455 2.65817 86.0388 2.96408C85.6851 3.78638 85.3178 4.74493 84.9368 5.83975C84.5556 6.91857 84.1911 8.0371 83.8432 9.19535C83.4951 10.3376 83.186 11.4232 82.9159 12.452C83.6917 11.9744 84.5558 11.5113 85.5081 11.0626C86.4601 10.5979 87.421 10.1811 88.3907 9.81208C89.3761 9.42682 90.2989 9.12267 91.1592 8.89962C92.0194 8.67657 92.7455 8.55988 93.3374 8.54955C93.8013 8.54146 94.1557 8.67129 94.4004 8.93906C94.6451 9.20683 94.7702 9.50069 94.7758 9.82064C94.78 10.0606 94.7122 10.3018 94.5724 10.5443C94.4326 10.7868 94.1323 11.0001 93.6714 11.1841C92.1446 11.7229 90.6259 12.2694 89.1154 12.8239C87.6208 13.3781 85.96 14.0311 84.1328 14.7831C84.5299 15.5283 84.9913 16.2964 85.5172 17.0873C86.0428 17.8623 86.6076 18.5885 87.2115 19.2661C87.8151 19.9277 88.4327 20.469 89.0641 20.89C89.6953 21.2951 90.3148 21.4923 90.9227 21.4817C91.8186 21.466 92.6794 21.275 93.5051 20.9085C94.3306 20.526 95.0908 20.0727 95.7857 19.5485C96.4804 19.0083 97.0874 18.4856 97.6067 17.9805C98.1419 17.4751 98.5591 17.0837 98.8584 16.8065C99.0313 16.6274 99.197 16.4965 99.3556 16.4137C99.5302 16.3307 99.7454 16.2869 100.001 16.2824C100.305 16.2771 100.539 16.3691 100.702 16.5582C100.881 16.7311 100.965 16.9617 100.954 17.2499C100.943 17.5222 100.812 17.7965 100.561 18.0729C100.357 18.3005 100.035 18.6662 99.5957 19.1699C99.1722 19.6574 98.6536 20.2025 98.04 20.8053C97.4422 21.3919 96.772 21.9637 96.0296 22.5207C95.303 23.0615 94.5266 23.5071 93.7006 23.8575C92.8749 24.224 92.0221 24.4149 91.1422 24.4303Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M60.3456 23.2154C59.9616 23.2221 59.5754 23.1008 59.187 22.8516C58.8143 22.586 58.5037 22.2074 58.2551 21.7156C58.0062 21.2079 57.8754 20.5941 57.8629 19.8742C57.8461 18.9144 57.9562 17.8883 58.1932 16.796C58.4462 15.7034 58.7551 14.6098 59.1201 13.5153C59.485 12.4208 59.8431 11.3904 60.1943 10.4241C60.3622 9.95708 60.5727 9.64135 60.8259 9.47691C61.0787 9.29647 61.4132 9.20262 61.8291 9.19536C62.773 9.17888 63.2531 9.64258 63.2696 10.5864C63.2724 10.7464 63.2058 11.0596 63.07 11.5261C62.9338 11.9765 62.7515 12.5318 62.523 13.1919C62.3104 13.8517 62.0829 14.5677 61.8403 15.3401C61.6135 16.0962 61.4029 16.868 61.2087 17.6555C61.0301 18.4267 60.8908 19.1573 60.7908 19.8471C61.149 19.2808 61.5448 18.5778 61.9782 17.7381C62.4273 16.8821 62.8998 15.9857 63.3955 15.0489C63.9072 14.1118 64.4201 13.2388 64.934 12.4297C65.4477 11.6046 65.956 10.9316 66.459 10.4107C66.962 9.88988 67.4455 9.62541 67.9094 9.61731C68.3093 9.61033 68.6867 9.68375 69.0414 9.83759C69.4119 9.97514 69.7368 10.2575 70.0163 10.6847C70.2956 11.0959 70.514 11.6922 70.6717 12.4735C70.8453 13.2546 70.9433 14.2851 70.9657 15.5649C70.9768 16.2048 71.0437 16.8277 71.1663 17.4337C71.3046 18.0233 71.5212 18.5156 71.8162 18.9105C72.1108 19.2895 72.5221 19.4743 73.05 19.4651C73.9619 19.4492 74.7748 19.267 75.4889 18.9184C76.2029 18.5699 76.828 18.167 77.3641 17.7095C77.9002 17.2521 78.3492 16.8522 78.7113 16.5098C78.9314 16.282 79.1203 16.1026 79.278 15.9719C79.4518 15.8408 79.5946 15.7743 79.7066 15.7724C80.0426 15.7665 80.2841 15.8503 80.4311 16.0238C80.5942 16.1969 80.6783 16.4355 80.6836 16.7395C80.6864 16.8994 80.5556 17.1978 80.2912 17.6344C80.0268 18.0711 79.6515 18.5737 79.1653 19.1423C78.6949 19.6946 78.1522 20.2322 77.5372 20.755C76.9223 21.2778 76.2578 21.7135 75.5437 22.062C74.8297 22.4105 74.0968 22.5913 73.3449 22.6044C72.0811 22.6265 71.0293 22.4208 70.1896 21.9874C69.3657 21.5377 68.7443 20.7804 68.3257 19.7156C67.9068 18.6347 67.681 17.1584 67.6483 15.2867C67.6475 15.2387 67.6461 15.1587 67.6441 15.0468C67.6355 14.5508 67.6198 14.111 67.5971 13.7274C67.5904 13.3434 67.5609 13.0319 67.5087 12.7928C67.2454 13.2935 66.9278 13.8911 66.5559 14.5857C66.2 15.28 65.8129 16.0229 65.3946 16.8143C64.9761 17.5897 64.5414 18.3574 64.0906 19.1174C63.6395 19.8614 63.1873 20.5414 62.734 21.1574C62.2967 21.7731 61.8733 22.2686 61.4638 22.6438C61.0542 23.019 60.6815 23.2095 60.3456 23.2154Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M41.481 24.457C40.9691 24.4659 40.5027 24.33 40.0817 24.0494C39.661 23.7847 39.3268 23.4304 39.079 22.9867C38.831 22.5269 38.7023 22.0331 38.6931 21.5052C38.6758 20.5134 38.8749 19.5417 39.2903 18.5903C39.7218 17.6387 40.29 16.7326 40.9951 15.8722C41.7002 15.0117 42.4706 14.2222 43.3062 13.5035C44.1575 12.7685 45.0026 12.1377 45.8415 11.6109C46.6964 11.0839 47.4733 10.6703 48.1722 10.3701C48.8871 10.0695 49.4445 9.91579 49.8444 9.90881C50.3083 9.90072 50.6861 9.99814 50.9777 10.2011C51.285 10.3877 51.4423 10.689 51.4495 11.105C51.4518 11.233 51.4216 11.3375 51.359 11.4186C51.5678 11.463 51.7379 11.58 51.8692 11.7698C52.0005 11.9595 52.0695 12.2463 52.0763 12.6303C52.079 12.7903 52.0463 13.2069 51.978 13.8802C51.9098 14.5535 51.836 15.3709 51.7568 16.3324C51.6936 17.2937 51.671 18.2942 51.6892 19.3341C51.692 19.494 51.7185 19.6376 51.7687 19.7647C51.8187 19.8759 51.9236 19.9301 52.0836 19.9273C52.7235 19.9161 53.3692 19.7768 54.0206 19.5094C54.6878 19.2257 55.3299 18.8864 55.9471 18.4916C56.5801 18.0805 57.1491 17.6785 57.6543 17.2856C58.1753 16.8765 58.6095 16.5409 58.957 16.2787C59.3202 16.0004 59.5578 15.8602 59.6697 15.8582C60.2137 15.8488 60.4904 16.116 60.4999 16.6599C60.5024 16.8039 60.3317 17.1109 59.9879 17.581C59.6597 18.0348 59.2049 18.5628 58.6233 19.165C58.0414 19.7513 57.3876 20.3388 56.6618 20.9275C55.9516 21.5 55.2079 21.977 54.4304 22.3587C53.6689 22.74 52.9283 22.937 52.2084 22.9495C51.3285 22.9649 50.653 22.7687 50.1818 22.3608C49.7266 21.9527 49.3969 21.3984 49.1926 20.6978C49.0041 19.981 48.894 19.1748 48.8623 18.2792C48.0669 19.4613 47.2371 20.5159 46.3732 21.4432C45.525 22.3541 44.6815 23.0809 43.8429 23.6236C43.0042 24.1664 42.2169 24.4441 41.481 24.457ZM49.7659 12.2865C48.2113 13.0658 46.8186 13.9542 45.5878 14.9519C44.3567 15.9335 43.3825 16.9587 42.665 18.0273C41.9635 19.0958 41.6213 20.1179 41.6384 21.0937C41.6431 21.3657 41.7735 21.4994 42.0294 21.495C42.3334 21.4897 42.7303 21.3067 43.22 20.9461C43.7258 20.5852 44.2696 20.1117 44.8515 19.5254C45.4331 18.9232 46.0135 18.2569 46.5929 17.5267C47.1719 16.7805 47.7107 16.019 48.2092 15.2422C48.7078 14.4653 49.1029 13.7223 49.3945 13.0131C49.502 12.7552 49.6258 12.513 49.7659 12.2865Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20.2332 25.788C19.4974 25.8008 18.9658 25.6021 18.6386 25.1917C18.3114 24.7814 18.1408 24.1763 18.1268 23.3764C18.114 22.6405 18.2332 21.6783 18.4845 20.4897C18.7515 19.2849 19.1042 17.9425 19.5424 16.4626C19.9963 14.9665 20.5056 13.4294 21.0701 11.8513C21.6344 10.2572 22.2232 8.69467 22.8366 7.16373C23.4497 5.6168 24.049 4.19812 24.6346 2.9077C24.9128 2.34276 25.21 1.95352 25.5264 1.73996C25.8427 1.52641 26.1928 1.41628 26.5767 1.40958C26.9447 1.40316 27.2585 1.5017 27.5181 1.7052C27.7774 1.8927 27.9108 2.20242 27.9183 2.63435C27.92 2.73034 27.7984 3.10052 27.5537 3.74489C27.3086 4.37326 26.9869 5.195 26.5885 6.21011C26.1899 7.20922 25.7535 8.337 25.2794 9.59347C24.805 10.8339 24.3315 12.1304 23.859 13.4829C23.3863 14.8193 22.9613 16.1389 22.5839 17.4417C22.2066 18.7445 21.9076 19.9499 21.6869 21.0579C22.2337 20.2963 22.8191 19.4539 23.4431 18.5309C24.0828 17.5916 24.7306 16.6601 25.3866 15.7365C26.0583 14.7967 26.7154 13.9371 27.3579 13.1577C28.0164 12.3781 28.6296 11.7513 29.1974 11.2773C29.7652 10.8034 30.2651 10.5626 30.697 10.5551C31.4969 10.5411 32.1407 10.7539 32.6284 11.1935C33.1319 11.6167 33.393 12.3643 33.4117 13.4361C33.4203 13.9321 33.3348 14.5336 33.1551 15.2409C32.9752 15.9321 32.7559 16.6641 32.4974 17.4367C32.2545 18.193 32.0433 18.9248 31.8636 19.6321C31.6839 20.3393 31.5987 20.9569 31.6079 21.4848C31.6129 21.7728 31.7514 21.9144 32.0234 21.9096C32.8072 21.896 33.5962 21.7142 34.3902 21.3643C35.2002 21.0141 35.9688 20.5846 36.6961 20.0758C37.439 19.5508 38.1101 19.035 38.7094 18.5284C39.3243 18.0056 39.8209 17.5729 40.1989 17.2303C40.4664 16.9695 40.7115 16.7972 40.934 16.7133C41.1563 16.6134 41.3634 16.5618 41.5554 16.5585C42.2113 16.547 42.545 16.8693 42.5564 17.5252C42.5587 17.6531 42.5375 17.8135 42.4928 18.0063C42.4479 18.1831 42.2998 18.4098 42.0486 18.6862C41.7974 18.9626 41.4202 19.3533 40.9169 19.8581C40.4136 20.363 39.8229 20.9014 39.1448 21.4733C38.4827 22.0449 37.7562 22.5937 36.9652 23.1196C36.19 23.6292 35.3892 24.0512 34.563 24.3857C33.7367 24.7202 32.9316 24.8943 32.1477 24.9079C30.9639 24.9286 30.0462 24.6086 29.3945 23.9478C28.7432 23.3031 28.4076 22.4128 28.3878 21.277C28.3741 20.4931 28.5169 19.5065 28.8162 18.3171C29.1152 17.1117 29.4538 15.8816 29.8319 14.6268C29.2815 15.1805 28.7089 15.8386 28.1141 16.6011C27.519 17.3476 26.9168 18.1422 26.3074 18.985C25.6978 19.8117 25.096 20.6304 24.502 21.4408C23.9238 22.2351 23.3685 22.9649 22.836 23.6303C22.3032 24.2796 21.8162 24.8002 21.375 25.192C20.9338 25.5837 20.5532 25.7824 20.2332 25.788Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2.61566 9.60494C1.84777 9.61835 1.2786 9.55627 0.908146 9.41872C0.553407 9.26488 0.318231 9.08496 0.202618 8.87895C0.102724 8.65666 0.0512403 8.45753 0.0481686 8.28155C0.0442593 8.05759 0.167772 7.79939 0.418707 7.50697C0.669362 7.19855 1.16138 6.96592 1.89475 6.8091C2.62785 6.63628 3.4972 6.47708 4.50281 6.33151C5.50815 6.16994 6.57789 6.03125 7.71204 5.91544C8.84592 5.78363 9.99606 5.66754 11.1625 5.56717C12.3289 5.46679 13.4398 5.3914 14.495 5.34097C15.5501 5.27455 16.4855 5.23422 17.3014 5.21998C18.1492 5.20518 18.806 5.24172 19.2716 5.32961C19.7372 5.4175 20.0672 5.53175 20.2617 5.67238C20.4722 5.81273 20.5948 5.96261 20.6296 6.12203C20.6804 6.28117 20.707 6.43272 20.7096 6.5767C20.7101 6.6087 20.7105 6.63269 20.7108 6.64869C20.7142 6.84066 20.6698 7.04947 20.5777 7.27511C20.5017 7.50047 20.321 7.69565 20.0359 7.86065C19.7504 8.00966 19.3197 8.08919 18.7438 8.09924C18.5359 8.10287 18.0725 8.14296 17.3537 8.21952C16.6349 8.29608 15.7565 8.39142 14.7183 8.50556C13.6801 8.61969 12.578 8.73495 11.4118 8.85132C11.2484 9.57428 11.0631 10.4176 10.8559 11.3814C10.6647 12.3449 10.4663 13.3565 10.2608 14.4163C10.0713 15.4757 9.88943 16.5191 9.71533 17.5462C9.54095 18.5574 9.39731 19.4961 9.28441 20.3622C9.17123 21.2123 9.09539 21.9097 9.05689 22.4545C9.00575 23.1915 8.79886 23.7152 8.43622 24.0256C8.0893 24.3197 7.73986 24.4698 7.38792 24.4759C7.13196 24.4804 6.87502 24.4289 6.6171 24.3214C6.35947 24.2298 6.14773 24.0175 5.98189 23.6843C5.81577 23.3352 5.72671 22.8167 5.7147 22.1288C5.70241 21.4249 5.75132 20.5599 5.86143 19.5338C5.97126 18.4917 6.11983 17.377 6.30714 16.1895C6.51044 15.0018 6.7376 13.8056 6.98861 12.6011C7.25563 11.3962 7.53203 10.2712 7.81784 9.2261C6.7474 9.32479 5.7568 9.41409 4.84606 9.494C3.93504 9.55791 3.19157 9.59489 2.61566 9.60494Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="muted">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.thank_you_note"
|
||||
)}
|
||||
</p>
|
||||
<p class="muted">
|
||||
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
|
||||
</p>
|
||||
${this._renderSubscriptionState()}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSubscriptionState(): TemplateResult | typeof nothing {
|
||||
switch (this._accountState) {
|
||||
case "trial":
|
||||
return html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.trial_title"
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.trial_text"
|
||||
)}
|
||||
<ha-button
|
||||
slot="action"
|
||||
size="s"
|
||||
href="https://account.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.add_payment"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
case "canceled":
|
||||
return html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.canceled_title"
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.canceled_text",
|
||||
{ date: this._renewalDate }
|
||||
)}
|
||||
<ha-button
|
||||
slot="action"
|
||||
size="s"
|
||||
href="https://account.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.resubscribe"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
case "expired":
|
||||
return html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.expired_title"
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.expired_text"
|
||||
)}
|
||||
<ha-button
|
||||
slot="action"
|
||||
size="s"
|
||||
href="https://account.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.renew"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
default:
|
||||
// "subscribed" and "unknown" show no alert.
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
private _subscriptionDetail(): string {
|
||||
switch (this._accountState) {
|
||||
case "unknown":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.status_unknown"
|
||||
);
|
||||
case "expired":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.expired_label"
|
||||
);
|
||||
case "trial":
|
||||
return this.hass.localize("ui.panel.config.cloud.account.trial_ends", {
|
||||
date: this._renewalDate,
|
||||
});
|
||||
case "canceled":
|
||||
return this.hass.localize("ui.panel.config.cloud.account.access_ends", {
|
||||
date: this._renewalDate,
|
||||
});
|
||||
default:
|
||||
// "subscribed"
|
||||
return this.hass.localize("ui.panel.config.cloud.account.renews", {
|
||||
date: this._renewalDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _renderAccountCard(): TemplateResult {
|
||||
const { email } = this.cloudStatus;
|
||||
const at = email.indexOf("@");
|
||||
const maskedEmail = at > 0 ? `${"•".repeat(at)}${email.slice(at)}` : email;
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="account-header">
|
||||
<span class="card-title"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.nabu_casa_account"
|
||||
)}</span
|
||||
>
|
||||
<img
|
||||
class="nc-logo"
|
||||
alt="Nabu Casa"
|
||||
src="https://brands.home-assistant.io/_/cloud/${
|
||||
this.hass.themes?.darkMode ? "dark_" : ""
|
||||
}icon.png"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.email"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text" class="email-line">
|
||||
<span>${this._emailRevealed ? email : maskedEmail}</span>
|
||||
<ha-icon-button
|
||||
.path=${this._emailRevealed ? mdiEyeOff : mdiEye}
|
||||
.label=${
|
||||
this._emailRevealed
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.hide_email"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.show_email"
|
||||
)
|
||||
}
|
||||
@click=${this._toggleEmail}
|
||||
></ha-icon-button>
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.subscription"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text">${this._subscriptionDetail()}</span>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
<div class="card-actions split">
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
href="https://account.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.manage_account"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._signOut}
|
||||
variant="danger"
|
||||
appearance="plain"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.cloud.account.sign_out")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFeaturesCard(): TemplateResult {
|
||||
const cloudhooks = this.cloudStatus.prefs.cloudhooks || {};
|
||||
// Distinguish "not loaded yet / unavailable" (undefined) from "zero active"
|
||||
// so we don't claim 0 active webhooks before the fetch resolves.
|
||||
const webhookStatus =
|
||||
this.webhooks === undefined
|
||||
? ""
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.webhooks_active",
|
||||
{
|
||||
count: this.webhooks.filter(
|
||||
(hook) =>
|
||||
isActiveCloudWebhook(hook) && cloudhooks[hook.webhook_id]
|
||||
).length,
|
||||
}
|
||||
);
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="extras-header">
|
||||
<h1>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.features_title"
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="extras-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.features_intro"
|
||||
)}
|
||||
</p>
|
||||
<ha-md-list>
|
||||
${this._featureRow(
|
||||
mdiEarth,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_remote"
|
||||
),
|
||||
this._remoteStatus(),
|
||||
"/config/cloud/remote"
|
||||
)}
|
||||
${this._featureRow(
|
||||
mdiBackupRestore,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_backups"
|
||||
),
|
||||
this._renderBackupStatus(),
|
||||
"/config/cloud/backup"
|
||||
)}
|
||||
${this._featureRow(
|
||||
mdiMicrophone,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_voice"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_voice_sub"
|
||||
),
|
||||
"/config/cloud/voice-assistants"
|
||||
)}
|
||||
${this._featureRow(
|
||||
mdiCellphone,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_companion"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_companion_sub"
|
||||
),
|
||||
"/config/cloud/companion"
|
||||
)}
|
||||
${this._featureRow(
|
||||
mdiMicrophoneMessage,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_alexa_google"
|
||||
),
|
||||
this._alexaGoogleStatus(),
|
||||
"/config/voice-assistants/assistants?historyBack=1"
|
||||
)}
|
||||
${this._featureRow(
|
||||
mdiVideo,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_cameras"
|
||||
),
|
||||
this._onOffStatus(
|
||||
this.cloudStatus.prefs.cloud_ice_servers_enabled
|
||||
),
|
||||
"/config/cloud/webrtc"
|
||||
)}
|
||||
${this._featureRow(
|
||||
mdiWebhook,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.feature_webhooks"
|
||||
),
|
||||
webhookStatus,
|
||||
"/config/cloud/webhooks"
|
||||
)}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _featureRow(
|
||||
icon: string,
|
||||
title: string,
|
||||
supporting: string | TemplateResult,
|
||||
href: string
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<ha-md-list-item type="link" href=${href}>
|
||||
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
|
||||
<span slot="headline">${title}</span>
|
||||
<span slot="supporting-text">${supporting}</span>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private get _subscriptionActive(): boolean {
|
||||
return this.cloudStatus.active_subscription;
|
||||
}
|
||||
|
||||
private _statusLine(
|
||||
dotClass: "enabled" | "warning" | "disabled",
|
||||
text: string
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<span class="status-line">
|
||||
<span class="status-dot ${dotClass}"></span>
|
||||
${text}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private _onOffStatus(enabled: boolean): TemplateResult {
|
||||
// An enabled feature only works with an active subscription; otherwise it
|
||||
// is configured on but inactive, so show it as such instead of green "On".
|
||||
if (enabled && !this._subscriptionActive) {
|
||||
return this._statusLine(
|
||||
"disabled",
|
||||
this.hass.localize("ui.panel.config.cloud.account.overview.inactive")
|
||||
);
|
||||
}
|
||||
return this._statusLine(
|
||||
enabled ? "enabled" : "disabled",
|
||||
enabled
|
||||
? this.hass.localize("ui.panel.config.cloud.account.overview.on")
|
||||
: this.hass.localize("ui.panel.config.cloud.account.overview.off")
|
||||
);
|
||||
}
|
||||
|
||||
private _remoteStatus(): TemplateResult {
|
||||
if (!this.cloudStatus.prefs.remote_enabled) {
|
||||
return this._statusLine(
|
||||
"disabled",
|
||||
this.hass.localize("ui.panel.config.cloud.account.overview.off")
|
||||
);
|
||||
}
|
||||
if (!this._subscriptionActive) {
|
||||
return this._statusLine(
|
||||
"disabled",
|
||||
this.hass.localize("ui.panel.config.cloud.account.overview.inactive")
|
||||
);
|
||||
}
|
||||
// Remote access only actually works once the certificate is ready — reflect
|
||||
// the in-progress and error states instead of a misleading green "On".
|
||||
switch (this.cloudStatus.remote_certificate_status) {
|
||||
case "ready":
|
||||
return this._statusLine(
|
||||
"enabled",
|
||||
this.hass.localize("ui.panel.config.cloud.account.overview.on")
|
||||
);
|
||||
case "error":
|
||||
return this._statusLine(
|
||||
"disabled",
|
||||
this.hass.localize("ui.panel.config.cloud.account.overview.error")
|
||||
);
|
||||
default:
|
||||
return this._statusLine(
|
||||
"warning",
|
||||
this.hass.localize("ui.panel.config.cloud.account.overview.preparing")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _alexaGoogleStatus(): string {
|
||||
const { alexa_registered, google_registered } = this.cloudStatus;
|
||||
if (alexa_registered && google_registered) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.alexa_google_both"
|
||||
);
|
||||
}
|
||||
if (alexa_registered) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.alexa_google_alexa"
|
||||
);
|
||||
}
|
||||
if (google_registered) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.alexa_google_google"
|
||||
);
|
||||
}
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.alexa_google_none"
|
||||
);
|
||||
}
|
||||
|
||||
private _renderBackupStatus(): TemplateResult | string {
|
||||
// No config yet (still loading, fetch failed, or backup integration not
|
||||
// loaded) is "unknown", not "no cloud backup" — leave it blank rather than
|
||||
// asserting a state we don't know.
|
||||
if (!this.backupConfig) {
|
||||
return "";
|
||||
}
|
||||
if (!cloudBackupEnabled(this.backupConfig)) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.no_cloud_backup"
|
||||
);
|
||||
}
|
||||
// Cloud backup is configured, but without an active subscription future
|
||||
// backups won't run — surface that instead of a stale green "last backup".
|
||||
if (!this._subscriptionActive) {
|
||||
return this._statusLine(
|
||||
"disabled",
|
||||
this.hass.localize("ui.panel.config.cloud.account.overview.inactive")
|
||||
);
|
||||
}
|
||||
const health = cloudBackupHealth(this.backupConfig);
|
||||
if (health === "none") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.no_cloud_backup"
|
||||
);
|
||||
}
|
||||
const dot =
|
||||
health === "success"
|
||||
? "enabled"
|
||||
: health === "failed"
|
||||
? "disabled"
|
||||
: "warning";
|
||||
const last = this.backupConfig?.last_completed_automatic_backup;
|
||||
const lastBackupRelative = last
|
||||
? relativeTime(new Date(last), this.hass.locale, new Date(), true)
|
||||
: undefined;
|
||||
const text =
|
||||
health === "failed"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.backup_failed"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.last_backup",
|
||||
{ relative: lastBackupRelative ?? "" }
|
||||
);
|
||||
return this._statusLine(dot, text);
|
||||
}
|
||||
|
||||
private get _accountState():
|
||||
"subscribed" | "trial" | "canceled" | "expired" | "unknown" {
|
||||
const active = this.cloudStatus.active_subscription;
|
||||
switch (this.subscription?.subscription?.status) {
|
||||
case "trialing":
|
||||
return active ? "trial" : "expired";
|
||||
case "active":
|
||||
return active ? "subscribed" : "expired";
|
||||
case "canceled":
|
||||
return "canceled";
|
||||
case "expired":
|
||||
return "expired";
|
||||
case "unknown":
|
||||
return active ? "unknown" : "expired";
|
||||
default:
|
||||
return active ? "subscribed" : "expired";
|
||||
}
|
||||
}
|
||||
|
||||
private get _renewalDate(): string {
|
||||
return this.subscription?.plan_renewal_date
|
||||
? formatDate(
|
||||
new Date(this.subscription.plan_renewal_date * 1000),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.getting_renewal_date"
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleEmail() {
|
||||
this._emailRevealed = !this._emailRevealed;
|
||||
}
|
||||
|
||||
private _signOut() {
|
||||
fireEvent(this, "cloud-sign-out");
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6);
|
||||
}
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.card-title {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
.thank-you-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.thank-you-header svg {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
ha-alert ha-button[slot="action"] {
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.account-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--ha-space-4) var(--ha-space-4) 0;
|
||||
}
|
||||
.nc-logo {
|
||||
width: 36px;
|
||||
height: auto;
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
.muted {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
p.muted {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
.status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
.status-dot.enabled {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
.status-dot.warning {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
.status-dot.disabled {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
ha-md-list {
|
||||
padding: 0;
|
||||
--md-list-item-leading-space: 0;
|
||||
--md-list-item-trailing-space: 0;
|
||||
}
|
||||
.extras-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
}
|
||||
.extras-header h1 {
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
letter-spacing: -0.012em;
|
||||
margin: 0;
|
||||
}
|
||||
.extras-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 var(--ha-card-border-radius, 12px)
|
||||
var(--ha-card-border-radius, 12px);
|
||||
}
|
||||
.extras-content p {
|
||||
color: var(--secondary-text-color);
|
||||
padding-inline: var(--ha-space-4);
|
||||
margin-top: 0;
|
||||
}
|
||||
.extras-content ha-md-list {
|
||||
padding-top: 0;
|
||||
--md-list-item-leading-space: var(--ha-space-4);
|
||||
--md-list-item-trailing-space: var(--ha-space-4);
|
||||
}
|
||||
.extras-content ha-md-list-item {
|
||||
--md-list-item-top-space: var(--ha-space-2);
|
||||
--md-list-item-bottom-space: var(--ha-space-2);
|
||||
}
|
||||
.extras-content ha-svg-icon[slot="start"],
|
||||
.extras-content ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-2) var(--ha-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-actions.split {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.email-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
.email-line ha-icon-button {
|
||||
--ha-icon-button-size: 32px;
|
||||
--mdc-icon-size: 18px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-account-overview": CloudAccountOverview;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"cloud-sign-out": undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BackupConfig } from "../../../../data/backup";
|
||||
import { cloudBackupHealth } from "../../../../data/backup";
|
||||
import type {
|
||||
CloudOnboardingItem,
|
||||
CloudStatusLoggedIn,
|
||||
} from "../../../../data/cloud";
|
||||
import { ONBOARDING_ITEMS } from "../../../../data/cloud";
|
||||
|
||||
// Whether a single onboarding step is already set up. Shared so the onboarding
|
||||
// UI and the "is onboarding complete" check can't drift apart.
|
||||
export const onboardingPanelCompleted = (
|
||||
panel: CloudOnboardingItem,
|
||||
cloudStatus: CloudStatusLoggedIn,
|
||||
backupConfig?: BackupConfig
|
||||
): boolean => {
|
||||
if (panel === "remote") {
|
||||
return cloudStatus.prefs.remote_enabled;
|
||||
}
|
||||
|
||||
if (panel === "backup") {
|
||||
const health = cloudBackupHealth(backupConfig);
|
||||
return health === "success" || health === "old";
|
||||
}
|
||||
|
||||
if (panel === "voice") {
|
||||
return cloudStatus.alexa_registered || cloudStatus.google_registered;
|
||||
}
|
||||
|
||||
return cloudStatus.prefs.cloud_ice_servers_enabled;
|
||||
};
|
||||
|
||||
export const onboardingComplete = (
|
||||
cloudStatus: CloudStatusLoggedIn,
|
||||
backupConfig?: BackupConfig
|
||||
): boolean =>
|
||||
ONBOARDING_ITEMS.every((panel) =>
|
||||
onboardingPanelCompleted(panel, cloudStatus, backupConfig)
|
||||
);
|
||||
@@ -1,26 +1,30 @@
|
||||
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { formatDateTime } from "../../../../common/datetime/format_date_time";
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tip";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import type { BackupConfig } from "../../../../data/backup";
|
||||
import { fetchBackupConfig } from "../../../../data/backup";
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../../data/cloud";
|
||||
import {
|
||||
cloudLogout,
|
||||
completeCloudOnboarding,
|
||||
fetchCloudSubscriptionInfo,
|
||||
ONBOARDING_ITEMS,
|
||||
removeCloudData,
|
||||
} from "../../../../data/cloud";
|
||||
import type { Webhook } from "../../../../data/webhook";
|
||||
import { fetchWebhooks } from "../../../../data/webhook";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -29,13 +33,14 @@ import "../../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../ha-config-section";
|
||||
import "./cloud-ice-servers-pref";
|
||||
import "./cloud-remote-pref";
|
||||
import "./cloud-tts-pref";
|
||||
import "./cloud-webhooks";
|
||||
import "./cloud-account-onboarding";
|
||||
import "./cloud-account-overview";
|
||||
import {
|
||||
onboardingComplete,
|
||||
onboardingPanelCompleted,
|
||||
} from "./cloud-account-status";
|
||||
import { showCloudOnboardingDialog } from "./show-dialog-cloud-onboarding";
|
||||
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
|
||||
@customElement("cloud-account")
|
||||
export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
@@ -49,6 +54,15 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _subscription?: SubscriptionInfo;
|
||||
|
||||
@state() private _backupConfig?: BackupConfig;
|
||||
|
||||
@state() private _webhooks?: Webhook[];
|
||||
|
||||
// Whether the async backup config fetch has finished (regardless of outcome).
|
||||
// Used to avoid flashing the onboarding card in and out before we know the
|
||||
// backup state — see _showOnboarding.
|
||||
@state() private _backupConfigChecked = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
@@ -77,174 +91,112 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div class="content">
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
<span slot="header">Home Assistant Cloud</span>
|
||||
<div slot="introduction">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.thank_you_note"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.nabu_casa_account"
|
||||
)}
|
||||
>
|
||||
<div class="account-row">
|
||||
<ha-list-item noninteractive twoline>
|
||||
${this.cloudStatus.email.replace(
|
||||
/(\w{3})[\w.-]+@([\w.]+\w)/,
|
||||
"$1***@$2"
|
||||
)}
|
||||
<span slot="secondary" class="wrap">
|
||||
${
|
||||
this._subscription
|
||||
? this._subscription.human_description.replace(
|
||||
"{periodEnd}",
|
||||
this._subscription.plan_renewal_date
|
||||
? formatDateTime(
|
||||
new Date(
|
||||
this._subscription.plan_renewal_date * 1000
|
||||
),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: ""
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.fetching_subscription"
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</ha-list-item>
|
||||
</div>
|
||||
|
||||
${
|
||||
this.cloudStatus.cloud === "connecting" &&
|
||||
this.cloudStatus.cloud_last_disconnect_reason
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${
|
||||
this.cloudStatus.cloud_last_disconnect_reason.reason
|
||||
}
|
||||
></ha-alert>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<div class="account-row">
|
||||
<ha-list-item noninteractive>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.connection_status"
|
||||
)}:
|
||||
${
|
||||
this.cloudStatus.cloud === "connected"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.connected"
|
||||
)
|
||||
: this.cloudStatus.cloud === "disconnected"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.not_connected"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.connecting"
|
||||
)
|
||||
}
|
||||
</ha-list-item>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
href="https://account.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.manage_account"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._signOut}
|
||||
variant="danger"
|
||||
appearance="plain"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.sign_out"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
</ha-config-section>
|
||||
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
<span slot="header"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.integrations"
|
||||
)}</span
|
||||
>
|
||||
<div slot="introduction">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.integrations_introduction"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.integrations_introduction2"
|
||||
)}
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.integrations_link_all_features"
|
||||
)}</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<cloud-remote-pref
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this.cloudStatus}
|
||||
></cloud-remote-pref>
|
||||
|
||||
<cloud-tts-pref
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.cloudStatus=${this.cloudStatus}
|
||||
></cloud-tts-pref>
|
||||
|
||||
<cloud-ice-servers-pref
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this.cloudStatus}
|
||||
></cloud-ice-servers-pref>
|
||||
|
||||
<ha-tip>
|
||||
<a href="/config/voice-assistants">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tip_moved_voice_assistants"
|
||||
)}
|
||||
</a>
|
||||
</ha-tip>
|
||||
|
||||
<cloud-webhooks
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this.cloudStatus}
|
||||
></cloud-webhooks>
|
||||
</ha-config-section>
|
||||
${
|
||||
this._showOnboarding
|
||||
? html`
|
||||
<cloud-account-onboarding
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this.cloudStatus}
|
||||
.backupConfig=${this._backupConfig}
|
||||
@cloud-open-onboarding=${this._openOnboardingDialog}
|
||||
></cloud-account-onboarding>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<cloud-account-overview
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this.cloudStatus}
|
||||
.subscription=${this._subscription}
|
||||
.backupConfig=${this._backupConfig}
|
||||
.webhooks=${this._webhooks}
|
||||
@cloud-sign-out=${this._signOut}
|
||||
></cloud-account-overview>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private get _onboarded(): boolean {
|
||||
return (
|
||||
this.cloudStatus.onboarding_completed ||
|
||||
this.cloudStatus.onboarding_postponed
|
||||
);
|
||||
}
|
||||
|
||||
private get _showOnboarding(): boolean {
|
||||
if (!this.cloudStatus.active_subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._onboarded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this._backupConfigChecked && this._nonBackupStepsComplete) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide once every step is set up (see _syncOnboardedItems, which records the
|
||||
// completed items on the backend). The setup dialog lives at the document
|
||||
// level (see _openOnboardingDialog), so it survives this card unmounting.
|
||||
return !onboardingComplete(this.cloudStatus, this._backupConfig);
|
||||
}
|
||||
|
||||
private _openOnboardingDialog() {
|
||||
showCloudOnboardingDialog(this, {
|
||||
cloudStatus: this.cloudStatus,
|
||||
backupConfig: this._backupConfig,
|
||||
// The dialog keeps its own copy live; this refreshes the page behind it.
|
||||
onChanged: () => {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
this._fetchBackupConfig();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private get _nonBackupStepsComplete(): boolean {
|
||||
return (
|
||||
onboardingPanelCompleted("remote", this.cloudStatus) &&
|
||||
onboardingPanelCompleted("voice", this.cloudStatus) &&
|
||||
onboardingPanelCompleted("streaming", this.cloudStatus)
|
||||
);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this._fetchSubscriptionInfo();
|
||||
this._fetchBackupConfig();
|
||||
this._fetchWebhooks();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("cloudStatus") || changedProps.has("_backupConfig")) {
|
||||
this._syncOnboardedItems();
|
||||
}
|
||||
}
|
||||
|
||||
private async _syncOnboardedItems() {
|
||||
if (!this.cloudStatus.active_subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onboarded = this.cloudStatus.prefs.onboarded_items;
|
||||
const toComplete = ONBOARDING_ITEMS.filter(
|
||||
(item) =>
|
||||
!onboarded.includes(item) &&
|
||||
onboardingPanelCompleted(item, this.cloudStatus, this._backupConfig)
|
||||
);
|
||||
|
||||
if (toComplete.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await completeCloudOnboarding(this.hass, toComplete);
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
} catch {
|
||||
// Best effort; retried on the next status refresh.
|
||||
}
|
||||
}
|
||||
|
||||
protected override hassSubscribe() {
|
||||
@@ -289,6 +241,32 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchBackupConfig() {
|
||||
if (!isComponentLoaded(this.hass.config, "backup")) {
|
||||
this._backupConfigChecked = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await fetchBackupConfig(this.hass);
|
||||
this._backupConfig = result.config;
|
||||
} catch {
|
||||
// Best effort; leave the backup status unknown.
|
||||
} finally {
|
||||
this._backupConfigChecked = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchWebhooks() {
|
||||
if (!isComponentLoaded(this.hass.config, "webhook")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._webhooks = await fetchWebhooks(this.hass);
|
||||
} catch {
|
||||
// Best effort; leave the webhook count at zero.
|
||||
}
|
||||
}
|
||||
|
||||
private async _signOut() {
|
||||
showConfirmationDialog(this, {
|
||||
text: this.hass.localize(
|
||||
@@ -355,33 +333,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
[slot="introduction"] {
|
||||
margin: -1em 0;
|
||||
}
|
||||
[slot="introduction"] a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.content {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.account-row {
|
||||
padding: var(--ha-space-7) var(--ha-space-5) 0;
|
||||
padding-bottom: calc(
|
||||
var(--safe-area-inset-bottom) + var(--ha-space-6)
|
||||
);
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
gap: var(--ha-space-6);
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
}
|
||||
ha-button {
|
||||
align-self: center;
|
||||
}
|
||||
.wrap {
|
||||
white-space: normal;
|
||||
}
|
||||
.status {
|
||||
text-transform: capitalize;
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
mdiBackupRestore,
|
||||
mdiCalendar,
|
||||
mdiHarddisk,
|
||||
mdiShieldLock,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import { relativeTime } from "../../../../common/datetime/relative_time";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { BackupConfig, BackupInfo } from "../../../../data/backup";
|
||||
import {
|
||||
CLOUD_AGENT,
|
||||
cloudBackupEnabled,
|
||||
fetchBackupConfig,
|
||||
fetchBackupInfo,
|
||||
getLastCloudBackup,
|
||||
} from "../../../../data/backup";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { bytesToString } from "../../../../util/bytes-to-string";
|
||||
import { cloudSubpageStyle } from "./cloud-subpage-style";
|
||||
|
||||
@customElement("cloud-backup-pref")
|
||||
export class CloudBackupPref extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _backupConfig?: BackupConfig;
|
||||
|
||||
@state() private _backupInfo?: BackupInfo;
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
protected render() {
|
||||
const isConfigured = cloudBackupEnabled(this._backupConfig);
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.title"
|
||||
)}
|
||||
back-path="/config/cloud/account"
|
||||
>
|
||||
<div class="content">
|
||||
${
|
||||
this._loaded
|
||||
? isConfigured
|
||||
? this._renderStatusCard()
|
||||
: this._renderSetupCard()
|
||||
: html`
|
||||
<ha-card outlined>
|
||||
<div class="spinner">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
}
|
||||
${this._renderInfoCard()}
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderInfoCard() {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content">
|
||||
<h3 class="info-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.card_title"
|
||||
)}
|
||||
</h3>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.cloud_info"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.cloud_info_one_backup"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.cloud_info_size_limit"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.cloud_info_encrypted"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.cloud_info_no_access"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://support.nabucasa.com/hc/en-us/sections/26353804834973"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.link_learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderStatusCard() {
|
||||
const cloudBackup = getLastCloudBackup(this._backupInfo?.backups);
|
||||
const cloudAgent = cloudBackup?.agents[CLOUD_AGENT];
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.status_card_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${
|
||||
cloudBackup
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.last_cloud_backup",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
new Date(cloudBackup.date),
|
||||
this.hass.locale,
|
||||
new Date(),
|
||||
true
|
||||
),
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.no_cloud_backups"
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
${
|
||||
cloudAgent
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiHarddisk}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${bytesToString(cloudAgent.size)}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiShieldLock}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${
|
||||
cloudAgent.protected
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.backup_encrypted"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.backup_not_encrypted"
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-md-list-item>
|
||||
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${
|
||||
this._backupConfig?.next_automatic_backup
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.next_backup",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
new Date(this._backupConfig.next_automatic_backup),
|
||||
this.hass.locale,
|
||||
new Date(),
|
||||
true
|
||||
),
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.no_next_backup"
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="filled" href="/config/backup?historyBack=1">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.configure"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSetupCard() {
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.setup_card_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.backup.setup_info"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="filled" href="/config/backup?historyBack=1">
|
||||
${this.hass.localize("ui.panel.config.cloud.account.backup.set_up")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps) {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._fetchData();
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchData() {
|
||||
if (!isComponentLoaded(this.hass.config, "backup")) {
|
||||
// Backup integration isn't loaded, so there's nothing to fetch; fall back
|
||||
// to the setup card rather than spinning forever.
|
||||
this._loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [configResult, info] = await Promise.all([
|
||||
fetchBackupConfig(this.hass),
|
||||
fetchBackupInfo(this.hass),
|
||||
]);
|
||||
this._backupConfig = configResult.config;
|
||||
this._backupInfo = info;
|
||||
} catch {
|
||||
// Best effort; leave the backup status unknown.
|
||||
} finally {
|
||||
this._loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
css`
|
||||
.info-header {
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
margin: 0 0 var(--ha-space-2) 0;
|
||||
}
|
||||
.card-content p,
|
||||
.card-content li {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.card-content a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ul {
|
||||
padding-left: var(--ha-space-6);
|
||||
padding-inline-start: var(--ha-space-6);
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
li {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
ha-md-list {
|
||||
background: none;
|
||||
--md-list-item-leading-space: 0;
|
||||
--md-list-item-trailing-space: 0;
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-list-item-top-space: var(--ha-space-2);
|
||||
--md-list-item-bottom-space: var(--ha-space-2);
|
||||
--md-list-item-one-line-container-height: 40px;
|
||||
}
|
||||
ha-svg-icon[slot="start"] {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-8) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-backup-pref": CloudBackupPref;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { cloudSubpageStyle } from "./cloud-subpage-style";
|
||||
|
||||
@customElement("cloud-companion-pref")
|
||||
export class CloudCompanionPref extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.title"
|
||||
)}
|
||||
back-path="/config/cloud/account"
|
||||
>
|
||||
<div class="content">
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.connected_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.connected_intro"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.bullet_sensors"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.bullet_presence"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.bullet_alerts"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.bullet_secure"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.bullet_push"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://companion.home-assistant.io/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.webhook_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.webhook_info_one"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.companion.webhook_info_two"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
css`
|
||||
.card-content p,
|
||||
.card-content li {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ul {
|
||||
padding-left: var(--ha-space-6);
|
||||
padding-inline-start: var(--ha-space-6);
|
||||
padding-inline-end: initial;
|
||||
margin: 0;
|
||||
}
|
||||
li {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-companion-pref": CloudCompanionPref;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-switch";
|
||||
import type { HaSwitch } from "../../../../components/ha-switch";
|
||||
import type { CloudStatusLoggedIn } from "../../../../data/cloud";
|
||||
import { updateCloudPref } from "../../../../data/cloud";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { cloudSubpageStyle } from "./cloud-subpage-style";
|
||||
|
||||
@customElement("cloud-ice-servers-pref")
|
||||
export class CloudICEServersPref extends LitElement {
|
||||
@@ -17,6 +19,8 @@ export class CloudICEServersPref extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render() {
|
||||
if (!this.cloudStatus) {
|
||||
return nothing;
|
||||
@@ -26,37 +30,50 @@ export class CloudICEServersPref extends LitElement {
|
||||
this.cloudStatus.prefs;
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize(
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.ice_servers.title"
|
||||
)}
|
||||
back-path="/config/cloud/account"
|
||||
>
|
||||
<div class="header-actions">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.ice_servers.link_learn_how_it_works"
|
||||
<div class="content">
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.ice_servers.title"
|
||||
)}
|
||||
.path=${mdiHelpCircleOutline}
|
||||
href="https://www.nabucasa.com/config/webrtc/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="icon-link"
|
||||
></ha-icon-button>
|
||||
<ha-switch
|
||||
.checked=${cloudICEServersEnabled}
|
||||
@change=${this._toggleCloudICEServersEnabledChanged}
|
||||
></ha-switch>
|
||||
</div>
|
||||
>
|
||||
<div class="header-actions">
|
||||
<ha-switch
|
||||
.checked=${cloudICEServersEnabled}
|
||||
@change=${this._toggleCloudICEServersEnabledChanged}
|
||||
></ha-switch>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.ice_servers.info"
|
||||
)}
|
||||
</p>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.ice_servers.info"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://www.nabucasa.com/config/webrtc/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.ice_servers.link_learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</ha-card>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -74,25 +91,28 @@ export class CloudICEServersPref extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.header-actions {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
inset-inline-end: 16px;
|
||||
inset-inline-start: initial;
|
||||
top: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.header-actions .icon-link {
|
||||
margin-top: -16px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
css`
|
||||
.header-actions {
|
||||
position: absolute;
|
||||
right: var(--ha-space-4);
|
||||
inset-inline-end: var(--ha-space-4);
|
||||
inset-inline-start: initial;
|
||||
top: var(--ha-space-6);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.card-content p {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-switch";
|
||||
import "../../../../components/item/ha-row-item";
|
||||
import "../../../../components/ha-tip";
|
||||
|
||||
import { formatDate } from "../../../../common/datetime/format_date";
|
||||
import type { HaSwitch } from "../../../../components/ha-switch";
|
||||
@@ -18,15 +18,20 @@ import {
|
||||
disconnectCloudRemote,
|
||||
updateCloudPref,
|
||||
} from "../../../../data/cloud";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { obfuscateUrl } from "../../../../util/url";
|
||||
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
|
||||
import { cloudSubpageStyle } from "./cloud-subpage-style";
|
||||
|
||||
@customElement("cloud-remote-pref")
|
||||
export class CloudRemotePref extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
|
||||
|
||||
protected render() {
|
||||
@@ -38,174 +43,216 @@ export class CloudRemotePref extends LitElement {
|
||||
this.cloudStatus.prefs;
|
||||
|
||||
const {
|
||||
cloud,
|
||||
cloud_last_disconnect_reason,
|
||||
remote_connected,
|
||||
remote_domain,
|
||||
remote_certificate,
|
||||
remote_certificate_status,
|
||||
} = this.cloudStatus;
|
||||
|
||||
if (!remote_certificate || remote_certificate_status !== "ready") {
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.title"
|
||||
)}
|
||||
>
|
||||
<div class="preparing">
|
||||
${
|
||||
remote_certificate_status === "error"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.cerificate_error"
|
||||
)
|
||||
: remote_certificate_status === "loading"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.cerificate_loading"
|
||||
)
|
||||
: remote_certificate_status === "loaded"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.cerificate_loaded"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.access_is_being_prepared"
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize(
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.title"
|
||||
)}
|
||||
back-path="/config/cloud/account"
|
||||
>
|
||||
<div class="header-actions">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.link_learn_how_it_works"
|
||||
)}
|
||||
.path=${mdiHelpCircleOutline}
|
||||
href="https://www.nabucasa.com/config/remote/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="icon-link"
|
||||
></ha-icon-button>
|
||||
<ha-switch
|
||||
.checked=${remote_enabled}
|
||||
@change=${this._toggleChanged}
|
||||
></ha-switch>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
${
|
||||
!remote_connected && remote_enabled
|
||||
!remote_certificate || remote_certificate_status !== "ready"
|
||||
? html`
|
||||
<ha-alert
|
||||
.title=${this.hass.localize(
|
||||
`ui.panel.config.cloud.account.remote.reconnecting`
|
||||
)}
|
||||
></ha-alert>
|
||||
<ha-card outlined>
|
||||
<div class="preparing">
|
||||
${
|
||||
remote_certificate_status === "error"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.cerificate_error"
|
||||
)
|
||||
: remote_certificate_status === "loading"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.cerificate_loading"
|
||||
)
|
||||
: remote_certificate_status === "loaded"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.cerificate_loaded"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.access_is_being_prepared"
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
: strict_connection === "drop_connection"
|
||||
? html`<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
`ui.panel.config.cloud.account.remote.drop_connection_warning_title`
|
||||
)}
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.cloud.account.remote.drop_connection_warning`
|
||||
)}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.cloud.account.remote.info")}
|
||||
</p>
|
||||
${
|
||||
remote_connected
|
||||
? nothing
|
||||
: html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.info_instance_will_be_available"
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.card_title"
|
||||
)}
|
||||
</p>
|
||||
>
|
||||
<div class="header-actions">
|
||||
<ha-switch
|
||||
.checked=${remote_enabled}
|
||||
@change=${this._toggleChanged}
|
||||
></ha-switch>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
${
|
||||
cloud === "connecting" && cloud_last_disconnect_reason
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${cloud_last_disconnect_reason.reason}
|
||||
></ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
!remote_connected && remote_enabled
|
||||
? html`
|
||||
<ha-alert
|
||||
.title=${this.hass.localize(
|
||||
`ui.panel.config.cloud.account.remote.reconnecting`
|
||||
)}
|
||||
></ha-alert>
|
||||
`
|
||||
: strict_connection === "drop_connection"
|
||||
? html`<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
`ui.panel.config.cloud.account.remote.drop_connection_warning_title`
|
||||
)}
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.cloud.account.remote.drop_connection_warning`
|
||||
)}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.info"
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
remote_connected
|
||||
? nothing
|
||||
: html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.info_instance_will_be_available"
|
||||
)}
|
||||
</p>
|
||||
`
|
||||
}
|
||||
|
||||
<ha-input-copy
|
||||
readonly
|
||||
.value=${`https://${remote_domain}`}
|
||||
.maskedValue=${obfuscateUrl(`https://${remote_domain}`)}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.common.copy_link"
|
||||
)}
|
||||
></ha-input-copy>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://www.nabucasa.com/config/remote/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.link_learn_how_it_works"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.security_options"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.external_activation"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.external_activation_secondary"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${remote_allow_remote_enable}
|
||||
@change=${this._toggleAllowRemoteEnabledChanged}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.certificate_info"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${
|
||||
this.cloudStatus!.remote_certificate
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.certificate_expire",
|
||||
{
|
||||
date: formatDate(
|
||||
new Date(
|
||||
this.cloudStatus.remote_certificate
|
||||
.expire_date
|
||||
),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}
|
||||
)
|
||||
: nothing
|
||||
}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._openCertInfo}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.more_info"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-tip .hass=${this.hass}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.tip_custom_domain"
|
||||
)}
|
||||
<a
|
||||
href="https://support.nabucasa.com/hc/en-us/articles/26497540527517"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.tip_custom_domain_link"
|
||||
)}</a
|
||||
>
|
||||
</ha-tip>
|
||||
`
|
||||
}
|
||||
|
||||
<ha-input-copy
|
||||
readonly
|
||||
.value=${`https://${remote_domain}`}
|
||||
.maskedValue=${obfuscateUrl(`https://${remote_domain}`)}
|
||||
.label=${this.hass!.localize("ui.panel.config.common.copy_link")}
|
||||
></ha-input-copy>
|
||||
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.security_options"
|
||||
)}
|
||||
>
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.external_activation"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.external_activation_secondary"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${remote_allow_remote_enable}
|
||||
@change=${this._toggleAllowRemoteEnabledChanged}
|
||||
>
|
||||
</ha-switch>
|
||||
</ha-row-item>
|
||||
<hr />
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.certificate_info"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${
|
||||
this.cloudStatus!.remote_certificate
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.certificate_expire",
|
||||
{
|
||||
date: formatDate(
|
||||
new Date(
|
||||
this.cloudStatus.remote_certificate.expire_date
|
||||
),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}
|
||||
)
|
||||
: nothing
|
||||
}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._openCertInfo}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.more_info"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-row-item>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
</ha-card>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -245,72 +292,50 @@ export class CloudRemotePref extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.preparing {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.header-actions {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
inset-inline-end: 16px;
|
||||
inset-inline-start: initial;
|
||||
top: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.header-actions .icon-link {
|
||||
margin-top: -16px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.warning {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.break-word {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.connection-status {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 24px;
|
||||
inset-inline-end: 24px;
|
||||
inset-inline-start: initial;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
}
|
||||
.card-actions a {
|
||||
text-decoration: none;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
ha-row-item {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
ha-row-item::part(headline),
|
||||
ha-row-item::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--expansion-panel-content-padding: 0 16px;
|
||||
--expansion-panel-summary-padding: 0 16px;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background-color: var(--divider-color);
|
||||
margin: 8px 0;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
css`
|
||||
.preparing {
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.card-content p {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.header-actions {
|
||||
position: absolute;
|
||||
right: var(--ha-space-4);
|
||||
inset-inline-end: var(--ha-space-4);
|
||||
inset-inline-start: initial;
|
||||
top: var(--ha-space-6);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
ha-md-list {
|
||||
background: none;
|
||||
--md-list-item-leading-space: 0;
|
||||
--md-list-item-trailing-space: 0;
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
ha-tip {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const cloudSubpageStyle = css`
|
||||
.content {
|
||||
padding: var(--ha-space-7) var(--ha-space-5) 0;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
ha-card {
|
||||
display: block;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
margin-bottom: var(--ha-space-6);
|
||||
}
|
||||
`;
|
||||
@@ -1,17 +1,31 @@
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import { mdiContentCopy, mdiPlayCircleOutline, mdiRobot } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../../../../common/entity/supports-feature";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-language-picker";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-switch";
|
||||
import "../../../../components/ha-textarea";
|
||||
import "../../../../components/ha-tip";
|
||||
import "../../../../components/voice-assistant-brand-icon";
|
||||
import type {
|
||||
HaSelectOption,
|
||||
HaSelectSelectEvent,
|
||||
} from "../../../../components/ha-select";
|
||||
import type { HaTextArea } from "../../../../components/ha-textarea";
|
||||
import { showAutomationEditor } from "../../../../data/automation";
|
||||
import type { CloudStatusLoggedIn } from "../../../../data/cloud";
|
||||
import { updateCloudPref } from "../../../../data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../../data/cloud/tts";
|
||||
@@ -19,10 +33,14 @@ import {
|
||||
getCloudTTSInfo,
|
||||
getCloudTtsLanguages,
|
||||
} from "../../../../data/cloud/tts";
|
||||
import { MediaPlayerEntityFeature } from "../../../../data/media-player";
|
||||
import { convertTextToSpeech } from "../../../../data/tts";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { showTryTtsDialog } from "./show-dialog-cloud-tts-try";
|
||||
import { cloudSubpageStyle } from "./cloud-subpage-style";
|
||||
|
||||
export const getCloudTtsSupportedVoices = (
|
||||
language: string,
|
||||
@@ -55,6 +73,16 @@ export class CloudTTSPref extends LitElement {
|
||||
|
||||
@state() private ttsInfo?: CloudTTSInfo;
|
||||
|
||||
@state() private _loadingExample = false;
|
||||
|
||||
@query("#message") private _messageInput?: HaTextArea;
|
||||
|
||||
@storage({ key: "cloudTtsTryMessage", state: false, subscribe: false })
|
||||
private _message!: string;
|
||||
|
||||
@storage({ key: "cloudTtsTryTarget", state: false, subscribe: false })
|
||||
private _target!: string;
|
||||
|
||||
protected render() {
|
||||
if (!this.cloudStatus || !this.ttsInfo) {
|
||||
return nothing;
|
||||
@@ -64,74 +92,231 @@ export class CloudTTSPref extends LitElement {
|
||||
const defaultVoice = this.cloudStatus.prefs.tts_default_voice;
|
||||
const voices = this.getSupportedVoices(defaultVoice[0], this.ttsInfo);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize("ui.panel.config.cloud.account.tts.title")}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.description",
|
||||
{
|
||||
service: '"tts.cloud_say"',
|
||||
}
|
||||
)}
|
||||
<br /><br />
|
||||
<div class="row">
|
||||
<ha-language-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.default_language"
|
||||
)}
|
||||
.disabled=${this.savingPreferences}
|
||||
.value=${defaultVoice[0]}
|
||||
.languages=${languages}
|
||||
@value-changed=${this._handleLanguageChange}
|
||||
>
|
||||
</ha-language-picker>
|
||||
const target = this._target || "browser";
|
||||
const targetOptions = this._getTargetOptions(
|
||||
this.hass.states,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.target_browser"
|
||||
)
|
||||
);
|
||||
|
||||
<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.default_voice"
|
||||
)}
|
||||
.disabled=${this.savingPreferences}
|
||||
.value=${defaultVoice[1]}
|
||||
@selected=${this._handleVoiceChange}
|
||||
.options=${voices.map((voice) => ({
|
||||
value: voice.voiceId,
|
||||
label: voice.voiceName,
|
||||
}))}
|
||||
>
|
||||
</ha-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<div class="voice-id" @click=${this._copyVoiceId}>
|
||||
<div class="label">
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.account.tts.title")}
|
||||
back-path="/config/cloud/account"
|
||||
>
|
||||
<div class="content">
|
||||
<ha-card outlined>
|
||||
<div class="card-header">
|
||||
<voice-assistant-brand-icon
|
||||
.hass=${this.hass}
|
||||
.voiceAssistantId=${"conversation"}
|
||||
></voice-assistant-brand-icon>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.tts.selected_voice_id"
|
||||
"ui.panel.config.cloud.account.assist.card_title"
|
||||
)}
|
||||
</div>
|
||||
<code>${defaultVoice[1]}</code>
|
||||
${
|
||||
this.narrow
|
||||
? nothing
|
||||
: html`
|
||||
<ha-icon-button
|
||||
.path=${mdiContentCopy}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.media-browser.tts.copy_voice_id"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="flex"></div>
|
||||
<ha-button appearance="plain" @click=${this._openTryDialog}>
|
||||
${this.hass.localize("ui.panel.config.cloud.account.tts.try")}
|
||||
</ha-button>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.assist.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://www.home-assistant.io/voice_control/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.assist.link_learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
href="/config/voice-assistants/assistants"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.assist.configure"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.card_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.default_language"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.default_language_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-language-picker
|
||||
slot="end"
|
||||
.hass=${this.hass}
|
||||
.label=${""}
|
||||
.disabled=${this.savingPreferences}
|
||||
.value=${defaultVoice[0]}
|
||||
.languages=${languages}
|
||||
noClearButton
|
||||
@value-changed=${this._handleLanguageChange}
|
||||
>
|
||||
</ha-language-picker>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.default_voice"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.default_voice_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-select
|
||||
slot="end"
|
||||
.disabled=${this.savingPreferences}
|
||||
.value=${defaultVoice[1]}
|
||||
@selected=${this._handleVoiceChange}
|
||||
.options=${voices.map((voice) => ({
|
||||
value: voice.voiceId,
|
||||
label: voice.voiceName,
|
||||
}))}
|
||||
>
|
||||
</ha-select>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.tts.selected_voice_id"
|
||||
)}
|
||||
</span>
|
||||
<code slot="supporting-text">${defaultVoice[1]}</code>
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
.path=${mdiContentCopy}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.media-browser.tts.copy_voice_id"
|
||||
)}
|
||||
@click=${this._copyVoiceId}
|
||||
></ha-icon-button>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
<div class="try-tts">
|
||||
<span class="try-heading">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.header"
|
||||
)}
|
||||
</span>
|
||||
<ha-textarea
|
||||
id="message"
|
||||
resize="auto"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.message"
|
||||
)}
|
||||
.value=${
|
||||
this._message ||
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.example_message",
|
||||
{ name: this.hass.user!.name }
|
||||
)
|
||||
}
|
||||
></ha-textarea>
|
||||
<ha-select
|
||||
id="target"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.target"
|
||||
)}
|
||||
.value=${target}
|
||||
@selected=${this._handleTargetChanged}
|
||||
.options=${targetOptions}
|
||||
></ha-select>
|
||||
<div class="try-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
.disabled=${target === "browser"}
|
||||
@click=${this._createAutomation}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiRobot}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.create_automation"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
@click=${this._playExample}
|
||||
.disabled=${this._loadingExample}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPlayCircleOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.play"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://support.nabucasa.com/hc/en-us/articles/25619386304541"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.link_learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.stt.card_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.stt.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://support.nabucasa.com/hc/en-us/articles/29718084245149"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.stt.link_learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-tip .hass=${this.hass}>
|
||||
${this.hass.localize("ui.panel.config.cloud.account.tts.tip")}
|
||||
</ha-tip>
|
||||
</div>
|
||||
</ha-card>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -151,9 +336,113 @@ export class CloudTTSPref extends LitElement {
|
||||
|
||||
private getSupportedVoices = memoizeOne(getCloudTtsSupportedVoices);
|
||||
|
||||
private _openTryDialog() {
|
||||
showTryTtsDialog(this, {
|
||||
defaultVoice: this.cloudStatus!.prefs.tts_default_voice,
|
||||
// Memoized on hass.states identity: rebuilt when states change (so a media
|
||||
// player rename is picked up) and reused across self-triggered re-renders
|
||||
// (target selection, saving, loading example) where states is unchanged.
|
||||
private _getTargetOptions = memoizeOne(
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
browserLabel: string
|
||||
): HaSelectOption[] => [
|
||||
{ value: "browser", label: browserLabel },
|
||||
...Object.values(states)
|
||||
.filter(
|
||||
(entity) =>
|
||||
computeStateDomain(entity) === "media_player" &&
|
||||
supportsFeature(entity, MediaPlayerEntityFeature.PLAY_MEDIA)
|
||||
)
|
||||
.map((entity) => ({
|
||||
value: entity.entity_id,
|
||||
label: computeStateName(entity),
|
||||
})),
|
||||
]
|
||||
);
|
||||
|
||||
private async _copyVoiceId(ev: Event) {
|
||||
ev.preventDefault();
|
||||
await copyToClipboard(this.cloudStatus!.prefs.tts_default_voice[1]);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTargetChanged(ev: HaSelectSelectEvent) {
|
||||
this._target = ev.detail.value;
|
||||
this.requestUpdate("_target");
|
||||
}
|
||||
|
||||
private async _playExample() {
|
||||
const message = this._messageInput?.value;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
this._message = message;
|
||||
|
||||
const target = this._target || "browser";
|
||||
if (target === "browser") {
|
||||
// The audio element must be created + played from a user action (iOS).
|
||||
const audio = new Audio();
|
||||
audio.play();
|
||||
this._playBrowser(message, audio);
|
||||
} else {
|
||||
this.hass.callService("tts", "cloud_say", {
|
||||
entity_id: target,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _createAutomation() {
|
||||
const message = this._messageInput!.value!;
|
||||
this._message = message;
|
||||
showAutomationEditor({
|
||||
action: [
|
||||
{
|
||||
service: "tts.cloud_say",
|
||||
data: { entity_id: this._target, message },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async _playBrowser(message: string, audio: HTMLAudioElement) {
|
||||
this._loadingExample = true;
|
||||
const [language, voice] = this.cloudStatus!.prefs.tts_default_voice;
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
const result = await convertTextToSpeech(this.hass, {
|
||||
platform: "cloud",
|
||||
message,
|
||||
language,
|
||||
options: { voice },
|
||||
});
|
||||
url = result.path;
|
||||
} catch (err: any) {
|
||||
this._loadingExample = false;
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.unable_load_example",
|
||||
{ error: err.error || err.body || err }
|
||||
),
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
audio.src = url;
|
||||
audio.addEventListener("canplaythrough", () => {
|
||||
audio.play();
|
||||
});
|
||||
audio.addEventListener("playing", () => {
|
||||
this._loadingExample = false;
|
||||
});
|
||||
audio.addEventListener("error", () => {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.error_playing_audio"
|
||||
),
|
||||
});
|
||||
this._loadingExample = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,62 +499,77 @@ export class CloudTTSPref extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _copyVoiceId(ev) {
|
||||
ev.preventDefault();
|
||||
await copyToClipboard(this.cloudStatus!.prefs.tts_default_voice[1]);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.example {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
inset-inline-end: 16px;
|
||||
inset-inline-start: initial;
|
||||
top: 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.row > * {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
code {
|
||||
margin-left: 6px;
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
.voice-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 14px;
|
||||
--ha-icon-button-size: 24px;
|
||||
}
|
||||
:host([narrow]) .voice-id {
|
||||
flex-direction: column;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
align-items: start;
|
||||
align-items: left;
|
||||
}
|
||||
:host([narrow]) .label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
:host([narrow]) code {
|
||||
margin-left: 0;
|
||||
}
|
||||
.flex {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
css`
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
.card-content {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.card-content p {
|
||||
color: var(--secondary-text-color);
|
||||
padding-inline: var(--ha-space-4);
|
||||
margin: 0;
|
||||
}
|
||||
.try-tts {
|
||||
padding-inline: var(--ha-space-4);
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
.try-heading {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.try-tts ha-textarea,
|
||||
.try-tts ha-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.try-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--ha-space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
ha-md-list {
|
||||
background: none;
|
||||
--md-list-item-leading-space: var(--ha-space-4);
|
||||
--md-list-item-trailing-space: var(--ha-space-4);
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
ha-language-picker,
|
||||
ha-select {
|
||||
min-width: 210px;
|
||||
}
|
||||
@media all and (max-width: 450px) {
|
||||
ha-language-picker,
|
||||
ha-select {
|
||||
min-width: 160px;
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
ha-tip {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
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 "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-spinner";
|
||||
@@ -10,20 +11,26 @@ import "../../../../components/item/ha-row-item";
|
||||
import type { CloudStatusLoggedIn, CloudWebhook } from "../../../../data/cloud";
|
||||
import { createCloudhook, deleteCloudhook } from "../../../../data/cloud";
|
||||
import type { Webhook, WebhookError } from "../../../../data/webhook";
|
||||
import { fetchWebhooks } from "../../../../data/webhook";
|
||||
import { fetchWebhooks, isActiveCloudWebhook } from "../../../../data/webhook";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showManageCloudhookDialog } from "../dialog-manage-cloudhook/show-dialog-manage-cloudhook";
|
||||
import { cloudSubpageStyle } from "./cloud-subpage-style";
|
||||
|
||||
@customElement("cloud-webhooks")
|
||||
export class CloudWebhooks extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
|
||||
|
||||
@state() private _cloudHooks?: Record<string, CloudWebhook>;
|
||||
|
||||
@state() private _localHooks?: Webhook[];
|
||||
@state() private _customHooks?: Webhook[];
|
||||
|
||||
@state() private _mobileHooks?: Webhook[];
|
||||
|
||||
@state() private _progress: string[] = [];
|
||||
|
||||
@@ -34,102 +41,152 @@ export class CloudWebhooks extends LitElement {
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass!.localize(
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.title"
|
||||
)}
|
||||
back-path="/config/cloud/account"
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass!.localize("ui.panel.config.cloud.account.webhooks.info")}
|
||||
${
|
||||
!this.cloudStatus ||
|
||||
!this._localHooks ||
|
||||
!this._cloudHooks ||
|
||||
!this.hass
|
||||
? html`
|
||||
<div class="body-text">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.loading"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: this._localHooks.length === 0
|
||||
? html`
|
||||
<div class="body-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet"
|
||||
)}
|
||||
<a href="/config/integrations"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_integration"
|
||||
<div class="content">
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.info"
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
!this.cloudStatus ||
|
||||
!this._customHooks ||
|
||||
!this._mobileHooks ||
|
||||
!this._cloudHooks ||
|
||||
!this.hass
|
||||
? html`
|
||||
<div class="body-text">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.loading"
|
||||
)}
|
||||
</a>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet2"
|
||||
)}
|
||||
<a href="/config/automation/edit/new"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_automation"
|
||||
)}</a
|
||||
>.
|
||||
</div>
|
||||
`
|
||||
: this._localHooks.map(
|
||||
(entry) => html`
|
||||
<ha-row-item .entry=${entry}>
|
||||
<span slot="headline"
|
||||
>${entry.name}
|
||||
${
|
||||
entry.domain !== entry.name.toLowerCase()
|
||||
? ` (${entry.domain})`
|
||||
: ""
|
||||
}</span
|
||||
>
|
||||
<span slot="supporting-text">${entry.webhook_id}</span>
|
||||
${
|
||||
this._progress.includes(entry.webhook_id)
|
||||
? html`
|
||||
<div class="progress" slot="end">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`
|
||||
: this._cloudHooks![entry.webhook_id]
|
||||
? html`
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._handleManageButton}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.manage"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: html`<ha-switch
|
||||
slot="end"
|
||||
@click=${this._enableWebhook}
|
||||
>
|
||||
</ha-switch>`
|
||||
}
|
||||
</ha-row-item>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
: this._customHooks.length === 0 && !this._mobileHooks.length
|
||||
? html`
|
||||
<div class="body-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet"
|
||||
)}
|
||||
<a href="/config/integrations"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_integration"
|
||||
)}
|
||||
</a>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet2"
|
||||
)}
|
||||
<a href="/config/automation/edit/new"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_automation"
|
||||
)}</a
|
||||
>.
|
||||
</div>
|
||||
`
|
||||
: this._customHooks.map((entry) =>
|
||||
this._renderHookRow(entry)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://www.nabucasa.com/config/webhooks"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.link_learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
${
|
||||
this._mobileHooks && this._mobileHooks.length
|
||||
? html`
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.companion_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.companion_info"
|
||||
)}
|
||||
</p>
|
||||
${this._mobileHooks.map((entry) =>
|
||||
this._renderHookRow(entry)
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="footer">
|
||||
<a
|
||||
href="https://www.nabucasa.com/config/webhooks"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.link_learn_more"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ha-card
|
||||
outlined
|
||||
header=${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.about_title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.about_info"
|
||||
)}
|
||||
</p>
|
||||
<ul class="examples">
|
||||
<li>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.example_camera"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.example_location"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.example_ifttt"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.example_nfc"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.example_sms"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="note">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.about_note"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</ha-card>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -140,10 +197,59 @@ export class CloudWebhooks extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _renderHookRow(entry: Webhook) {
|
||||
return html`
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${entry.name}
|
||||
${
|
||||
entry.domain !== entry.name.toLowerCase()
|
||||
? ` (${entry.domain})`
|
||||
: ""
|
||||
}</span
|
||||
>
|
||||
<span slot="supporting-text">${entry.webhook_id}</span>
|
||||
${
|
||||
this._progress.includes(entry.webhook_id)
|
||||
? html`
|
||||
<div class="progress" slot="end">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`
|
||||
: this._cloudHooks?.[entry.webhook_id]
|
||||
? html`
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
data-webhook-id=${entry.webhook_id}
|
||||
@click=${this._handleManageButton}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.cloud.account.webhooks.manage"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-switch
|
||||
slot="end"
|
||||
data-webhook-id=${entry.webhook_id}
|
||||
@click=${this._enableWebhook}
|
||||
></ha-switch>
|
||||
`
|
||||
}
|
||||
</ha-row-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _showDialog(webhookId: string) {
|
||||
const webhook = this._localHooks!.find(
|
||||
(ent) => ent.webhook_id === webhookId
|
||||
)!;
|
||||
const webhook = [
|
||||
...(this._customHooks ?? []),
|
||||
...(this._mobileHooks ?? []),
|
||||
].find((ent) => ent.webhook_id === webhookId);
|
||||
if (!webhook) {
|
||||
return;
|
||||
}
|
||||
const cloudhook = this._cloudHooks![webhookId];
|
||||
showManageCloudhookDialog(this, {
|
||||
webhook,
|
||||
@@ -152,33 +258,40 @@ export class CloudWebhooks extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleManageButton(ev: MouseEvent) {
|
||||
const entry = (ev.currentTarget as any).parentElement.entry as Webhook;
|
||||
this._showDialog(entry.webhook_id);
|
||||
private _handleManageButton(ev: Event) {
|
||||
const webhookId = (ev.currentTarget as HTMLElement).dataset.webhookId!;
|
||||
this._showDialog(webhookId);
|
||||
}
|
||||
|
||||
private async _enableWebhook(ev: MouseEvent) {
|
||||
const entry = (ev.currentTarget as any).parentElement!.entry as Webhook;
|
||||
this._progress = [...this._progress, entry.webhook_id];
|
||||
private async _enableWebhook(ev: Event) {
|
||||
const webhookId = (ev.currentTarget as HTMLElement).dataset.webhookId!;
|
||||
if (this._progress.includes(webhookId)) {
|
||||
return;
|
||||
}
|
||||
this._progress = [...this._progress, webhookId];
|
||||
let updatedWebhook;
|
||||
|
||||
try {
|
||||
updatedWebhook = await createCloudhook(this.hass!, entry.webhook_id);
|
||||
updatedWebhook = await createCloudhook(this.hass!, webhookId);
|
||||
} catch (err: any) {
|
||||
alert((err as WebhookError).message);
|
||||
return;
|
||||
} finally {
|
||||
this._progress = this._progress.filter((wid) => wid !== entry.webhook_id);
|
||||
this._progress = this._progress.filter((wid) => wid !== webhookId);
|
||||
}
|
||||
|
||||
this._cloudHooks = {
|
||||
...this._cloudHooks,
|
||||
[entry.webhook_id]: updatedWebhook,
|
||||
[webhookId]: updatedWebhook,
|
||||
};
|
||||
|
||||
// Refresh the shared cloud status so the account overview's active-webhook
|
||||
// count reflects the new cloudhook (it reads cloudStatus.prefs.cloudhooks).
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
|
||||
// Only open dialog if we're not also enabling others, otherwise it's confusing
|
||||
if (this._progress.length === 0) {
|
||||
this._showDialog(entry.webhook_id);
|
||||
this._showDialog(webhookId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,48 +313,61 @@ export class CloudWebhooks extends LitElement {
|
||||
// Remove cloud related parts from entry.
|
||||
const { [webhookId]: disabledHook, ...newHooks } = this._cloudHooks!;
|
||||
this._cloudHooks = newHooks;
|
||||
|
||||
// Keep the shared cloud status (and the overview's active-webhook count) in
|
||||
// sync now that this cloudhook is gone.
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
|
||||
private async _fetchData() {
|
||||
if (!isComponentLoaded(this.hass!.config, "webhook")) {
|
||||
this._localHooks = [];
|
||||
this._customHooks = [];
|
||||
this._mobileHooks = [];
|
||||
return;
|
||||
}
|
||||
const hooks = await fetchWebhooks(this.hass!);
|
||||
this._localHooks = hooks.filter(
|
||||
(hook) =>
|
||||
// Only hooks that are not limited to local requests are relevant
|
||||
!hook.local_only &&
|
||||
// Deleted webhooks -> nobody cares :)
|
||||
(hook.domain !== "mobile_app" || hook.name !== "Deleted Webhook")
|
||||
);
|
||||
const relevant = hooks.filter(isActiveCloudWebhook);
|
||||
|
||||
// Mobile app webhooks are created automatically and shown in their own card.
|
||||
this._customHooks = relevant.filter((hook) => hook.domain !== "mobile_app");
|
||||
this._mobileHooks = relevant.filter((hook) => hook.domain === "mobile_app");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
css`
|
||||
.body-text {
|
||||
padding: 8px 0;
|
||||
.card-content p {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.webhook {
|
||||
display: flex;
|
||||
padding: 4px 0;
|
||||
.body-text {
|
||||
padding: var(--ha-space-2) 0;
|
||||
}
|
||||
.body-text a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.examples {
|
||||
color: var(--secondary-text-color);
|
||||
padding-inline-start: var(--ha-space-5);
|
||||
}
|
||||
.examples li {
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.note {
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.progress {
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-right: var(--ha-space-4);
|
||||
margin-inline-end: var(--ha-space-4);
|
||||
margin-inline-start: initial;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.footer {
|
||||
padding-top: 16px;
|
||||
}
|
||||
.body-text a,
|
||||
.footer a {
|
||||
color: var(--primary-color);
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
ha-row-item {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
import {
|
||||
mdiBackupRestore,
|
||||
mdiCheck,
|
||||
mdiCheckCircle,
|
||||
mdiEarth,
|
||||
mdiGoogleAssistant,
|
||||
mdiMicrophone,
|
||||
mdiMicrophoneMessage,
|
||||
mdiVideo,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import "../../../../components/ha-logo-svg";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { BackupConfig } from "../../../../data/backup";
|
||||
import {
|
||||
CLOUD_AGENT,
|
||||
cloudBackupEnabled,
|
||||
fetchBackupConfig,
|
||||
updateBackupConfig,
|
||||
} from "../../../../data/backup";
|
||||
import type { CloudOnboardingItem } from "../../../../data/cloud";
|
||||
import {
|
||||
connectCloudRemote,
|
||||
disconnectCloudRemote,
|
||||
fetchCloudStatus,
|
||||
ONBOARDING_ITEMS,
|
||||
updateCloudPref,
|
||||
} from "../../../../data/cloud";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { onboardingPanelCompleted } from "./cloud-account-status";
|
||||
import type { CloudOnboardingDialogParams } from "./show-dialog-cloud-onboarding";
|
||||
|
||||
@customElement("dialog-cloud-onboarding")
|
||||
export class DialogCloudOnboarding extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: CloudOnboardingDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _openPanel?: string;
|
||||
|
||||
// Kept in sync locally (re-fetched after each toggle) so the panel labels and
|
||||
// completion ticks stay live while the dialog is open, independent of the
|
||||
// page behind it.
|
||||
@state() private _cloudStatus?: CloudOnboardingDialogParams["cloudStatus"];
|
||||
|
||||
@state() private _backupConfig?: BackupConfig;
|
||||
|
||||
public showDialog(params: CloudOnboardingDialogParams): void {
|
||||
this._params = params;
|
||||
this._cloudStatus = params.cloudStatus;
|
||||
this._backupConfig = params.backupConfig;
|
||||
this._openPanel = this._firstIncompletePanel();
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
this._cloudStatus = undefined;
|
||||
this._backupConfig = undefined;
|
||||
this._openPanel = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this._cloudStatus) {
|
||||
return nothing;
|
||||
}
|
||||
const cloudStatus = this._cloudStatus;
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.dialog_title"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<p class="muted setup-intro">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.dialog_intro"
|
||||
)}
|
||||
</p>
|
||||
<div class="setup-dialog">
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
data-panel="remote"
|
||||
.expanded=${this._openPanel === "remote"}
|
||||
@expanded-changed=${this._panelExpanded}
|
||||
>
|
||||
${this._panelHeader(
|
||||
"remote",
|
||||
mdiEarth,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.remote_title"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.remote_helper"
|
||||
)
|
||||
)}
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.remote_body"
|
||||
)}
|
||||
</p>
|
||||
<ha-button appearance="plain" @click=${this._toggleRemote}>
|
||||
${
|
||||
cloudStatus.prefs.remote_enabled
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.turn_off"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.turn_on"
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
data-panel="backup"
|
||||
.expanded=${this._openPanel === "backup"}
|
||||
@expanded-changed=${this._panelExpanded}
|
||||
>
|
||||
${this._panelHeader(
|
||||
"backup",
|
||||
mdiBackupRestore,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.backup_title"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.backup_helper"
|
||||
)
|
||||
)}
|
||||
<div class="panel-body">
|
||||
${
|
||||
!this._backupConfig?.automatic_backups_configured
|
||||
? html`
|
||||
<p class="muted">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.backup_none_body"
|
||||
)}
|
||||
</p>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="/config/backup?historyBack=1"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.backup_set_up"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<p class="muted">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.backup_body"
|
||||
)}
|
||||
</p>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._toggleCloudBackup}
|
||||
>
|
||||
${
|
||||
this._cloudBackupEnabled
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.turn_off"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.turn_on"
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
data-panel="voice"
|
||||
.expanded=${this._openPanel === "voice"}
|
||||
@expanded-changed=${this._panelExpanded}
|
||||
>
|
||||
${this._panelHeader(
|
||||
"voice",
|
||||
mdiMicrophoneMessage,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.voice_title"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.voice_helper"
|
||||
)
|
||||
)}
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.voice_body"
|
||||
)}
|
||||
</p>
|
||||
${this._voiceCloudCard()}
|
||||
${this._assistantCard(
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.alexa_name"
|
||||
),
|
||||
mdiMicrophone,
|
||||
"alexa",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.alexa_tagline"
|
||||
),
|
||||
[
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.alexa_bullet1"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.alexa_bullet2"
|
||||
),
|
||||
],
|
||||
cloudStatus.alexa_registered
|
||||
)}
|
||||
${this._assistantCard(
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.google_name"
|
||||
),
|
||||
mdiGoogleAssistant,
|
||||
"google",
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.google_tagline"
|
||||
),
|
||||
[
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.google_bullet1"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.google_bullet2"
|
||||
),
|
||||
],
|
||||
cloudStatus.google_registered
|
||||
)}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
data-panel="streaming"
|
||||
.expanded=${this._openPanel === "streaming"}
|
||||
@expanded-changed=${this._panelExpanded}
|
||||
>
|
||||
${this._panelHeader(
|
||||
"streaming",
|
||||
mdiVideo,
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.streaming_title"
|
||||
),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.streaming_helper"
|
||||
)
|
||||
)}
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.streaming_body"
|
||||
)}
|
||||
</p>
|
||||
<ha-button appearance="plain" @click=${this._toggleWebrtc}>
|
||||
${
|
||||
cloudStatus.prefs.cloud_ice_servers_enabled
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.turn_off"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.turn_on"
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" @click=${this.closeDialog}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.done"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _panelHeader(
|
||||
key: CloudOnboardingItem,
|
||||
icon: string,
|
||||
title: string,
|
||||
helper: string
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div slot="leading-icon" class="panel-icon ${key}">
|
||||
<ha-svg-icon .path=${icon}></ha-svg-icon>
|
||||
</div>
|
||||
<div slot="header" class="panel-header">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title">${title}</span>
|
||||
<span class="panel-sub">${helper}</span>
|
||||
</div>
|
||||
${this._statusTick(this._panelCompleted(key))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _panelCompleted(key: CloudOnboardingItem): boolean {
|
||||
return this._cloudStatus
|
||||
? onboardingPanelCompleted(key, this._cloudStatus, this._backupConfig)
|
||||
: false;
|
||||
}
|
||||
|
||||
private _statusTick(completed: boolean) {
|
||||
if (!completed) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-svg-icon class="status-tick on" .path=${mdiCheckCircle}></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
|
||||
private _panelExpanded(ev: CustomEvent<{ expanded: boolean }>) {
|
||||
const key = (ev.currentTarget as HTMLElement).getAttribute("data-panel");
|
||||
if (ev.detail.expanded) {
|
||||
this._openPanel = key ?? undefined;
|
||||
} else if (this._openPanel === key) {
|
||||
this._openPanel = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _voiceCloudCard(): TemplateResult {
|
||||
return html`
|
||||
<div class="option-card">
|
||||
<div class="option-head">
|
||||
<div class="option-icon cloud">
|
||||
<ha-logo-svg></ha-logo-svg>
|
||||
</div>
|
||||
<div class="option-heading">
|
||||
<span class="option-title"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.ha_cloud_title"
|
||||
)}</span
|
||||
>
|
||||
<span class="option-sub">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.ha_cloud_sub"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="pill active"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.included"
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="option-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="https://www.home-assistant.io/voice_control/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.learn_more"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _assistantCard(
|
||||
name: string,
|
||||
icon: string,
|
||||
colorKey: string,
|
||||
tagline: string,
|
||||
bullets: string[],
|
||||
linked: boolean
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="option-card">
|
||||
<div class="option-head">
|
||||
<div class="option-icon ${colorKey}">
|
||||
<ha-svg-icon .path=${icon}></ha-svg-icon>
|
||||
</div>
|
||||
<div class="option-heading">
|
||||
<span class="option-title">${name}</span>
|
||||
<span class="option-sub">${tagline}</span>
|
||||
</div>
|
||||
${
|
||||
linked
|
||||
? html`
|
||||
<span class="pill active"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.active"
|
||||
)}</span
|
||||
>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
linked
|
||||
? html`
|
||||
<div class="option-actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href="/config/voice-assistants/assistants?historyBack=1"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.manage"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<ul class="option-bullets">
|
||||
${bullets.map(
|
||||
(bullet) => html`
|
||||
<li>
|
||||
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>${bullet}
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
<div class="option-actions">
|
||||
<ha-button
|
||||
href="/config/voice-assistants/assistants?historyBack=1"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.onboarding.set_up",
|
||||
{ name }
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private get _cloudBackupEnabled(): boolean {
|
||||
return cloudBackupEnabled(this._backupConfig);
|
||||
}
|
||||
|
||||
private _firstIncompletePanel(): CloudOnboardingItem | undefined {
|
||||
return ONBOARDING_ITEMS.find((key) => !this._panelCompleted(key));
|
||||
}
|
||||
|
||||
private async _refreshStatus() {
|
||||
const status = await fetchCloudStatus(this.hass);
|
||||
if (status.logged_in) {
|
||||
this._cloudStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
private async _refreshBackupConfig() {
|
||||
if (!isComponentLoaded(this.hass.config, "backup")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await fetchBackupConfig(this.hass);
|
||||
this._backupConfig = result.config;
|
||||
} catch {
|
||||
// Best effort; keep the last known config.
|
||||
}
|
||||
}
|
||||
|
||||
private async _toggleRemote() {
|
||||
if (!this._cloudStatus) {
|
||||
return;
|
||||
}
|
||||
const enable = !this._cloudStatus.prefs.remote_enabled;
|
||||
try {
|
||||
if (enable) {
|
||||
await connectCloudRemote(this.hass);
|
||||
} else {
|
||||
await disconnectCloudRemote(this.hass);
|
||||
}
|
||||
await this._refreshStatus();
|
||||
this._params?.onChanged?.();
|
||||
} catch (err: any) {
|
||||
showToast(this, { message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
private async _toggleWebrtc() {
|
||||
if (!this._cloudStatus) {
|
||||
return;
|
||||
}
|
||||
const enable = !this._cloudStatus.prefs.cloud_ice_servers_enabled;
|
||||
try {
|
||||
await updateCloudPref(this.hass, {
|
||||
cloud_ice_servers_enabled: enable,
|
||||
});
|
||||
await this._refreshStatus();
|
||||
this._params?.onChanged?.();
|
||||
} catch (err: any) {
|
||||
showToast(this, { message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
private async _toggleCloudBackup() {
|
||||
const config = this._backupConfig;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.automatic_backups_configured) {
|
||||
this.closeDialog();
|
||||
navigate("/config/backup?historyBack=1");
|
||||
return;
|
||||
}
|
||||
|
||||
const agentIds = this._cloudBackupEnabled
|
||||
? config.create_backup.agent_ids.filter((id) => id !== CLOUD_AGENT)
|
||||
: [...config.create_backup.agent_ids, CLOUD_AGENT];
|
||||
try {
|
||||
await updateBackupConfig(this.hass, {
|
||||
create_backup: { agent_ids: agentIds },
|
||||
});
|
||||
await this._refreshBackupConfig();
|
||||
this._params?.onChanged?.();
|
||||
} catch (err: any) {
|
||||
showToast(this, { message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
.muted {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
p.muted {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
.setup-intro.muted {
|
||||
margin-bottom: var(--ha-space-6);
|
||||
}
|
||||
.setup-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
.setup-dialog ha-expansion-panel {
|
||||
--expansion-panel-summary-padding: var(--ha-space-3) var(--ha-space-4);
|
||||
}
|
||||
.panel-header {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.panel-sub {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
.status-tick {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-tick.on {
|
||||
color: var(--success-color);
|
||||
}
|
||||
.panel-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
--mdc-icon-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-icon.remote {
|
||||
color: var(--blue-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--blue-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.panel-icon.backup {
|
||||
color: var(--green-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--green-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.panel-icon.voice {
|
||||
color: var(--purple-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--purple-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.panel-icon.streaming {
|
||||
color: var(--cyan-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--cyan-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.panel-body {
|
||||
padding: var(--ha-space-2) var(--ha-space-5) var(--ha-space-5)
|
||||
var(--ha-space-14);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
.panel-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.panel-body > ha-button {
|
||||
align-self: flex-start;
|
||||
--ha-button-padding-inline: 0;
|
||||
}
|
||||
.option-card {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-lg, 12px);
|
||||
padding: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
.option-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
.option-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
--mdc-icon-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.option-icon.cloud {
|
||||
color: var(--primary-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--primary-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.option-icon.alexa {
|
||||
color: var(--cyan-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--cyan-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.option-icon.google {
|
||||
color: var(--blue-color);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--blue-color) 15%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.option-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-title {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.option-sub {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.pill {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
padding: 2px var(--ha-space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--ha-font-size-s);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.active {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--primary-text-color) 8%,
|
||||
transparent
|
||||
);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.option-bullets {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.option-bullets li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.option-bullets ha-svg-icon {
|
||||
color: var(--success-color);
|
||||
--mdc-icon-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.option-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-cloud-onboarding": DialogCloudOnboarding;
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { mdiPlayCircleOutline, mdiRobot } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../../../../common/entity/supports-feature";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-select";
|
||||
import type {
|
||||
HaSelectOption,
|
||||
HaSelectSelectEvent,
|
||||
} from "../../../../components/ha-select";
|
||||
import "../../../../components/ha-textarea";
|
||||
import type { HaTextArea } from "../../../../components/ha-textarea";
|
||||
import "../../../../components/ha-dialog";
|
||||
import { showAutomationEditor } from "../../../../data/automation";
|
||||
import { MediaPlayerEntityFeature } from "../../../../data/media-player";
|
||||
import { convertTextToSpeech } from "../../../../data/tts";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { TryTtsDialogParams } from "./show-dialog-cloud-tts-try";
|
||||
|
||||
@customElement("dialog-cloud-try-tts")
|
||||
export class DialogTryTts extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _loadingExample = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _params?: TryTtsDialogParams;
|
||||
|
||||
@query("#message") private _messageInput?: HaTextArea;
|
||||
|
||||
@storage({
|
||||
key: "cloudTtsTryMessage",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _message!: string;
|
||||
|
||||
@storage({
|
||||
key: "cloudTtsTryTarget",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _target!: string;
|
||||
|
||||
public showDialog(params: TryTtsDialogParams) {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
const target = this._target || "browser";
|
||||
|
||||
const targetOptions: HaSelectOption[] = Object.values(this.hass.states)
|
||||
.filter(
|
||||
(entity) =>
|
||||
computeStateDomain(entity) === "media_player" &&
|
||||
supportsFeature(entity, MediaPlayerEntityFeature.PLAY_MEDIA)
|
||||
)
|
||||
.map((entity) => ({
|
||||
value: entity.entity_id,
|
||||
label: computeStateName(entity),
|
||||
}));
|
||||
|
||||
targetOptions.unshift({
|
||||
value: "browser",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.target_browser"
|
||||
),
|
||||
});
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.header"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div>
|
||||
<ha-textarea
|
||||
resize="auto"
|
||||
id="message"
|
||||
autofocus
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.message"
|
||||
)}
|
||||
.value=${
|
||||
this._message ||
|
||||
this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.example_message",
|
||||
{ name: this.hass.user!.name }
|
||||
)
|
||||
}
|
||||
>
|
||||
</ha-textarea>
|
||||
|
||||
<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.target"
|
||||
)}
|
||||
id="target"
|
||||
.value=${target}
|
||||
@selected=${this._handleTargetChanged}
|
||||
.options=${targetOptions}
|
||||
>
|
||||
</ha-select>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
.disabled=${target === "browser"}
|
||||
@click=${this._createAutomation}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiRobot}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.create_automation"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._playExample}
|
||||
.disabled=${this._loadingExample}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPlayCircleOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.tts.dialog.play"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleTargetChanged(ev: HaSelectSelectEvent) {
|
||||
this._target = ev.detail.value;
|
||||
this.requestUpdate("_target");
|
||||
}
|
||||
|
||||
private async _playExample() {
|
||||
const message = this._messageInput?.value;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
this._message = message;
|
||||
|
||||
const target = this._target || "browser";
|
||||
if (target === "browser") {
|
||||
// We create the audio element here + do a play, because iOS requires it to be done by user action
|
||||
const audio = new Audio();
|
||||
audio.play();
|
||||
this._playBrowser(message, audio);
|
||||
} else {
|
||||
this.hass.callService("tts", "cloud_say", {
|
||||
entity_id: target,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _createAutomation() {
|
||||
const message = this._messageInput!.value!;
|
||||
this._message = message;
|
||||
showAutomationEditor({
|
||||
action: [
|
||||
{
|
||||
service: "tts.cloud_say",
|
||||
data: {
|
||||
entity_id: this._target,
|
||||
message: message,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private async _playBrowser(message: string, audio: HTMLAudioElement) {
|
||||
this._loadingExample = true;
|
||||
|
||||
const language = this._params!.defaultVoice[0];
|
||||
const voice = this._params!.defaultVoice[1];
|
||||
|
||||
let url;
|
||||
try {
|
||||
const result = await convertTextToSpeech(this.hass, {
|
||||
platform: "cloud",
|
||||
message,
|
||||
language,
|
||||
options: { voice },
|
||||
});
|
||||
url = result.path;
|
||||
} catch (err: any) {
|
||||
this._loadingExample = false;
|
||||
showAlertDialog(this, {
|
||||
text: `Unable to load example. ${err.error || err.body || err}`,
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
audio.src = url;
|
||||
audio.addEventListener("canplaythrough", () => {
|
||||
audio.play();
|
||||
});
|
||||
audio.addEventListener("playing", () => {
|
||||
this._loadingExample = false;
|
||||
});
|
||||
audio.addEventListener("error", () => {
|
||||
showAlertDialog(this, { title: "Error playing audio." });
|
||||
this._loadingExample = false;
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-textarea,
|
||||
ha-select {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-cloud-try-tts": DialogTryTts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { BackupConfig } from "../../../../data/backup";
|
||||
import type { CloudStatusLoggedIn } from "../../../../data/cloud";
|
||||
|
||||
export interface CloudOnboardingDialogParams {
|
||||
cloudStatus: CloudStatusLoggedIn;
|
||||
backupConfig?: BackupConfig;
|
||||
// Called after each successful change so the opener can refresh the page
|
||||
// behind the dialog (the dialog keeps its own copy live independently).
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
export const loadCloudOnboardingDialog = () =>
|
||||
import("./dialog-cloud-onboarding");
|
||||
|
||||
export const showCloudOnboardingDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: CloudOnboardingDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-cloud-onboarding",
|
||||
dialogImport: loadCloudOnboardingDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
|
||||
export interface TryTtsDialogParams {
|
||||
defaultVoice: [string, string];
|
||||
}
|
||||
|
||||
export const loadTryTtsDialog = () => import("./dialog-cloud-tts-try");
|
||||
|
||||
export const showTryTtsDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: TryTtsDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-cloud-try-tts",
|
||||
dialogImport: loadTryTtsDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -8,8 +8,20 @@ import type { ValueChangedEvent, HomeAssistant, Route } from "../../../types";
|
||||
import "./account/cloud-account";
|
||||
import "./login/cloud-login-panel";
|
||||
|
||||
const LOGGED_IN_URLS = ["account", "google-assistant", "alexa"];
|
||||
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"];
|
||||
const LOGGED_IN_URLS = [
|
||||
"account",
|
||||
"remote",
|
||||
"backup",
|
||||
"voice-assistants",
|
||||
"companion",
|
||||
"webrtc",
|
||||
"webhooks",
|
||||
] as const;
|
||||
|
||||
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"] as const;
|
||||
|
||||
type CloudPage =
|
||||
(typeof LOGGED_IN_URLS)[number] | (typeof NOT_LOGGED_IN_URLS)[number];
|
||||
|
||||
@customElement("ha-config-cloud")
|
||||
class HaConfigCloud extends HassRouterPage {
|
||||
@@ -30,10 +42,10 @@ class HaConfigCloud extends HassRouterPage {
|
||||
// Guard the different pages based on if we're logged in.
|
||||
beforeRender: (page: string) => {
|
||||
if (this.cloudStatus.logged_in) {
|
||||
if (!LOGGED_IN_URLS.includes(page)) {
|
||||
if (!LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "account";
|
||||
}
|
||||
} else if (!NOT_LOGGED_IN_URLS.includes(page)) {
|
||||
} else if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "login";
|
||||
}
|
||||
return undefined;
|
||||
@@ -53,7 +65,31 @@ class HaConfigCloud extends HassRouterPage {
|
||||
account: {
|
||||
tag: "cloud-account",
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
tag: "cloud-remote-pref",
|
||||
load: () => import("./account/cloud-remote-pref"),
|
||||
},
|
||||
backup: {
|
||||
tag: "cloud-backup-pref",
|
||||
load: () => import("./account/cloud-backup-pref"),
|
||||
},
|
||||
"voice-assistants": {
|
||||
tag: "cloud-tts-pref",
|
||||
load: () => import("./account/cloud-tts-pref"),
|
||||
},
|
||||
companion: {
|
||||
tag: "cloud-companion-pref",
|
||||
load: () => import("./account/cloud-companion-pref"),
|
||||
},
|
||||
webrtc: {
|
||||
tag: "cloud-ice-servers-pref",
|
||||
load: () => import("./account/cloud-ice-servers-pref"),
|
||||
},
|
||||
webhooks: {
|
||||
tag: "cloud-webhooks",
|
||||
load: () => import("./account/cloud-webhooks"),
|
||||
},
|
||||
} satisfies Record<CloudPage, RouterOptions["routes"][string]>,
|
||||
};
|
||||
|
||||
@state() private _flashMessage = "";
|
||||
@@ -105,7 +141,7 @@ class HaConfigCloud extends HassRouterPage {
|
||||
if (
|
||||
this.cloudStatus &&
|
||||
!this.cloudStatus.logged_in &&
|
||||
LOGGED_IN_URLS.includes(this._currentPage)
|
||||
LOGGED_IN_URLS.some((url) => url === this._currentPage)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -211,8 +211,8 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/developer-tools",
|
||||
translationKey: "developer_tools",
|
||||
path: "/config/tools",
|
||||
translationKey: "tools",
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
@@ -328,10 +328,10 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
developer_tools: [
|
||||
tools: [
|
||||
{
|
||||
path: "/config/developer-tools",
|
||||
translationKey: "ui.panel.config.dashboard.developer_tools.main",
|
||||
path: "/config/tools",
|
||||
translationKey: "ui.panel.config.dashboard.tools.main",
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
|
||||
@@ -70,9 +70,9 @@ class HaPanelConfig extends HassRouterPage {
|
||||
tag: "ha-config-system-navigation",
|
||||
load: () => import("./core/ha-config-system-navigation"),
|
||||
},
|
||||
"developer-tools": {
|
||||
tag: "ha-panel-developer-tools",
|
||||
load: () => import("./developer-tools/ha-panel-developer-tools"),
|
||||
tools: {
|
||||
tag: "ha-panel-tools",
|
||||
load: () => import("./tools/ha-panel-tools"),
|
||||
cache: true,
|
||||
},
|
||||
logs: {
|
||||
|
||||
@@ -372,7 +372,7 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
return html`${labelAndDescription}
|
||||
<ha-input
|
||||
type="number"
|
||||
.value=${item.value}
|
||||
.value=${item.value?.toString()}
|
||||
.min=${item.metadata.min}
|
||||
.max=${item.metadata.max}
|
||||
.property=${item.property}
|
||||
@@ -493,7 +493,7 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
if (ev.target === undefined || this._config![ev.target.key] === undefined) {
|
||||
return;
|
||||
}
|
||||
if (this._config![ev.target.key].value === value) {
|
||||
if (this._config![ev.target.key].value === Number(value)) {
|
||||
return;
|
||||
}
|
||||
this._setResult(ev.target.key, undefined);
|
||||
@@ -505,8 +505,12 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
if (ev.target === undefined || this._config![ev.target.key] === undefined) {
|
||||
return;
|
||||
}
|
||||
const value = Number(ev.target.value);
|
||||
if (Number(this._config![ev.target.key].value) === value) {
|
||||
// An empty input must not be coerced to 0 by Number()
|
||||
const value =
|
||||
ev.target.value === undefined || ev.target.value === ""
|
||||
? NaN
|
||||
: Number(ev.target.value);
|
||||
if (this._config![ev.target.key].value === value) {
|
||||
return;
|
||||
}
|
||||
if (isNaN(value)) {
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { HaForm } from "../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
const SCHEMA = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "server_port",
|
||||
required: true,
|
||||
selector: { number: { min: 1, max: 65535, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "server_host",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.ssl"),
|
||||
schema: [
|
||||
{
|
||||
name: "ssl_certificate",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_key",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_peer_certificate",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_profile",
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{
|
||||
value: "modern",
|
||||
label: localize(
|
||||
"ui.panel.config.network.http.ssl_profile_modern"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "intermediate",
|
||||
label: localize(
|
||||
"ui.panel.config.network.http.ssl_profile_intermediate"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reverse_proxy",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.reverse_proxy"),
|
||||
schema: [
|
||||
{
|
||||
name: "use_x_forwarded_for",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "trusted_proxies",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ip_banning",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.ip_banning"),
|
||||
schema: [
|
||||
{
|
||||
name: "ip_ban_enabled",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "login_attempts_threshold",
|
||||
required: true,
|
||||
selector: { number: { min: -1, max: 1000, mode: "box" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "advanced",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.advanced"),
|
||||
schema: [
|
||||
{
|
||||
name: "cors_allowed_origins",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "use_x_frame_options",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
@customElement("ha-config-http-form")
|
||||
class HaConfigHttpForm extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _stable?: HttpConfig;
|
||||
|
||||
@state() private _config?: HttpConfig;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _fieldErrors: Record<string, string> = {};
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
@state() private _showNoChanges = false;
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
|
||||
@query("ha-alert") private _firstAlert?: HTMLElement;
|
||||
|
||||
private _onConfigResolved = () => this._fetchConfig();
|
||||
|
||||
protected override firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._fetchConfig();
|
||||
}
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("http-config-resolved", this._onConfigResolved);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("http-config-resolved", this._onConfigResolved);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._stable && !this._error) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = SCHEMA(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("ui.panel.config.network.http.caption")}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._showNoChanges
|
||||
? html`
|
||||
<ha-alert alert-type="success">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.save_no_changes"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._config
|
||||
? html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${schema}
|
||||
.error=${this._fieldErrors}
|
||||
.disabled=${this._saving}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._config
|
||||
? html`
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${this._saving}
|
||||
.loading=${this._saving}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.network.http.save")}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
// Pending is exclusively handled by the global confirm/revert dialog, so
|
||||
// the form only ever displays stable.
|
||||
const { stable } = await fetchHttpConfig(this.hass);
|
||||
this._stable = stable;
|
||||
this._config = { ...stable };
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
// Expandable sections render their own title; never label them.
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${schema.name}` as any
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelper = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.helpers.${schema.name}` as any
|
||||
) || ""
|
||||
);
|
||||
};
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._config = ev.detail.value;
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._config || !this._stable) {
|
||||
return;
|
||||
}
|
||||
if (this._form && !this._form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (JSON.stringify(this._stable) === JSON.stringify(this._config)) {
|
||||
this._showNoChanges = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.network.http.save_confirm.title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.network.http.save_confirm.text"
|
||||
),
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.network.http.save_confirm.confirm"
|
||||
),
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._saving = true;
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
try {
|
||||
const result = await saveHttpConfig(this.hass, this._config);
|
||||
if (!result.restart) {
|
||||
this._showNoChanges = true;
|
||||
}
|
||||
// restart === true: a restart is in flight. The reply usually races with
|
||||
// the connection drop; if we do reach this branch, the disconnected
|
||||
// overlay will appear in moments. Leave the form as is.
|
||||
} catch (err: any) {
|
||||
// The restart kills the WS connection before the ack — that's expected.
|
||||
if (
|
||||
err?.error?.code === ERR_CONNECTION_LOST ||
|
||||
err === ERR_CONNECTION_LOST
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// voluptuous formats errors as "<message> @ data['<field>']".
|
||||
// If a field is identified, mark it inline; otherwise show a card-level
|
||||
// alert.
|
||||
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
|
||||
if (fieldMatch) {
|
||||
this._fieldErrors = { [fieldMatch[1]]: err.message };
|
||||
} else {
|
||||
this._error = err.message;
|
||||
}
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
await this.updateComplete;
|
||||
await this._form?.updateComplete;
|
||||
// Inline field errors render inside ha-form's shadow root, so fall back to
|
||||
// it when no top-level alert is present.
|
||||
const target =
|
||||
this._firstAlert ??
|
||||
this._form?.shadowRoot?.querySelector<HTMLElement>("ha-alert");
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.description {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-http-form": HaConfigHttpForm;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/ha-icon-next";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "./ha-config-http-form";
|
||||
import "./ha-config-network";
|
||||
import "./ha-config-url-form";
|
||||
import "./supervisor-hostname";
|
||||
@@ -42,6 +43,7 @@ class HaConfigSectionNetwork extends LitElement {
|
||||
: ""
|
||||
}
|
||||
<ha-config-url-form .hass=${this.hass}></ha-config-url-form>
|
||||
<ha-config-http-form .hass=${this.hass}></ha-config-http-form>
|
||||
<ha-config-network .hass=${this.hass}></ha-config-network>
|
||||
${
|
||||
NETWORK_BROWSERS.some((component) =>
|
||||
@@ -92,6 +94,7 @@ class HaConfigSectionNetwork extends LitElement {
|
||||
supervisor-hostname,
|
||||
supervisor-network,
|
||||
ha-config-url-form,
|
||||
ha-config-http-form,
|
||||
ha-config-network,
|
||||
.discovery-card {
|
||||
display: block;
|
||||
|
||||
@@ -147,7 +147,14 @@ class DialogRepairsIssue extends LitElement {
|
||||
}
|
||||
>
|
||||
${this.hass!.localize("ui.panel.config.repairs.dialog.learn")}
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
${
|
||||
learnMoreUrlIsHomeAssistant
|
||||
? nothing
|
||||
: html`<ha-svg-icon
|
||||
slot="end"
|
||||
.path=${mdiOpenInNew}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { fixStatisticsIssue } from "../developer-tools/statistics/fix-statistics";
|
||||
import { fixStatisticsIssue } from "../tools/statistics/fix-statistics";
|
||||
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
|
||||
import { showRepairsFlowDialog } from "./show-dialog-repair-flow";
|
||||
import { showRepairsIssueDialog } from "./show-repair-issue-dialog";
|
||||
@@ -171,7 +171,7 @@ class HaConfigRepairs extends LitElement {
|
||||
issue.translation_key &&
|
||||
STATISTIC_TYPES.includes(issue.translation_key as any)
|
||||
) {
|
||||
this.hass.loadFragmentTranslation("developer-tools");
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
const data = await fetchRepairsIssueData(
|
||||
this.hass.connection,
|
||||
issue.domain,
|
||||
|
||||
@@ -594,15 +594,30 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
...baseConfig,
|
||||
...initData,
|
||||
} as ScriptConfig;
|
||||
this._initDirtyTracking({ type: "deep" }, baseConfig as ScriptConfig);
|
||||
this._updateDirtyState(this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: baseConfig as ScriptConfig,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.readOnly = false;
|
||||
}
|
||||
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
getScriptStateConfig(this.hass, this.entityId).then((c) => {
|
||||
this.config = normalizeScriptConfig(c.config);
|
||||
this._initDirtyTracking({ type: "deep" }, this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._checkValidation();
|
||||
});
|
||||
const regEntry = this.entityRegistry?.find(
|
||||
@@ -647,7 +662,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
|
||||
this.config = ev.detail.value;
|
||||
this.errors = undefined;
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private async _runScript() {
|
||||
@@ -734,7 +752,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
}
|
||||
|
||||
this._manualEditor?.addFields();
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private _preprocessYaml() {
|
||||
@@ -749,7 +770,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
}
|
||||
this.yamlErrors = undefined;
|
||||
this.config = ev.detail.value;
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
@@ -765,7 +789,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
|
||||
const id = this.scriptId || String(Date.now());
|
||||
@@ -880,7 +907,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve(true);
|
||||
},
|
||||
@@ -899,7 +929,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
config: this.config!,
|
||||
updateConfig: (config) => {
|
||||
this.config = config;
|
||||
this._updateDirtyState(config);
|
||||
this._updateDirtyState({
|
||||
config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve();
|
||||
},
|
||||
@@ -919,9 +952,11 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
|
||||
if (!this.scriptId) {
|
||||
const saved = await this._promptScriptAlias();
|
||||
if (!saved) {
|
||||
return;
|
||||
if (!this.config?.alias) {
|
||||
const saved = await this._promptScriptAlias();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.currentEntityId = this._computeEntityIdFromAlias(this.config!.alias);
|
||||
}
|
||||
@@ -1046,7 +1081,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
private _applyUndoRedo(config: ScriptConfig) {
|
||||
this._manualEditor?.triggerCloseSidebar();
|
||||
this.config = config;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private _undo() {
|
||||
|
||||
+23
-25
@@ -48,7 +48,7 @@ import { resolveMediaSource } from "../../../../data/media_source";
|
||||
import { MatchMinHeightMixin } from "../../../../mixins/match-min-height-mixin";
|
||||
import { withViewTransition } from "../../../../common/util/view-transition";
|
||||
|
||||
@customElement("developer-tools-action")
|
||||
@customElement("tools-action")
|
||||
class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -130,14 +130,12 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
|
||||
const modeButtons: ToggleButton[] = [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.ui_mode"
|
||||
),
|
||||
label: this.hass.localize("ui.panel.config.tools.tabs.actions.ui_mode"),
|
||||
value: "ui",
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.yaml_mode"
|
||||
"ui.panel.config.tools.tabs.actions.yaml_mode"
|
||||
),
|
||||
value: "yaml",
|
||||
},
|
||||
@@ -163,7 +161,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
<div class="header-row">
|
||||
<div class="header-title">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.title"
|
||||
"ui.panel.config.tools.tabs.actions.title"
|
||||
)}
|
||||
</div>
|
||||
<ha-button-toggle-group
|
||||
@@ -177,7 +175,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
</div>
|
||||
<p class="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.description"
|
||||
"ui.panel.config.tools.tabs.actions.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -220,14 +218,14 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
!this._uiAvailable
|
||||
? html`<span class="error"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.no_template_ui_support"
|
||||
"ui.panel.config.tools.tabs.actions.no_template_ui_support"
|
||||
)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-progress-button raised @click=${this._callService}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.call_service"
|
||||
"ui.panel.config.tools.tabs.actions.call_service"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
@@ -238,7 +236,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
? html`<div class="content response">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.response"
|
||||
"ui.panel.config.tools.tabs.actions.response"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
@@ -253,7 +251,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
slot="extra-actions"
|
||||
@click=${this._copyTemplate}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.copy_clipboard_template"
|
||||
"ui.panel.config.tools.tabs.actions.copy_clipboard_template"
|
||||
)}</ha-button
|
||||
>
|
||||
</ha-yaml-editor>
|
||||
@@ -270,10 +268,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
.header=${
|
||||
this._yamlMode
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.all_parameters"
|
||||
"ui.panel.config.tools.tabs.actions.all_parameters"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.yaml_parameters"
|
||||
"ui.panel.config.tools.tabs.actions.yaml_parameters"
|
||||
)
|
||||
}
|
||||
outlined
|
||||
@@ -287,7 +285,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
target
|
||||
? html`
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.accepts_target"
|
||||
"ui.panel.config.tools.tabs.actions.accepts_target"
|
||||
)}
|
||||
`
|
||||
: ""
|
||||
@@ -322,17 +320,17 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
<tr>
|
||||
<th>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.column_parameter"
|
||||
"ui.panel.config.tools.tabs.actions.column_parameter"
|
||||
)}
|
||||
</th>
|
||||
<th>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.column_description"
|
||||
"ui.panel.config.tools.tabs.actions.column_description"
|
||||
)}
|
||||
</th>
|
||||
<th>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.column_example"
|
||||
"ui.panel.config.tools.tabs.actions.column_example"
|
||||
)}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -371,7 +369,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
appearance="plain"
|
||||
@click=${this._fillExampleData}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.fill_example_data"
|
||||
"ui.panel.config.tools.tabs.actions.fill_example_data"
|
||||
)}</ha-button
|
||||
>`
|
||||
: ""
|
||||
@@ -406,14 +404,14 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
const errorCategory = yamlMode ? "yaml" : "ui";
|
||||
if (!serviceData?.action) {
|
||||
return localize(
|
||||
`ui.panel.config.developer-tools.tabs.actions.errors.${errorCategory}.no_action`
|
||||
`ui.panel.config.tools.tabs.actions.errors.${errorCategory}.no_action`
|
||||
);
|
||||
}
|
||||
const domain = computeDomain(serviceData.action);
|
||||
const service = computeObjectId(serviceData.action);
|
||||
if (!domain || !service) {
|
||||
return localize(
|
||||
`ui.panel.config.developer-tools.tabs.actions.errors.${errorCategory}.invalid_action`
|
||||
`ui.panel.config.tools.tabs.actions.errors.${errorCategory}.invalid_action`
|
||||
);
|
||||
}
|
||||
const dataIsTemplate =
|
||||
@@ -427,7 +425,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
!serviceData.data?.area_id
|
||||
) {
|
||||
return localize(
|
||||
`ui.panel.config.developer-tools.tabs.actions.errors.${errorCategory}.no_target`
|
||||
`ui.panel.config.tools.tabs.actions.errors.${errorCategory}.no_target`
|
||||
);
|
||||
}
|
||||
for (const field of fields) {
|
||||
@@ -437,7 +435,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
(!serviceData.data || serviceData.data[field.key] === undefined)
|
||||
) {
|
||||
return localize(
|
||||
`ui.panel.config.developer-tools.tabs.actions.errors.${errorCategory}.missing_required_field`,
|
||||
`ui.panel.config.tools.tabs.actions.errors.${errorCategory}.missing_required_field`,
|
||||
{ key: field.key }
|
||||
);
|
||||
}
|
||||
@@ -496,7 +494,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
forwardHaptic(this, "failure");
|
||||
button.actionError();
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.errors.yaml.invalid_yaml"
|
||||
"ui.panel.config.tools.tabs.actions.errors.yaml.invalid_yaml"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -569,7 +567,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
rel="noreferrer"
|
||||
><ha-button>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.actions.open_media"
|
||||
"ui.panel.config.tools.tabs.actions.open_media"
|
||||
)}
|
||||
</ha-button></a
|
||||
>
|
||||
@@ -829,6 +827,6 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"developer-tools-action": HaPanelDevAction;
|
||||
"tools-action": HaPanelDevAction;
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -25,7 +25,7 @@ interface SentenceParsingResult {
|
||||
result: AssistDebugResult | null;
|
||||
}
|
||||
|
||||
@customElement("developer-tools-assist")
|
||||
@customElement("tools-assist")
|
||||
class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -118,14 +118,14 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
<div class="content">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.title"
|
||||
"ui.panel.config.tools.tabs.assist.title"
|
||||
)}
|
||||
class="form"
|
||||
>
|
||||
<div class="card-content">
|
||||
<p class="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.description"
|
||||
"ui.panel.config.tools.tabs.assist.description"
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
@@ -143,7 +143,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
<ha-textarea
|
||||
resize="auto"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.sentences"
|
||||
"ui.panel.config.tools.tabs.assist.sentences"
|
||||
)}
|
||||
id="sentences-input"
|
||||
@input=${this._textAreaInput}
|
||||
@@ -157,7 +157,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
.disabled=${!this._language || !this._validInput}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.parse_sentences"
|
||||
"ui.panel.config.tools.tabs.assist.parse_sentences"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiDownload}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.download_results"
|
||||
"ui.panel.config.tools.tabs.assist.download_results"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -204,7 +204,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
</div>
|
||||
<div class="info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.language"
|
||||
"ui.panel.config.tools.tabs.assist.language"
|
||||
)}:
|
||||
${formatLanguageCode(language, this.hass.locale)}
|
||||
(${language})
|
||||
@@ -221,7 +221,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
`
|
||||
: html`<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.no_match"
|
||||
"ui.panel.config.tools.tabs.assist.no_match"
|
||||
)}
|
||||
</ha-alert>`
|
||||
}
|
||||
@@ -304,6 +304,6 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"developer-tools-assist": HaPanelDevAssist;
|
||||
"tools-assist": HaPanelDevAssist;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -17,12 +17,12 @@ class HaDebugConnectionRow extends LitElement {
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.debug_connection.title"
|
||||
"ui.panel.config.tools.tabs.debug.debug_connection.title"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.debug_connection.description"
|
||||
"ui.panel.config.tools.tabs.debug.debug_connection.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
+2
-2
@@ -20,12 +20,12 @@ class HaDebugDisableViewTransitionRow extends LitElement {
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.disable_view_transition.title"
|
||||
"ui.panel.config.tools.tabs.debug.disable_view_transition.title"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.disable_view_transition.description"
|
||||
"ui.panel.config.tools.tabs.debug.disable_view_transition.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
+2
-2
@@ -230,13 +230,13 @@ export class HaDebugViewportEnvironmentCard extends LitElement {
|
||||
return html`
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.viewport_environment.title"
|
||||
"ui.panel.config.tools.tabs.debug.viewport_environment.title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p class="explanation">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.viewport_environment.description"
|
||||
"ui.panel.config.tools.tabs.debug.viewport_environment.description"
|
||||
)}
|
||||
</p>
|
||||
<ha-code-editor
|
||||
+6
-6
@@ -20,7 +20,7 @@ import "./ha-debug-connection-row";
|
||||
import "./ha-debug-disable-view-transition-row";
|
||||
import "./ha-debug-viewport-environment-card";
|
||||
|
||||
@customElement("developer-tools-debug")
|
||||
@customElement("tools-debug")
|
||||
class HaPanelDevDebug extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -31,7 +31,7 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
|
||||
<div class="content">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.title"
|
||||
"ui.panel.config.tools.tabs.debug.title"
|
||||
)}
|
||||
>
|
||||
<ha-list-base>
|
||||
@@ -45,13 +45,13 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
|
||||
</ha-card>
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.entity_diagnostic.title"
|
||||
"ui.panel.config.tools.tabs.debug.entity_diagnostic.title"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-entity-picker
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.entity_diagnostic.description"
|
||||
"ui.panel.config.tools.tabs.debug.entity_diagnostic.description"
|
||||
)}
|
||||
@value-changed=${this._entityPicked}
|
||||
></ha-entity-picker>
|
||||
@@ -62,7 +62,7 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
|
||||
appearance="filled"
|
||||
.disabled=${!this._entityId}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.debug.entity_diagnostic.copy_to_clipboard"
|
||||
"ui.panel.config.tools.tabs.debug.entity_diagnostic.copy_to_clipboard"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
@@ -136,6 +136,6 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"developer-tools-debug": HaPanelDevDebug;
|
||||
"tools-debug": HaPanelDevDebug;
|
||||
}
|
||||
}
|
||||
+18
-18
@@ -76,7 +76,7 @@ class EventSubscribeCard extends LitElement {
|
||||
return html`
|
||||
<ha-card
|
||||
header=${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.listen_to_events"
|
||||
"ui.panel.config.tools.tabs.events.listen_to_events"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
@@ -84,10 +84,10 @@ class EventSubscribeCard extends LitElement {
|
||||
.label=${
|
||||
this._subscribed
|
||||
? this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.listening_to"
|
||||
"ui.panel.config.tools.tabs.events.listening_to"
|
||||
)
|
||||
: this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.subscribe_to"
|
||||
"ui.panel.config.tools.tabs.events.subscribe_to"
|
||||
)
|
||||
}
|
||||
.disabled=${this._subscribed !== undefined}
|
||||
@@ -96,11 +96,11 @@ class EventSubscribeCard extends LitElement {
|
||||
></ha-input>
|
||||
<ha-input
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.filter_events"
|
||||
"ui.panel.config.tools.tabs.events.filter_events"
|
||||
)}
|
||||
.value=${this._eventFilter}
|
||||
.disabled=${this._subscribed !== undefined}
|
||||
.hint=${`${this.hass!.localize("ui.panel.config.developer-tools.tabs.events.filter_helper")}${this._ignoredEventsCount ? ` ${this.hass!.localize("ui.panel.config.developer-tools.tabs.events.filter_ignored", { count: this._ignoredEventsCount })}` : ""}`}
|
||||
.hint=${`${this.hass!.localize("ui.panel.config.tools.tabs.events.filter_helper")}${this._ignoredEventsCount ? ` ${this.hass!.localize("ui.panel.config.tools.tabs.events.filter_ignored", { count: this._ignoredEventsCount })}` : ""}`}
|
||||
@input=${this._filterChanged}
|
||||
></ha-input>
|
||||
${
|
||||
@@ -118,10 +118,10 @@ class EventSubscribeCard extends LitElement {
|
||||
${
|
||||
this._subscribed
|
||||
? this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.stop_listening"
|
||||
"ui.panel.config.tools.tabs.events.stop_listening"
|
||||
)
|
||||
: this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.start_listening"
|
||||
"ui.panel.config.tools.tabs.events.start_listening"
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
@@ -131,7 +131,7 @@ class EventSubscribeCard extends LitElement {
|
||||
@click=${this._clearEvents}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.clear_events"
|
||||
"ui.panel.config.tools.tabs.events.clear_events"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -144,10 +144,10 @@ class EventSubscribeCard extends LitElement {
|
||||
if (!this._events.length) {
|
||||
const message = this._subscribed
|
||||
? this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.waiting_for_events"
|
||||
"ui.panel.config.tools.tabs.events.waiting_for_events"
|
||||
)
|
||||
: this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.subscribe_prompt"
|
||||
"ui.panel.config.tools.tabs.events.subscribe_prompt"
|
||||
);
|
||||
return html`
|
||||
<ha-card class="events-card">
|
||||
@@ -172,7 +172,7 @@ class EventSubscribeCard extends LitElement {
|
||||
.path=${mdiChevronDoubleLeft}
|
||||
.disabled=${index >= bufferTotal - 1}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.oldest_event"
|
||||
"ui.panel.config.tools.tabs.events.oldest_event"
|
||||
)}
|
||||
@click=${this._showOldest}
|
||||
></ha-icon-button>
|
||||
@@ -180,13 +180,13 @@ class EventSubscribeCard extends LitElement {
|
||||
.path=${mdiChevronLeft}
|
||||
.disabled=${index >= bufferTotal - 1}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.older_event"
|
||||
"ui.panel.config.tools.tabs.events.older_event"
|
||||
)}
|
||||
@click=${this._showOlder}
|
||||
></ha-icon-button>
|
||||
<div class="event-info">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.event_fired",
|
||||
"ui.panel.config.tools.tabs.events.event_fired",
|
||||
{
|
||||
name: position,
|
||||
time: formatTimeWithSeconds(
|
||||
@@ -208,7 +208,7 @@ class EventSubscribeCard extends LitElement {
|
||||
<ha-tooltip for="buffer-info" placement="bottom">
|
||||
<span class="buffer-tooltip">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.buffer_disclaimer",
|
||||
"ui.panel.config.tools.tabs.events.buffer_disclaimer",
|
||||
{ count: MAX_BUFFERED_EVENTS }
|
||||
)}
|
||||
</span>
|
||||
@@ -221,7 +221,7 @@ class EventSubscribeCard extends LitElement {
|
||||
.path=${mdiChevronRight}
|
||||
.disabled=${atNewest}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.newer_event"
|
||||
"ui.panel.config.tools.tabs.events.newer_event"
|
||||
)}
|
||||
@click=${this._showNewer}
|
||||
></ha-icon-button>
|
||||
@@ -229,7 +229,7 @@ class EventSubscribeCard extends LitElement {
|
||||
.path=${mdiChevronDoubleRight}
|
||||
.disabled=${atNewest}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.newest_event"
|
||||
"ui.panel.config.tools.tabs.events.newest_event"
|
||||
)}
|
||||
@click=${this._showNewest}
|
||||
></ha-icon-button>
|
||||
@@ -362,13 +362,13 @@ class EventSubscribeCard extends LitElement {
|
||||
}, this._eventType);
|
||||
} catch (error) {
|
||||
this._error = this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.subscribe_failed",
|
||||
"ui.panel.config.tools.tabs.events.subscribe_failed",
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: this.hass!.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.unknown_error"
|
||||
"ui.panel.config.tools.tabs.events.unknown_error"
|
||||
),
|
||||
}
|
||||
);
|
||||
+1
-1
@@ -27,7 +27,7 @@ class EventsList extends LitElement {
|
||||
>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.count_listeners",
|
||||
"ui.panel.config.tools.tabs.events.count_listeners",
|
||||
{
|
||||
count: event.listener_count,
|
||||
}
|
||||
+10
-10
@@ -14,7 +14,7 @@ import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import "./event-subscribe-card";
|
||||
import "./events-list";
|
||||
|
||||
@customElement("developer-tools-event")
|
||||
@customElement("tools-event")
|
||||
class HaPanelDevEvent extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -40,7 +40,7 @@ class HaPanelDevEvent extends LitElement {
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.description"
|
||||
"ui.panel.config.tools.tabs.events.description"
|
||||
)}
|
||||
<a
|
||||
href=${documentationUrl(
|
||||
@@ -51,14 +51,14 @@ class HaPanelDevEvent extends LitElement {
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.documentation"
|
||||
"ui.panel.config.tools.tabs.events.documentation"
|
||||
)}
|
||||
</a>
|
||||
</p>
|
||||
<div class="inputs">
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.type"
|
||||
"ui.panel.config.tools.tabs.events.type"
|
||||
)}
|
||||
autofocus
|
||||
required
|
||||
@@ -67,7 +67,7 @@ class HaPanelDevEvent extends LitElement {
|
||||
></ha-input>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.data"
|
||||
"ui.panel.config.tools.tabs.events.data"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ class HaPanelDevEvent extends LitElement {
|
||||
appearance="filled"
|
||||
.disabled=${!this._isValid}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.fire_event"
|
||||
"ui.panel.config.tools.tabs.events.fire_event"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ class HaPanelDevEvent extends LitElement {
|
||||
<div>
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.active_listeners"
|
||||
"ui.panel.config.tools.tabs.events.active_listeners"
|
||||
)}
|
||||
</h2>
|
||||
<events-list
|
||||
@@ -131,7 +131,7 @@ class HaPanelDevEvent extends LitElement {
|
||||
if (!this._eventType) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.alert_event_type"
|
||||
"ui.panel.config.tools.tabs.events.alert_event_type"
|
||||
),
|
||||
});
|
||||
return;
|
||||
@@ -143,7 +143,7 @@ class HaPanelDevEvent extends LitElement {
|
||||
);
|
||||
fireEvent(this, "hass-notification", {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.events.notification_event_fired",
|
||||
"ui.panel.config.tools.tabs.events.notification_event_fired",
|
||||
{ type: this._eventType }
|
||||
),
|
||||
});
|
||||
@@ -221,6 +221,6 @@ class HaPanelDevEvent extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"developer-tools-event": HaPanelDevEvent;
|
||||
"tools-event": HaPanelDevEvent;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user