mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-23 11:37:30 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df9cfeab58 | |||
| a31713a5ea | |||
| d5005ba951 | |||
| fa29f86b9d | |||
| 7844938ecd | |||
| f188f5de07 | |||
| ad0ad20cbe | |||
| 25ed411198 | |||
| 988cd5ac82 | |||
| ca074a1c03 | |||
| b8be36c5bb |
@@ -1,41 +0,0 @@
|
||||
name: Prepare dependencies
|
||||
description: Install and cache the complete dependency tree
|
||||
|
||||
inputs:
|
||||
node-modules-cache-key:
|
||||
description: Prefix for the shared node_modules cache key
|
||||
default: node-modules-v1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check for complete dependency tree
|
||||
id: dependencies
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
@@ -11,9 +11,6 @@ inputs:
|
||||
node-modules-cache:
|
||||
description: Restore the exact shared node_modules cache before installing
|
||||
default: "false"
|
||||
node-modules-cache-key:
|
||||
description: Prefix for the shared node_modules cache key
|
||||
default: node-modules-v1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -28,7 +25,7 @@ runs:
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
|
||||
@@ -30,8 +30,34 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
- name: Check for complete dependency tree
|
||||
id: dependencies
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
lint:
|
||||
name: Lint and check format
|
||||
|
||||
@@ -22,42 +22,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
prepare-container-dependencies:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
with:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -65,10 +32,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
@@ -88,7 +53,6 @@ jobs:
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -96,10 +60,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
@@ -119,7 +81,6 @@ jobs:
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -127,10 +88,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
@@ -151,7 +110,6 @@ jobs:
|
||||
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-demo
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
@@ -174,11 +132,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -203,7 +158,6 @@ jobs:
|
||||
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-e2e-test-app
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
@@ -228,11 +182,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -257,7 +208,6 @@ jobs:
|
||||
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-gallery
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
@@ -282,11 +232,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -324,10 +271,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -12,18 +12,42 @@
|
||||
const remapping = require("@ampproject/remapping");
|
||||
|
||||
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
|
||||
let minifyPromise;
|
||||
const getMinifier = () => {
|
||||
if (!minifyPromise) {
|
||||
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
|
||||
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
|
||||
const loaderInitPromises = new Map();
|
||||
const initLoader = (browserslistEnv) => {
|
||||
if (!loaderInitPromises.has(browserslistEnv)) {
|
||||
loaderInitPromises.set(
|
||||
browserslistEnv,
|
||||
Promise.all([
|
||||
import("minify-literals"),
|
||||
import("browserslist"),
|
||||
import("lightningcss"),
|
||||
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
|
||||
const browserslist = browserslistModule.default;
|
||||
const { browserslistToTargets } = lightningcssModule;
|
||||
const rawTargets = browserslist(null, { env: browserslistEnv });
|
||||
if (!rawTargets.length) {
|
||||
throw new Error(
|
||||
`No browsers resolved for browserslist environment "${browserslistEnv}"`
|
||||
);
|
||||
}
|
||||
const lightningcssTargets = browserslistToTargets(rawTargets);
|
||||
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return minifyPromise;
|
||||
return loaderInitPromises.get(browserslistEnv);
|
||||
};
|
||||
|
||||
// HTML options mirror the previous babel-plugin-template-html-minifier config
|
||||
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
|
||||
// css`` templates and inline <style> is handled by minify-literals' lightningcss
|
||||
// default.
|
||||
// css`` templates and inline <style> is handled by minify-literals'
|
||||
// lightningcss. We pass in the targets from browserslist so lightningcss
|
||||
// can minify CSS appropriately for the build environment.
|
||||
//
|
||||
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
|
||||
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
|
||||
@@ -40,11 +64,16 @@ const htmlOptions = {
|
||||
|
||||
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const callback = this.async();
|
||||
getMinifier()
|
||||
.then((minifyHTMLLiterals) =>
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
@@ -96,6 +96,11 @@ const createRspackConfig = ({
|
||||
__dirname,
|
||||
"minify-template-literals-loader.cjs"
|
||||
),
|
||||
options: {
|
||||
browserslistEnv: latestBuild
|
||||
? "modern"
|
||||
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
|
||||
},
|
||||
},
|
||||
!latestBuild &&
|
||||
info.resource.startsWith(
|
||||
|
||||
+1
-8
@@ -91,14 +91,7 @@ export const STATES_OFF = ["closed", "locked", "off"];
|
||||
export const BINARY_STATE_ON = "on";
|
||||
export const BINARY_STATE_OFF = "off";
|
||||
|
||||
/** Domains where we allow toggle in Lovelace.
|
||||
* This is not strictly a list of what is possible to toggle, but the list of
|
||||
* domains where toggle is considered the primary default behavior.
|
||||
* Entities card uses this to determine which entities are controlled by the
|
||||
* header toggle.
|
||||
* Some cards use this to decide the default tap action.
|
||||
* Use canToggleDomain/canToggleState for determining if toggling is possible.
|
||||
*/
|
||||
/** Domains where we allow toggle in Lovelace. */
|
||||
export const DOMAINS_TOGGLE = new Set([
|
||||
"fan",
|
||||
"input_boolean",
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
|
||||
const services = hass.services[domain];
|
||||
if (!services) {
|
||||
return false;
|
||||
}
|
||||
const actionOn = getToggleAction(domain, true);
|
||||
const actionOff = getToggleAction(domain, false);
|
||||
return actionOn in services && actionOff in services;
|
||||
|
||||
if (domain === "button" || domain === "input_button") {
|
||||
return "press" in services;
|
||||
}
|
||||
if (domain === "lock") {
|
||||
return "lock" in services;
|
||||
}
|
||||
if (domain === "cover") {
|
||||
return "open_cover" in services;
|
||||
}
|
||||
if (domain === "valve") {
|
||||
return "open_valve" in services;
|
||||
}
|
||||
return "turn_on" in services;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { canToggleDomain } from "./can_toggle_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { supportsFeature } from "./supports-feature";
|
||||
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
|
||||
import { ClimateEntityFeature } from "../../data/climate";
|
||||
|
||||
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
@@ -26,12 +26,10 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
domain in SPECIAL_TOGGLE_ACTIONS &&
|
||||
SPECIAL_TOGGLE_ACTIONS[domain].feature
|
||||
) {
|
||||
return SPECIAL_TOGGLE_ACTIONS[domain].feature.every((f) =>
|
||||
supportsFeature(stateObj, f)
|
||||
if (domain === "climate") {
|
||||
return (
|
||||
supportsFeature(stateObj, ClimateEntityFeature.TURN_ON) &&
|
||||
supportsFeature(stateObj, ClimateEntityFeature.TURN_OFF)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@ import { AITaskEntityFeature } from "../../data/ai_task";
|
||||
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
|
||||
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
|
||||
import { CalendarEntityFeature } from "../../data/calendar";
|
||||
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
|
||||
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
|
||||
import { CameraEntityFeature } from "../../data/camera";
|
||||
import { ClimateEntityFeature } from "../../data/climate";
|
||||
import { ConversationEntityFeature } from "../../data/conversation";
|
||||
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
|
||||
import { CoverEntityFeature } from "../../data/cover";
|
||||
import { FanEntityFeature } from "../../data/fan";
|
||||
import { HumidifierEntityFeature } from "../../data/humidifier";
|
||||
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
|
||||
import { LightEntityFeature } from "../../data/light";
|
||||
import { LockEntityFeature } from "../../data/lock";
|
||||
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
|
||||
import { MediaPlayerEntityFeature } from "../../data/media-player";
|
||||
import { NotifyEntityFeature } from "../../data/notify";
|
||||
import { RemoteEntityFeature } from "../../data/remote";
|
||||
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
|
||||
import { SirenEntityFeature } from "../../data/siren";
|
||||
import { TodoListEntityFeature } from "../../data/todo";
|
||||
import { UpdateEntityFeature } from "../../data/update";
|
||||
import { VacuumEntityFeature } from "../../data/vacuum";
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
|
||||
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
|
||||
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
|
||||
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
|
||||
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
|
||||
|
||||
// These are domains which have nonstandard 'toggle' behavior.
|
||||
// Otherwise, any domain with a turn_on and turn_off service may be toggled.
|
||||
// If features are provided, all features must be supported.
|
||||
interface SpecialToggleAction {
|
||||
on: string;
|
||||
off?: string;
|
||||
feature?: number[];
|
||||
}
|
||||
|
||||
export const SPECIAL_TOGGLE_ACTIONS: Record<string, SpecialToggleAction> = {
|
||||
button: {
|
||||
on: "press",
|
||||
},
|
||||
camera: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [CameraEntityFeature.ON_OFF],
|
||||
},
|
||||
climate: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF],
|
||||
},
|
||||
cover: {
|
||||
on: "open_cover",
|
||||
off: "close_cover",
|
||||
feature: [CoverEntityFeature.OPEN, CoverEntityFeature.CLOSE],
|
||||
},
|
||||
input_button: {
|
||||
on: "press",
|
||||
},
|
||||
lock: {
|
||||
on: "unlock",
|
||||
off: "lock",
|
||||
},
|
||||
media_player: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [
|
||||
MediaPlayerEntityFeature.TURN_ON,
|
||||
MediaPlayerEntityFeature.TURN_OFF,
|
||||
],
|
||||
},
|
||||
scene: {
|
||||
on: "turn_on",
|
||||
},
|
||||
siren: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [SirenEntityFeature.TURN_ON, SirenEntityFeature.TURN_OFF],
|
||||
},
|
||||
valve: {
|
||||
on: "open_valve",
|
||||
off: "close_valve",
|
||||
},
|
||||
};
|
||||
|
||||
// This function assumes that the passed domain can toggle, it may otherwise
|
||||
// return a service that does not exist.
|
||||
export const getToggleAction = (domain: string, onOff: boolean): string => {
|
||||
return (
|
||||
SPECIAL_TOGGLE_ACTIONS[domain]?.[onOff ? "on" : "off"] ||
|
||||
SPECIAL_TOGGLE_ACTIONS[domain]?.["on"] ||
|
||||
(onOff ? "turn_on" : "turn_off")
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||
if (!states.length) {
|
||||
@@ -58,11 +57,14 @@ export const toggleGroupEntities = (
|
||||
|
||||
const isOn = state === "on" || state === "open";
|
||||
|
||||
let service = getToggleAction(domain, !isOn);
|
||||
let service = isOn ? "turn_off" : "turn_on";
|
||||
if (domain === "cover") {
|
||||
if (state === "opening" || state === "closing") {
|
||||
// If the cover is opening or closing, we toggle it to stop it
|
||||
service = "stop_cover";
|
||||
} else {
|
||||
// For covers, we use the open/close service
|
||||
service = isOn ? "close_cover" : "open_cover";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { forwardHaptic } from "../../data/haptics";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
import { getToggleAction } from "../../common/entity/get_toggle_action";
|
||||
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
@@ -125,10 +124,25 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
forwardHaptic(this, "light");
|
||||
const stateDomain = computeStateDomain(this.stateObj);
|
||||
let serviceDomain;
|
||||
let service;
|
||||
|
||||
const serviceDomain =
|
||||
stateDomain === "group" ? "homeassistant" : stateDomain;
|
||||
const service = getToggleAction(stateDomain, turnOn);
|
||||
if (stateDomain === "lock") {
|
||||
serviceDomain = "lock";
|
||||
service = turnOn ? "unlock" : "lock";
|
||||
} else if (stateDomain === "cover") {
|
||||
serviceDomain = "cover";
|
||||
service = turnOn ? "open_cover" : "close_cover";
|
||||
} else if (stateDomain === "valve") {
|
||||
serviceDomain = "valve";
|
||||
service = turnOn ? "open_valve" : "close_valve";
|
||||
} else if (stateDomain === "group") {
|
||||
serviceDomain = "homeassistant";
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
} else {
|
||||
serviceDomain = stateDomain;
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
}
|
||||
|
||||
const currentState = this.stateObj;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
} from "../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-form/ha-form";
|
||||
import { unitOfMeasurementOptions } from "../../data/number";
|
||||
|
||||
const SELECTOR_DEFAULTS = {
|
||||
number: {
|
||||
@@ -113,16 +112,6 @@ const SELECTOR_SCHEMAS = {
|
||||
name: "step",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "unit_of_measurement",
|
||||
selector: {
|
||||
select: {
|
||||
custom_value: true,
|
||||
sort: true,
|
||||
options: unitOfMeasurementOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const,
|
||||
object: [] as const,
|
||||
color_rgb: [] as const,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { subscribeLabFeature } from "../data/labs";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
@@ -81,14 +82,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
|
||||
class="snowflake ${
|
||||
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
|
||||
}"
|
||||
style="
|
||||
left: ${flake.left}%;
|
||||
width: ${flake.size}px;
|
||||
height: ${flake.size}px;
|
||||
animation-duration: ${flake.duration}s;
|
||||
animation-delay: ${flake.delay}s;
|
||||
--rotation: ${flake.rotation}deg;
|
||||
"
|
||||
style=${styleMap({
|
||||
left: `${flake.left}%`,
|
||||
width: `${flake.size}px`,
|
||||
height: `${flake.size}px`,
|
||||
"animation-duration": `${flake.duration}s`,
|
||||
"animation-delay": `${flake.delay}s`,
|
||||
"--rotation": `${flake.rotation}deg`,
|
||||
})}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
+4
-1
@@ -13,7 +13,10 @@ export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
|
||||
export { CameraEntityFeature } from "./feature/camera_entity_feature";
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
|
||||
interface CameraEntityAttributes extends HassEntityAttributeBase {
|
||||
model_name: string;
|
||||
|
||||
+12
-1
@@ -68,7 +68,18 @@ export type ClimateEntity = HassEntityBase & {
|
||||
};
|
||||
};
|
||||
|
||||
export { ClimateEntityFeature } from "./feature/climate_entity_feature";
|
||||
export enum ClimateEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
TARGET_TEMPERATURE_RANGE = 2,
|
||||
TARGET_HUMIDITY = 4,
|
||||
FAN_MODE = 8,
|
||||
PRESET_MODE = 16,
|
||||
SWING_MODE = 32,
|
||||
AUX_HEAT = 64,
|
||||
TURN_OFF = 128,
|
||||
TURN_ON = 256,
|
||||
SWING_HORIZONTAL_MODE = 512,
|
||||
}
|
||||
|
||||
const hvacModeOrdering = HVAC_MODES.reduce(
|
||||
(order, mode, index) => {
|
||||
|
||||
+10
-2
@@ -6,9 +6,17 @@ import { stateActive } from "../common/entity/state_active";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { HomeAssistantFormatters } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
import { CoverEntityFeature } from "./feature/cover_entity_feature";
|
||||
|
||||
export { CoverEntityFeature };
|
||||
export enum CoverEntityFeature {
|
||||
OPEN = 1,
|
||||
CLOSE = 2,
|
||||
SET_POSITION = 4,
|
||||
STOP = 8,
|
||||
OPEN_TILT = 16,
|
||||
CLOSE_TILT = 32,
|
||||
STOP_TILT = 64,
|
||||
SET_TILT_POSITION = 128,
|
||||
}
|
||||
|
||||
export const DEFAULT_COVER_FAVORITE_POSITIONS = [0, 25, 75, 100];
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export enum ClimateEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
TARGET_TEMPERATURE_RANGE = 2,
|
||||
TARGET_HUMIDITY = 4,
|
||||
FAN_MODE = 8,
|
||||
PRESET_MODE = 16,
|
||||
SWING_MODE = 32,
|
||||
AUX_HEAT = 64,
|
||||
TURN_OFF = 128,
|
||||
TURN_ON = 256,
|
||||
SWING_HORIZONTAL_MODE = 512,
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export enum CoverEntityFeature {
|
||||
OPEN = 1,
|
||||
CLOSE = 2,
|
||||
SET_POSITION = 4,
|
||||
STOP = 8,
|
||||
OPEN_TILT = 16,
|
||||
CLOSE_TILT = 32,
|
||||
STOP_TILT = 64,
|
||||
SET_TILT_POSITION = 128,
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export enum MediaPlayerEntityFeature {
|
||||
PAUSE = 1,
|
||||
SEEK = 2,
|
||||
VOLUME_SET = 4,
|
||||
VOLUME_MUTE = 8,
|
||||
PREVIOUS_TRACK = 16,
|
||||
NEXT_TRACK = 32,
|
||||
|
||||
TURN_ON = 128,
|
||||
TURN_OFF = 256,
|
||||
PLAY_MEDIA = 512,
|
||||
VOLUME_STEP = 1024,
|
||||
SELECT_SOURCE = 2048,
|
||||
STOP = 4096,
|
||||
CLEAR_PLAYLIST = 8192,
|
||||
PLAY = 16384,
|
||||
SHUFFLE_SET = 32768,
|
||||
SELECT_SOUND_MODE = 65536,
|
||||
BROWSE_MEDIA = 131072,
|
||||
REPEAT_SET = 262144,
|
||||
GROUPING = 524288,
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export enum SirenEntityFeature {
|
||||
TURN_ON = 1,
|
||||
TURN_OFF = 2,
|
||||
TONES = 4,
|
||||
VOLUME_SET = 8,
|
||||
DURATION = 16,
|
||||
}
|
||||
@@ -45,69 +45,6 @@ export interface MatterNodeDiagnostics {
|
||||
|
||||
export type MatterPingResult = Record<string, boolean>;
|
||||
|
||||
export type MatterTopologyNodeKind =
|
||||
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
|
||||
|
||||
export type MatterTopologyStrength = "strong" | "medium" | "weak" | "none";
|
||||
|
||||
export interface MatterTopologyDirectionInfo {
|
||||
strength: MatterTopologyStrength;
|
||||
lqi?: number | null;
|
||||
rssi?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyNode {
|
||||
id: string;
|
||||
kind: MatterTopologyNodeKind;
|
||||
network_type: string;
|
||||
node_id?: number | null;
|
||||
ha_device_id?: string | null;
|
||||
role?: string | null;
|
||||
available?: boolean | null;
|
||||
is_bridge?: boolean | null;
|
||||
ext_address?: string | null;
|
||||
rloc16?: number | null;
|
||||
ext_pan_id?: string | null;
|
||||
network_name?: string | null;
|
||||
vendor_name?: string | null;
|
||||
model_name?: string | null;
|
||||
last_seen?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
network: string;
|
||||
strength: MatterTopologyStrength;
|
||||
source_to_target?: MatterTopologyDirectionInfo | null;
|
||||
target_to_source?: MatterTopologyDirectionInfo | null;
|
||||
via_route_table?: boolean | null;
|
||||
path_cost?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopology {
|
||||
collected_at: number;
|
||||
nodes: MatterNetworkTopologyNode[];
|
||||
connections: MatterNetworkTopologyConnection[];
|
||||
}
|
||||
|
||||
export const fetchMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
refresh = false
|
||||
): Promise<MatterNetworkTopology> =>
|
||||
hass.callWS({
|
||||
type: "matter/network_topology",
|
||||
refresh,
|
||||
});
|
||||
|
||||
export const subscribeMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
callback: (topology: MatterNetworkTopology) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
|
||||
type: "matter/subscribe_network_topology",
|
||||
});
|
||||
|
||||
export interface MatterCommissioningParameters {
|
||||
setup_pin_code: number;
|
||||
setup_manual_code: string;
|
||||
|
||||
@@ -40,9 +40,6 @@ import type { MediaPlayerItemId } from "../components/media-player/ha-media-play
|
||||
import type { HomeAssistant, TranslationDict } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
import { isTTSMediaSource } from "./tts";
|
||||
import { MediaPlayerEntityFeature } from "./feature/media-player_entity_feature";
|
||||
|
||||
export { MediaPlayerEntityFeature };
|
||||
|
||||
interface MediaPlayerEntityAttributes extends HassEntityAttributeBase {
|
||||
media_content_id?: string;
|
||||
@@ -85,6 +82,29 @@ export interface MediaPlayerEntity extends HassEntityBase {
|
||||
| "buffering";
|
||||
}
|
||||
|
||||
export enum MediaPlayerEntityFeature {
|
||||
PAUSE = 1,
|
||||
SEEK = 2,
|
||||
VOLUME_SET = 4,
|
||||
VOLUME_MUTE = 8,
|
||||
PREVIOUS_TRACK = 16,
|
||||
NEXT_TRACK = 32,
|
||||
|
||||
TURN_ON = 128,
|
||||
TURN_OFF = 256,
|
||||
PLAY_MEDIA = 512,
|
||||
VOLUME_STEP = 1024,
|
||||
SELECT_SOURCE = 2048,
|
||||
STOP = 4096,
|
||||
CLEAR_PLAYLIST = 8192,
|
||||
PLAY = 16384,
|
||||
SHUFFLE_SET = 32768,
|
||||
SELECT_SOUND_MODE = 65536,
|
||||
BROWSE_MEDIA = 131072,
|
||||
REPEAT_SET = 262144,
|
||||
GROUPING = 524288,
|
||||
}
|
||||
|
||||
export type MediaPlayerBrowseAction = "pick" | "play";
|
||||
|
||||
export const BROWSER_PLAYER = "browser";
|
||||
|
||||
+7
-1
@@ -1 +1,7 @@
|
||||
export { SirenEntityFeature } from "./feature/siren_entity_feature";
|
||||
export enum SirenEntityFeature {
|
||||
TURN_ON = 1,
|
||||
TURN_OFF = 2,
|
||||
TONES = 4,
|
||||
VOLUME_SET = 8,
|
||||
DURATION = 16,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
mdiPlus,
|
||||
mdiShape,
|
||||
mdiTune,
|
||||
mdiVectorPolyline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -145,10 +144,6 @@ export class MatterConfigDashboard extends LitElement {
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-header">
|
||||
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
|
||||
<ha-button appearance="filled" href="/config/matter/visualization">
|
||||
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.matter.panel.show_map")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
@@ -257,9 +252,6 @@ export class MatterConfigDashboard extends LitElement {
|
||||
}
|
||||
|
||||
.nav-card .card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ class MatterConfigRouter extends HassRouterPage {
|
||||
tag: "matter-options-page",
|
||||
load: () => import("./matter-options-page"),
|
||||
},
|
||||
visualization: {
|
||||
tag: "matter-network-visualization",
|
||||
load: () => import("./matter-network-visualization"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterTopologyStrength,
|
||||
} from "../../../../../data/matter";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const CATEGORY_HOME_ASSISTANT = 0;
|
||||
const CATEGORY_BORDER_ROUTER = 1;
|
||||
const CATEGORY_ROUTER = 2;
|
||||
const CATEGORY_END_DEVICE = 3;
|
||||
const CATEGORY_WIFI_AP = 4;
|
||||
const CATEGORY_OFFLINE = 5;
|
||||
const CATEGORY_UNKNOWN = 6;
|
||||
|
||||
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
|
||||
|
||||
// HA is not a Matter node; the frontend synthesizes it as the graph root.
|
||||
export const HOME_ASSISTANT_NODE_ID = "ha";
|
||||
const HOME_ASSISTANT_LABEL = "Home Assistant";
|
||||
|
||||
// 0 is never returned: a falsy link value re-enables the direction arrow
|
||||
// in ha-network-graph
|
||||
export const strengthToScale = (
|
||||
strength?: MatterTopologyStrength | null
|
||||
): number => {
|
||||
switch (strength) {
|
||||
case "strong":
|
||||
return 4;
|
||||
case "medium":
|
||||
return 3;
|
||||
case "weak":
|
||||
return 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
export const strengthToColorVar = (
|
||||
strength?: MatterTopologyStrength | null
|
||||
): string => {
|
||||
switch (strength) {
|
||||
case "strong":
|
||||
return "--success-color";
|
||||
case "medium":
|
||||
return "--warning-color";
|
||||
case "weak":
|
||||
return "--error-color";
|
||||
default:
|
||||
return "--disabled-color";
|
||||
}
|
||||
};
|
||||
|
||||
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
|
||||
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
|
||||
|
||||
export const getTopologyNodeCategory = (
|
||||
node: MatterNetworkTopologyNode
|
||||
): number => {
|
||||
if (node.kind === "border_router") {
|
||||
return CATEGORY_BORDER_ROUTER;
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return CATEGORY_WIFI_AP;
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return CATEGORY_UNKNOWN;
|
||||
}
|
||||
if (node.available === false) {
|
||||
return CATEGORY_OFFLINE;
|
||||
}
|
||||
return node.role && ROUTER_ROLES.has(node.role)
|
||||
? CATEGORY_ROUTER
|
||||
: CATEGORY_END_DEVICE;
|
||||
};
|
||||
|
||||
export const getTopologyNodeName = (
|
||||
node: MatterNetworkTopologyNode,
|
||||
hass: HomeAssistant
|
||||
): string => {
|
||||
const device = node.ha_device_id
|
||||
? hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device) {
|
||||
return device.name_by_user || device.name || node.id;
|
||||
}
|
||||
if (node.kind === "border_router") {
|
||||
return (
|
||||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
|
||||
hass.localize("ui.panel.config.matter.visualization.border_router")
|
||||
);
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return (
|
||||
node.network_name ||
|
||||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
|
||||
);
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
|
||||
}
|
||||
if (node.node_id != null) {
|
||||
return hass.localize("ui.panel.config.matter.visualization.node", {
|
||||
node_id: node.node_id,
|
||||
});
|
||||
}
|
||||
return node.id;
|
||||
};
|
||||
|
||||
const isHub = (category: number): boolean =>
|
||||
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
|
||||
|
||||
export function createMatterNetworkChartData(
|
||||
topology: MatterNetworkTopology,
|
||||
hass: HomeAssistant,
|
||||
element: Element
|
||||
): NetworkData {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
const categoryColors = [
|
||||
style.getPropertyValue("--primary-color"),
|
||||
style.getPropertyValue("--deep-purple-color"),
|
||||
style.getPropertyValue("--cyan-color"),
|
||||
style.getPropertyValue("--teal-color"),
|
||||
style.getPropertyValue("--indigo-color"),
|
||||
style.getPropertyValue("--error-color"),
|
||||
style.getPropertyValue("--disabled-color"),
|
||||
];
|
||||
const categories = [
|
||||
{
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.router"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.visualization.offline"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize(
|
||||
"ui.panel.config.matter.visualization.unknown_devices"
|
||||
),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
|
||||
},
|
||||
];
|
||||
|
||||
const threadNetworks = new Set(
|
||||
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
|
||||
);
|
||||
const multiNetwork = threadNetworks.size > 1;
|
||||
|
||||
const nodes: NetworkNode[] = [
|
||||
{
|
||||
id: HOME_ASSISTANT_NODE_ID,
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
category: CATEGORY_HOME_ASSISTANT,
|
||||
value: 4,
|
||||
symbol: "roundRect",
|
||||
symbolSize: 45,
|
||||
polarDistance: 0,
|
||||
fixed: true,
|
||||
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
|
||||
},
|
||||
];
|
||||
const nodeCategories = new Map<string, number>();
|
||||
topology.nodes.forEach((node) => {
|
||||
const category = getTopologyNodeCategory(node);
|
||||
nodeCategories.set(node.id, category);
|
||||
const device = node.ha_device_id
|
||||
? hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
const area = device ? getDeviceArea(device, hass.areas) : undefined;
|
||||
const contextParts: string[] = [];
|
||||
if (area) {
|
||||
contextParts.push(area.name);
|
||||
}
|
||||
if ((multiNetwork || !area) && node.network_name) {
|
||||
contextParts.push(node.network_name);
|
||||
}
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name: getTopologyNodeName(node, hass),
|
||||
context: contextParts.join(" • ") || undefined,
|
||||
category,
|
||||
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
|
||||
symbol: isHub(category) ? "roundRect" : "circle",
|
||||
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
|
||||
itemStyle: {
|
||||
color: categoryColors[category],
|
||||
...(node.role === "leader"
|
||||
? {
|
||||
borderColor: style.getPropertyValue("--primary-color"),
|
||||
borderWidth: 2,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
polarDistance: isHub(category)
|
||||
? 0.1
|
||||
: category === CATEGORY_ROUTER
|
||||
? 0.4
|
||||
: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
const links: NetworkLink[] = [];
|
||||
topology.connections.forEach((conn) => {
|
||||
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
|
||||
return;
|
||||
}
|
||||
let { source, target } = conn;
|
||||
let forward = conn.source_to_target;
|
||||
let reverse = conn.target_to_source;
|
||||
if (!forward && reverse) {
|
||||
// normalize so the arrow points in the observed direction
|
||||
[source, target] = [target, source];
|
||||
forward = reverse;
|
||||
reverse = undefined;
|
||||
}
|
||||
const oneWay = Boolean(forward) && !reverse;
|
||||
const asymmetric =
|
||||
forward && reverse && forward.strength !== reverse.strength;
|
||||
const width = strengthToWidth(conn.strength);
|
||||
links.push({
|
||||
source,
|
||||
target,
|
||||
value: strengthToScale(forward?.strength ?? conn.strength),
|
||||
// route-table edges without per-direction info are not directional
|
||||
reverseValue: oneWay
|
||||
? undefined
|
||||
: strengthToScale(reverse?.strength ?? conn.strength),
|
||||
symbolSize: oneWay ? width * 2 + 3 : undefined,
|
||||
lineStyle: {
|
||||
width,
|
||||
color: style.getPropertyValue(strengthToColorVar(conn.strength)),
|
||||
type:
|
||||
oneWay || asymmetric
|
||||
? "dashed"
|
||||
: !forward && conn.via_route_table
|
||||
? "dotted"
|
||||
: "solid",
|
||||
},
|
||||
ignoreForceLayout: !(
|
||||
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const haLink = (target: string): NetworkLink => ({
|
||||
source: HOME_ASSISTANT_NODE_ID,
|
||||
target,
|
||||
value: 0,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: categoryColors[CATEGORY_HOME_ASSISTANT],
|
||||
},
|
||||
});
|
||||
|
||||
// HA reaches the mesh through the border routers and Wi-Fi access points
|
||||
const hubIds = topology.nodes
|
||||
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
|
||||
.map((node) => node.id);
|
||||
hubIds.forEach((id) => links.push(haLink(id)));
|
||||
|
||||
// any node group without a border router / AP is linked straight to HA so
|
||||
// it never floats free (HA has a direct operational path to every node)
|
||||
const adjacency = new Map<string, Set<string>>();
|
||||
topology.nodes.forEach((node) => adjacency.set(node.id, new Set()));
|
||||
links.forEach((link) => {
|
||||
if (link.source === HOME_ASSISTANT_NODE_ID) {
|
||||
return;
|
||||
}
|
||||
adjacency.get(link.source)?.add(link.target);
|
||||
adjacency.get(link.target)?.add(link.source);
|
||||
});
|
||||
const hubIdSet = new Set(hubIds);
|
||||
const visited = new Set<string>();
|
||||
topology.nodes.forEach((startNode) => {
|
||||
if (visited.has(startNode.id)) {
|
||||
return;
|
||||
}
|
||||
const component: string[] = [];
|
||||
const queue = [startNode.id];
|
||||
visited.add(startNode.id);
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
component.push(id);
|
||||
adjacency.get(id)?.forEach((next) => {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (component.some((id) => hubIdSet.has(id))) {
|
||||
return;
|
||||
}
|
||||
const candidates = component.filter(
|
||||
(id) => nodeCategories.get(id) !== CATEGORY_UNKNOWN
|
||||
);
|
||||
const representative = (candidates.length ? candidates : component).sort(
|
||||
(a, b) => (adjacency.get(b)?.size ?? 0) - (adjacency.get(a)?.size ?? 0)
|
||||
)[0];
|
||||
if (representative) {
|
||||
links.push(haLink(representative));
|
||||
}
|
||||
});
|
||||
|
||||
// keep the strongest link of every node in the force layout so
|
||||
// nodes hang near their best connection instead of floating free
|
||||
nodes.forEach((node) => {
|
||||
let bestLink: NetworkLink | undefined;
|
||||
const hasActiveLink = links.some((link) => {
|
||||
if (link.source !== node.id && link.target !== node.id) {
|
||||
return false;
|
||||
}
|
||||
if (!link.ignoreForceLayout) {
|
||||
return true;
|
||||
}
|
||||
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
|
||||
if (
|
||||
linkValue >
|
||||
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
|
||||
) {
|
||||
bestLink = link;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!hasActiveLink && bestLink) {
|
||||
bestLink.ignoreForceLayout = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
-515
@@ -1,515 +0,0 @@
|
||||
import { mdiRefresh } from "@mdi/js";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../../../../common/datetime/relative_time";
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
import { throttle } from "../../../../../common/util/throttle";
|
||||
import "../../../../../components/chart/ha-network-graph";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
MatterTopologyDirectionInfo,
|
||||
} from "../../../../../data/matter";
|
||||
import {
|
||||
fetchMatterNetworkTopology,
|
||||
subscribeMatterNetworkTopology,
|
||||
} from "../../../../../data/matter";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
HOME_ASSISTANT_NODE_ID,
|
||||
} from "./matter-network-data";
|
||||
|
||||
const UPDATE_THROTTLE_TIME = 5000;
|
||||
|
||||
@customElement("matter-network-visualization")
|
||||
export class MatterNetworkVisualization extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _topology?: MatterNetworkTopology;
|
||||
|
||||
@state() private _notSupported = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _refreshing = false;
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _throttledUpdateTopology = throttle(
|
||||
(topology: MatterNetworkTopology) => {
|
||||
this._topology = topology;
|
||||
},
|
||||
UPDATE_THROTTLE_TIME
|
||||
);
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hass && !this._unsub) {
|
||||
this._subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._throttledUpdateTopology.cancel();
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribe(): void {
|
||||
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
|
||||
if (!this._topology) {
|
||||
this._topology = topology;
|
||||
} else {
|
||||
this._throttledUpdateTopology(topology);
|
||||
}
|
||||
});
|
||||
this._unsub.catch((err: { code?: string; message?: string }) => {
|
||||
this._unsub = undefined;
|
||||
if (err?.code === "not_supported" || err?.code === "unknown_command") {
|
||||
this._notSupported = true;
|
||||
} else {
|
||||
this._error = err?.message || String(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.header"
|
||||
)}
|
||||
back-path="/config/matter/dashboard"
|
||||
>
|
||||
${
|
||||
this.narrow && this._topology?.nodes.length
|
||||
? html`<div slot="header">${this._renderInputSearch()}</div>`
|
||||
: nothing
|
||||
}
|
||||
${this._renderContent()}
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent() {
|
||||
if (this._notSupported) {
|
||||
return html`<div class="center">
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
</div>`;
|
||||
}
|
||||
if (this._error) {
|
||||
return html`<div class="center">
|
||||
<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.error_loading",
|
||||
{ error: this._error }
|
||||
)}
|
||||
</ha-alert>
|
||||
</div>`;
|
||||
}
|
||||
if (!this._topology) {
|
||||
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
|
||||
}
|
||||
if (!this._topology.nodes.length) {
|
||||
return html`<div class="center empty">
|
||||
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<ha-network-graph
|
||||
.hass=${this.hass}
|
||||
.searchFilter=${this._searchFilter}
|
||||
.data=${this._formatNetworkData(
|
||||
this._topology,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.themes,
|
||||
this.hass.language
|
||||
)}
|
||||
.searchableAttributes=${this._getSearchableAttributes}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@chart-click=${this._handleChartClick}
|
||||
>
|
||||
${!this.narrow ? this._renderInputSearch("search") : nothing}
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class="refresh-button"
|
||||
.disabled=${this._refreshing}
|
||||
.path=${mdiRefresh}
|
||||
@click=${this._refreshTopology}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.refresh_topology"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-network-graph>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderInputSearch(slot = "") {
|
||||
return html`<ha-input-search
|
||||
appearance="outlined"
|
||||
slot=${slot}
|
||||
.value=${this._searchFilter}
|
||||
@input=${this._handleSearchChange}
|
||||
></ha-input-search>`;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent): void {
|
||||
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
|
||||
}
|
||||
|
||||
private async _refreshTopology(): Promise<void> {
|
||||
if (this._refreshing) {
|
||||
return;
|
||||
}
|
||||
this._refreshing = true;
|
||||
try {
|
||||
this._topology = await fetchMatterNetworkTopology(this.hass, true);
|
||||
} catch (err: unknown) {
|
||||
this._error = (err as { message?: string })?.message || String(err);
|
||||
} finally {
|
||||
this._refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _formatNetworkData = memoizeOne(
|
||||
(
|
||||
topology: MatterNetworkTopology,
|
||||
_devices: HomeAssistant["devices"],
|
||||
_areas: HomeAssistant["areas"],
|
||||
// node/link colors and labels also depend on the theme and language,
|
||||
// so both take part in the cache key even though they are read via hass
|
||||
_themes: HomeAssistant["themes"],
|
||||
_language: HomeAssistant["language"]
|
||||
) => createMatterNetworkChartData(topology, this.hass, this)
|
||||
);
|
||||
|
||||
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
|
||||
return this._topology?.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
private _getConnection(
|
||||
source: string,
|
||||
target: string
|
||||
): MatterNetworkTopologyConnection | undefined {
|
||||
return this._topology?.connections.find(
|
||||
(conn) =>
|
||||
(conn.source === source && conn.target === target) ||
|
||||
(conn.source === target && conn.target === source)
|
||||
);
|
||||
}
|
||||
|
||||
private _getNodeName(id: string): string {
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return id;
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device) {
|
||||
return device.name_by_user || device.name || id;
|
||||
}
|
||||
if (node.kind === "border_router") {
|
||||
return (
|
||||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
|
||||
this.hass.localize("ui.panel.config.matter.visualization.border_router")
|
||||
);
|
||||
}
|
||||
if (node.kind === "wifi_ap") {
|
||||
return (
|
||||
node.network_name ||
|
||||
this.hass.localize("ui.panel.config.matter.visualization.wifi_ap")
|
||||
);
|
||||
}
|
||||
if (node.kind === "thread_unknown") {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.unknown_device"
|
||||
);
|
||||
}
|
||||
if (node.node_id != null) {
|
||||
return this.hass.localize("ui.panel.config.matter.visualization.node", {
|
||||
node_id: node.node_id,
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private _getSearchableAttributes = (nodeId: string): string[] => {
|
||||
const node = this._getTopologyNode(nodeId);
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
const attributes: string[] = [];
|
||||
if (node.node_id != null) {
|
||||
attributes.push(String(node.node_id));
|
||||
}
|
||||
if (node.network_name) {
|
||||
attributes.push(node.network_name);
|
||||
}
|
||||
if (node.ext_address) {
|
||||
attributes.push(node.ext_address);
|
||||
}
|
||||
if (node.vendor_name) {
|
||||
attributes.push(node.vendor_name);
|
||||
}
|
||||
if (node.model_name) {
|
||||
attributes.push(node.model_name);
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device?.manufacturer) {
|
||||
attributes.push(device.manufacturer);
|
||||
}
|
||||
if (device?.model) {
|
||||
attributes.push(device.model);
|
||||
}
|
||||
device?.connections.forEach((connection) => {
|
||||
attributes.push(connection[1]);
|
||||
});
|
||||
return attributes;
|
||||
};
|
||||
|
||||
private _localizeDynamic(prefix: string, value: string): string {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
|
||||
) || value
|
||||
);
|
||||
}
|
||||
|
||||
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
|
||||
const strength = this._localizeDynamic(
|
||||
"visualization.strength",
|
||||
direction.strength
|
||||
);
|
||||
if (direction.lqi != null) {
|
||||
return `${strength} (LQI ${direction.lqi})`;
|
||||
}
|
||||
if (direction.rssi != null) {
|
||||
return `${strength} (RSSI ${direction.rssi} dBm)`;
|
||||
}
|
||||
return strength;
|
||||
}
|
||||
|
||||
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
|
||||
const { dataType, data } = params as CallbackDataParams;
|
||||
if (dataType === "edge") {
|
||||
const { source, target } = data as { source: string; target: string };
|
||||
const conn = this._getConnection(source, target);
|
||||
if (!conn) {
|
||||
return nothing;
|
||||
}
|
||||
const lines: TemplateResult[] = [];
|
||||
if (conn.source_to_target) {
|
||||
lines.push(
|
||||
html`<br />${this._getNodeName(conn.source)} →
|
||||
${this._getNodeName(conn.target)}:
|
||||
${this._formatDirection(conn.source_to_target)}`
|
||||
);
|
||||
}
|
||||
if (conn.target_to_source) {
|
||||
lines.push(
|
||||
html`<br />${this._getNodeName(conn.target)} →
|
||||
${this._getNodeName(conn.source)}:
|
||||
${this._formatDirection(conn.target_to_source)}`
|
||||
);
|
||||
}
|
||||
if (!lines.length && conn.via_route_table) {
|
||||
lines.push(
|
||||
html`<br />${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.via_route_table"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return html`<b
|
||||
>${this._getNodeName(conn.source)} ↔
|
||||
${this._getNodeName(conn.target)}</b
|
||||
>${lines}`;
|
||||
}
|
||||
const { id } = data as { id: string };
|
||||
if (id === HOME_ASSISTANT_NODE_ID) {
|
||||
return html`<b>Home Assistant</b>`;
|
||||
}
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return nothing;
|
||||
}
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
|
||||
const lines: TemplateResult[] = [];
|
||||
if (node.node_id != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.node_id"
|
||||
)}:</b
|
||||
>
|
||||
${node.node_id}`
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.network"
|
||||
)}:</b
|
||||
>
|
||||
${this._localizeDynamic("network_type", node.network_type)}${
|
||||
node.network_name ? html` (${node.network_name})` : nothing
|
||||
}`
|
||||
);
|
||||
if (node.role) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.role"
|
||||
)}:</b
|
||||
>
|
||||
${this._localizeDynamic("visualization.roles", node.role)}`
|
||||
);
|
||||
}
|
||||
if (node.available != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.status"
|
||||
)}:</b
|
||||
>
|
||||
${this.hass.localize(
|
||||
node.available
|
||||
? "ui.panel.config.matter.visualization.online"
|
||||
: "ui.panel.config.matter.visualization.offline"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (device?.manufacturer || node.vendor_name) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.manufacturer"
|
||||
)}:</b
|
||||
>
|
||||
${device?.manufacturer || node.vendor_name}`
|
||||
);
|
||||
}
|
||||
if (device?.model || node.model_name) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.model"
|
||||
)}:</b
|
||||
>
|
||||
${device?.model || node.model_name}`
|
||||
);
|
||||
}
|
||||
if (area) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.area"
|
||||
)}:</b
|
||||
>
|
||||
${area.name}`
|
||||
);
|
||||
}
|
||||
if (node.last_seen != null) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.last_seen"
|
||||
)}:</b
|
||||
>
|
||||
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
|
||||
);
|
||||
}
|
||||
return html`<b>${this._getNodeName(id)}</b>${lines}`;
|
||||
};
|
||||
|
||||
private _handleChartClick(e: CustomEvent): void {
|
||||
if (
|
||||
e.detail.dataType === "node" &&
|
||||
e.detail.event.target.cursor === "pointer"
|
||||
) {
|
||||
const { id } = e.detail.data;
|
||||
const node = this._getTopologyNode(id);
|
||||
if (node?.ha_device_id) {
|
||||
navigate(`/config/devices/device/${node.ha_device_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
ha-network-graph {
|
||||
height: 100%;
|
||||
}
|
||||
[slot="header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
ha-alert {
|
||||
max-width: 500px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"matter-network-visualization": MatterNetworkVisualization;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { forwardHaptic } from "../../../data/haptics";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import type { ToggleGroupCardConfig } from "./types";
|
||||
import { getToggleAction } from "../../../common/entity/get_toggle_action";
|
||||
|
||||
@customElement("hui-toggle-group-card")
|
||||
export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
@@ -96,7 +95,12 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
const onEntities = this._getOnEntities();
|
||||
const domain = computeDomain(this._config.entities[0]);
|
||||
|
||||
const service = getToggleAction(domain, onEntities.length === 0);
|
||||
let service: string;
|
||||
if (domain === "cover") {
|
||||
service = onEntities.length > 0 ? "close_cover" : "open_cover";
|
||||
} else {
|
||||
service = onEntities.length > 0 ? "turn_off" : "turn_on";
|
||||
}
|
||||
|
||||
this.hass.callService(domain, service, {
|
||||
entity_id: this._config.entities,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { STATES_OFF } from "../../../../common/const";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { getToggleAction } from "../../../../common/entity/get_toggle_action";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
export const turnOnOffEntities = (
|
||||
@@ -12,11 +11,9 @@ export const turnOnOffEntities = (
|
||||
entityIds.forEach((entityId) => {
|
||||
if (STATES_OFF.includes(hass.states[entityId].state) === turnOn) {
|
||||
const stateDomain = computeDomain(entityId);
|
||||
// Entities with non-standard toggle action need separate calls
|
||||
const serviceDomain =
|
||||
getToggleAction(stateDomain, true) !== "turn_on"
|
||||
? stateDomain
|
||||
: "homeassistant";
|
||||
const serviceDomain = ["cover", "lock"].includes(stateDomain)
|
||||
? stateDomain
|
||||
: "homeassistant";
|
||||
|
||||
if (!(serviceDomain in domainsToCall)) {
|
||||
domainsToCall[serviceDomain] = [];
|
||||
@@ -26,7 +23,17 @@ export const turnOnOffEntities = (
|
||||
});
|
||||
|
||||
Object.keys(domainsToCall).forEach((domain) => {
|
||||
const service = getToggleAction(domain, turnOn);
|
||||
let service;
|
||||
switch (domain) {
|
||||
case "lock":
|
||||
service = turnOn ? "unlock" : "lock";
|
||||
break;
|
||||
case "cover":
|
||||
service = turnOn ? "open_cover" : "close_cover";
|
||||
break;
|
||||
default:
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
}
|
||||
|
||||
const entities = domainsToCall[domain];
|
||||
hass.callService(domain, service, { entity_id: entities });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { getToggleAction } from "../../../../common/entity/get_toggle_action";
|
||||
import type { HomeAssistant, ServiceCallResponse } from "../../../../types";
|
||||
|
||||
export const turnOnOffEntity = (
|
||||
@@ -10,7 +9,27 @@ export const turnOnOffEntity = (
|
||||
const stateDomain = computeDomain(entityId);
|
||||
const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain;
|
||||
|
||||
const service = getToggleAction(stateDomain, turnOn);
|
||||
let service;
|
||||
switch (stateDomain) {
|
||||
case "lock":
|
||||
service = turnOn ? "unlock" : "lock";
|
||||
break;
|
||||
case "cover":
|
||||
service = turnOn ? "open_cover" : "close_cover";
|
||||
break;
|
||||
case "button":
|
||||
case "input_button":
|
||||
service = "press";
|
||||
break;
|
||||
case "scene":
|
||||
service = "turn_on";
|
||||
break;
|
||||
case "valve":
|
||||
service = turnOn ? "open_valve" : "close_valve";
|
||||
break;
|
||||
default:
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
}
|
||||
|
||||
return hass.callService(serviceDomain, service, { entity_id: entityId });
|
||||
};
|
||||
|
||||
@@ -8328,7 +8328,6 @@
|
||||
"status_online": "Online",
|
||||
"status_offline": "Offline",
|
||||
"my_network_title": "My network",
|
||||
"show_map": "[%key:ui::panel::config::bluetooth::show_map%]",
|
||||
"devices": "{count, plural,\n one {# device}\n other {# devices}\n}",
|
||||
"device_count": "{count, plural,\n one {# device}\n other {# devices}\n}",
|
||||
"entity_count": "{count, plural,\n one {# entity}\n other {# entities}\n}",
|
||||
@@ -8377,47 +8376,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"visualization": {
|
||||
"header": "Network visualization",
|
||||
"refresh_topology": "Refresh topology",
|
||||
"border_router": "Border router",
|
||||
"router": "Router",
|
||||
"end_device": "End device",
|
||||
"wifi_ap": "Wi-Fi access point",
|
||||
"offline": "Offline",
|
||||
"online": "Online",
|
||||
"unknown_device": "Unknown device",
|
||||
"unknown_devices": "Unknown devices",
|
||||
"node": "Node {node_id}",
|
||||
"node_id": "Node ID",
|
||||
"network": "Network",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"manufacturer": "Manufacturer",
|
||||
"model": "Model",
|
||||
"area": "Area",
|
||||
"last_seen": "Last seen",
|
||||
"via_route_table": "Learned from routing table",
|
||||
"empty": "No network topology data is available yet.",
|
||||
"not_supported": "The connected Matter server does not support network topology. Update your Matter server to use this feature.",
|
||||
"error_loading": "Failed to load the network topology: {error}",
|
||||
"strength": {
|
||||
"strong": "Strong",
|
||||
"medium": "Medium",
|
||||
"weak": "Weak",
|
||||
"none": "None"
|
||||
},
|
||||
"roles": {
|
||||
"leader": "Leader",
|
||||
"router": "Router",
|
||||
"reed": "Router-eligible end device",
|
||||
"end_device": "End device",
|
||||
"sleepy_end_device": "Sleepy end device",
|
||||
"unassigned": "Unassigned",
|
||||
"station": "Station",
|
||||
"ap": "Access point"
|
||||
}
|
||||
},
|
||||
"network_type": {
|
||||
"thread": "Thread",
|
||||
"wifi": "Wi-Fi",
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("canToggleDomain", () => {
|
||||
services: {
|
||||
cover: {
|
||||
open_cover: null,
|
||||
close_cover: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
@@ -63,7 +62,6 @@ describe("canToggleDomain", () => {
|
||||
services: {
|
||||
lock: {
|
||||
lock: null,
|
||||
unlock: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
@@ -83,59 +81,4 @@ describe("canToggleDomain", () => {
|
||||
)
|
||||
);
|
||||
});
|
||||
it("Scene can toggle", () => {
|
||||
assert.isTrue(
|
||||
canToggleDomain(
|
||||
{
|
||||
services: {
|
||||
scene: {
|
||||
turn_on: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
"scene"
|
||||
)
|
||||
);
|
||||
});
|
||||
it("Button can toggle", () => {
|
||||
assert.isTrue(
|
||||
canToggleDomain(
|
||||
{
|
||||
services: {
|
||||
button: {
|
||||
press: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
"button"
|
||||
)
|
||||
);
|
||||
});
|
||||
it("Any domain can toggle with supported actions", () => {
|
||||
assert.isTrue(
|
||||
canToggleDomain(
|
||||
{
|
||||
services: {
|
||||
custom_component: {
|
||||
turn_on: null,
|
||||
turn_off: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
"custom_component"
|
||||
)
|
||||
);
|
||||
assert.isFalse(
|
||||
canToggleDomain(
|
||||
{
|
||||
services: {
|
||||
custom_component: {
|
||||
turn_on: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
"custom_component"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
MatterNetworkTopologyNode,
|
||||
} from "../../../../../src/data/matter";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
getTopologyNodeCategory,
|
||||
getTopologyNodeName,
|
||||
strengthToScale,
|
||||
} from "../../../../../src/panels/config/integrations/integration-panels/matter/matter-network-data";
|
||||
import type { HomeAssistant } from "../../../../../src/types";
|
||||
|
||||
const mockHass = (
|
||||
devices: Record<string, Partial<HomeAssistant["devices"][string]>> = {},
|
||||
areas: Record<string, Partial<HomeAssistant["areas"][string]>> = {}
|
||||
): HomeAssistant =>
|
||||
({
|
||||
localize: (key: string) => key.split(".").pop(),
|
||||
devices,
|
||||
areas,
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
const node = (
|
||||
overrides: Partial<MatterNetworkTopologyNode> & { id: string }
|
||||
): MatterNetworkTopologyNode => ({
|
||||
kind: "matter",
|
||||
network_type: "thread",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const connection = (
|
||||
overrides: Partial<MatterNetworkTopologyConnection> & {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
): MatterNetworkTopologyConnection => ({
|
||||
network: "thread",
|
||||
strength: "strong",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const topology = (
|
||||
nodes: MatterNetworkTopologyNode[],
|
||||
connections: MatterNetworkTopologyConnection[] = []
|
||||
): MatterNetworkTopology => ({
|
||||
collected_at: 1767888000000,
|
||||
nodes,
|
||||
connections,
|
||||
});
|
||||
|
||||
const element = document.createElement("div");
|
||||
document.body.appendChild(element);
|
||||
|
||||
describe("strengthToScale", () => {
|
||||
it("never returns a falsy value so the graph arrow stays suppressed", () => {
|
||||
expect(strengthToScale("strong")).toBe(4);
|
||||
expect(strengthToScale("medium")).toBe(3);
|
||||
expect(strengthToScale("weak")).toBe(2);
|
||||
expect(strengthToScale("none")).toBe(1);
|
||||
expect(strengthToScale(undefined)).toBe(1);
|
||||
expect(strengthToScale(null)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopologyNodeCategory", () => {
|
||||
it("maps kinds and roles to categories", () => {
|
||||
// category 0 is reserved for the synthesized Home Assistant root node
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "br", kind: "border_router" }))
|
||||
).toBe(1);
|
||||
expect(getTopologyNodeCategory(node({ id: "1", role: "leader" }))).toBe(2);
|
||||
expect(getTopologyNodeCategory(node({ id: "2", role: "router" }))).toBe(2);
|
||||
expect(getTopologyNodeCategory(node({ id: "3", role: "reed" }))).toBe(2);
|
||||
expect(getTopologyNodeCategory(node({ id: "4", role: "end_device" }))).toBe(
|
||||
3
|
||||
);
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "5", role: "sleepy_end_device" }))
|
||||
).toBe(3);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "6", network_type: "wifi", role: "station" })
|
||||
)
|
||||
).toBe(3);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "ap_112233445566", kind: "wifi_ap", network_type: "wifi" })
|
||||
)
|
||||
).toBe(4);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "7", role: "router", available: false })
|
||||
)
|
||||
).toBe(5);
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "unknown_1", kind: "thread_unknown" }))
|
||||
).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopologyNodeName", () => {
|
||||
it("prefers the HA device name", () => {
|
||||
const hass = mockHass({
|
||||
dev1: { name_by_user: "Living room plug", name: "Plug" },
|
||||
});
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({ id: "1", node_id: 1, ha_device_id: "dev1" }),
|
||||
hass
|
||||
)
|
||||
).toBe("Living room plug");
|
||||
});
|
||||
|
||||
it("falls back to wire metadata for external nodes", () => {
|
||||
const hass = mockHass();
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
|
||||
hass
|
||||
)
|
||||
).toBe("Apple");
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({
|
||||
id: "ap_112233445566",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
network_name: "MyWiFi",
|
||||
}),
|
||||
hass
|
||||
)
|
||||
).toBe("MyWiFi");
|
||||
expect(
|
||||
getTopologyNodeName(
|
||||
node({ id: "unknown_1", kind: "thread_unknown" }),
|
||||
hass
|
||||
)
|
||||
).toBe("unknown_device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMatterNetworkChartData", () => {
|
||||
it("maps a thread mesh with a border router", () => {
|
||||
const hass = mockHass(
|
||||
{ dev1: { name: "Leader plug", area_id: "living" } },
|
||||
{ living: { name: "Living room" } }
|
||||
);
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, ha_device_id: "dev1", role: "leader" }),
|
||||
node({ id: "2", node_id: 2, role: "end_device", available: true }),
|
||||
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
strength: "medium",
|
||||
source_to_target: { strength: "medium", lqi: 2 },
|
||||
target_to_source: { strength: "medium", lqi: 2 },
|
||||
}),
|
||||
connection({
|
||||
source: "1",
|
||||
target: "br_1",
|
||||
source_to_target: { strength: "strong", lqi: 3 },
|
||||
target_to_source: { strength: "strong", lqi: 3 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
hass,
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.categories).toHaveLength(7);
|
||||
// Home Assistant root + the 3 topology nodes
|
||||
expect(data.nodes).toHaveLength(4);
|
||||
|
||||
const ha = data.nodes[0];
|
||||
expect(ha.id).toBe("ha");
|
||||
expect(ha.category).toBe(0);
|
||||
expect(ha.fixed).toBe(true);
|
||||
expect(ha.polarDistance).toBe(0);
|
||||
|
||||
const leader = data.nodes.find((n) => n.id === "1")!;
|
||||
expect(leader.name).toBe("Leader plug");
|
||||
expect(leader.context).toBe("Living room");
|
||||
expect(leader.category).toBe(2);
|
||||
expect(leader.itemStyle?.borderWidth).toBe(2);
|
||||
|
||||
const endDevice = data.nodes.find((n) => n.id === "2")!;
|
||||
expect(endDevice.category).toBe(3);
|
||||
expect(endDevice.itemStyle?.borderWidth).toBeUndefined();
|
||||
|
||||
const borderRouter = data.nodes.find((n) => n.id === "br_1")!;
|
||||
expect(borderRouter.category).toBe(1);
|
||||
expect(borderRouter.symbol).toBe("roundRect");
|
||||
|
||||
const meshLink = data.links.find(
|
||||
(l) => l.source === "1" && l.target === "2"
|
||||
)!;
|
||||
expect(meshLink.value).toBe(3);
|
||||
expect(meshLink.reverseValue).toBe(3);
|
||||
expect(meshLink.lineStyle?.type).toBe("solid");
|
||||
|
||||
// HA anchors to the border router (the mesh's infrastructure), not to
|
||||
// the individual routers hanging off it
|
||||
const haLink = data.links.find((l) => l.source === "ha")!;
|
||||
expect(haLink.target).toBe("br_1");
|
||||
expect(haLink.symbol).toBe("none");
|
||||
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks asymmetric links dashed and keeps one-way arrows", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
node({ id: "3", node_id: 3, role: "router" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
source_to_target: { strength: "strong", lqi: 3 },
|
||||
target_to_source: { strength: "weak", lqi: 1 },
|
||||
}),
|
||||
// only observed from node 3's side: 3 → 2
|
||||
connection({
|
||||
source: "2",
|
||||
target: "3",
|
||||
strength: "medium",
|
||||
target_to_source: { strength: "medium", lqi: 2 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const asymmetric = data.links.find(
|
||||
(l) => l.source === "1" && l.target === "2"
|
||||
)!;
|
||||
expect(asymmetric.lineStyle?.type).toBe("dashed");
|
||||
expect(asymmetric.value).toBe(4);
|
||||
expect(asymmetric.reverseValue).toBe(2);
|
||||
|
||||
// one-way link is flipped so the arrow points the observed direction
|
||||
const oneWay = data.links.find(
|
||||
(l) => l.source === "3" && l.target === "2"
|
||||
)!;
|
||||
expect(oneWay.reverseValue).toBeUndefined();
|
||||
expect(oneWay.symbolSize).toBeGreaterThan(0);
|
||||
expect(oneWay.lineStyle?.type).toBe("dashed");
|
||||
});
|
||||
|
||||
it("suppresses direction arrows on route-table edges", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
strength: "none",
|
||||
via_route_table: true,
|
||||
path_cost: 1,
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const link = data.links[0];
|
||||
expect(link.value).toBe(1);
|
||||
expect(link.reverseValue).toBe(1);
|
||||
expect(link.lineStyle?.type).toBe("dotted");
|
||||
});
|
||||
|
||||
it("keeps every node attached to the force layout", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
node({ id: "3", node_id: 3, role: "end_device" }),
|
||||
node({ id: "br_1", kind: "border_router" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "1",
|
||||
target: "br_1",
|
||||
source_to_target: { strength: "strong", lqi: 3 },
|
||||
target_to_source: { strength: "strong", lqi: 3 },
|
||||
}),
|
||||
connection({
|
||||
source: "1",
|
||||
target: "2",
|
||||
strength: "weak",
|
||||
source_to_target: { strength: "weak", lqi: 1 },
|
||||
target_to_source: { strength: "weak", lqi: 1 },
|
||||
}),
|
||||
connection({
|
||||
source: "2",
|
||||
target: "3",
|
||||
strength: "weak",
|
||||
source_to_target: { strength: "weak", lqi: 1 },
|
||||
target_to_source: { strength: "weak", lqi: 1 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
// hub link stays active, and every node has at least one active link
|
||||
const hubLink = data.links.find((l) => l.target === "br_1")!;
|
||||
expect(hubLink.ignoreForceLayout).toBe(false);
|
||||
data.nodes.forEach((n) => {
|
||||
const nodeLinks = data.links.filter(
|
||||
(l) => l.source === n.id || l.target === n.id
|
||||
);
|
||||
expect(
|
||||
nodeLinks.some((l) => !l.ignoreForceLayout),
|
||||
`node ${n.id} has no active link`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates minimal nodes and skips connections to unknown nodes", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[node({ id: "1" })],
|
||||
[connection({ source: "1", target: "missing" })]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
// Home Assistant root + the single topology node
|
||||
expect(data.nodes).toHaveLength(2);
|
||||
// the bogus connection is skipped; the lone node is anchored to HA
|
||||
expect(data.links).toHaveLength(1);
|
||||
expect(data.links[0].source).toBe("ha");
|
||||
expect(data.links[0].target).toBe("1");
|
||||
});
|
||||
|
||||
it("anchors unconnected routers directly to Home Assistant", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({ id: "1", node_id: 1, role: "router" }),
|
||||
node({ id: "2", node_id: 2, role: "router" }),
|
||||
]),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const haTargets = data.links
|
||||
.filter((l) => l.source === "ha")
|
||||
.map((l) => l.target)
|
||||
.sort();
|
||||
expect(haTargets).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("routes HA through the Wi-Fi access point, not the stations", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology(
|
||||
[
|
||||
node({
|
||||
id: "ap_112233445566",
|
||||
kind: "wifi_ap",
|
||||
network_type: "wifi",
|
||||
}),
|
||||
node({ id: "7", node_id: 7, network_type: "wifi", role: "station" }),
|
||||
node({ id: "8", node_id: 8, network_type: "wifi", role: "station" }),
|
||||
],
|
||||
[
|
||||
connection({
|
||||
source: "7",
|
||||
target: "ap_112233445566",
|
||||
network: "wifi",
|
||||
source_to_target: { strength: "strong", rssi: -55 },
|
||||
}),
|
||||
connection({
|
||||
source: "8",
|
||||
target: "ap_112233445566",
|
||||
network: "wifi",
|
||||
source_to_target: { strength: "medium", rssi: -70 },
|
||||
}),
|
||||
]
|
||||
),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const haTargets = data.links
|
||||
.filter((l) => l.source === "ha")
|
||||
.map((l) => l.target);
|
||||
expect(haTargets).toEqual(["ap_112233445566"]);
|
||||
});
|
||||
|
||||
it("adds the network name to the context when there are multiple networks", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "1",
|
||||
node_id: 1,
|
||||
ext_pan_id: "AAA",
|
||||
network_name: "NetA",
|
||||
}),
|
||||
node({
|
||||
id: "2",
|
||||
node_id: 2,
|
||||
ext_pan_id: "BBB",
|
||||
network_name: "NetB",
|
||||
}),
|
||||
]),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.nodes.find((n) => n.id === "1")!.context).toBe("NetA");
|
||||
expect(data.nodes.find((n) => n.id === "2")!.context).toBe("NetB");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user