mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-25 05:03:58 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9d8a86905 | |||
| 0f75e1074b | |||
| 300b34f876 | |||
| 1f14f8689c | |||
| 56966ee04b | |||
| 598a987ba5 | |||
| 8775d3f4c5 |
@@ -0,0 +1,41 @@
|
||||
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,6 +11,9 @@ 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
|
||||
@@ -25,7 +28,7 @@ runs:
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
|
||||
@@ -30,34 +30,8 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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/**') }}
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
lint:
|
||||
name: Lint and check format
|
||||
|
||||
+65
-10
@@ -22,9 +22,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -32,8 +31,44 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- 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
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
@@ -53,6 +88,7 @@ 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
|
||||
@@ -60,8 +96,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
@@ -81,6 +119,7 @@ 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
|
||||
@@ -88,8 +127,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
@@ -110,6 +151,7 @@ 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
|
||||
@@ -132,8 +174,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
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
|
||||
@@ -158,6 +203,7 @@ 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
|
||||
@@ -182,8 +228,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
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
|
||||
@@ -208,6 +257,7 @@ 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
|
||||
@@ -232,8 +282,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
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
|
||||
@@ -271,8 +324,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
+8
-1
@@ -91,7 +91,14 @@ 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. */
|
||||
/** 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.
|
||||
*/
|
||||
export const DOMAINS_TOGGLE = new Set([
|
||||
"fan",
|
||||
"input_boolean",
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
const actionOn = getToggleAction(domain, true);
|
||||
const actionOff = getToggleAction(domain, false);
|
||||
return actionOn in services && actionOff 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 { ClimateEntityFeature } from "../../data/climate";
|
||||
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
|
||||
|
||||
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
@@ -26,10 +26,12 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain === "climate") {
|
||||
return (
|
||||
supportsFeature(stateObj, ClimateEntityFeature.TURN_ON) &&
|
||||
supportsFeature(stateObj, ClimateEntityFeature.TURN_OFF)
|
||||
if (
|
||||
domain in SPECIAL_TOGGLE_ACTIONS &&
|
||||
SPECIAL_TOGGLE_ACTIONS[domain].feature
|
||||
) {
|
||||
return SPECIAL_TOGGLE_ACTIONS[domain].feature.every((f) =>
|
||||
supportsFeature(stateObj, f)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/camera";
|
||||
import { ClimateEntityFeature } from "../../data/climate";
|
||||
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
|
||||
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
|
||||
import { ConversationEntityFeature } from "../../data/conversation";
|
||||
import { CoverEntityFeature } from "../../data/cover";
|
||||
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
|
||||
import { FanEntityFeature } from "../../data/fan";
|
||||
import { HumidifierEntityFeature } from "../../data/humidifier";
|
||||
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
|
||||
import { LightEntityFeature } from "../../data/light";
|
||||
import { LockEntityFeature } from "../../data/lock";
|
||||
import { MediaPlayerEntityFeature } from "../../data/media-player";
|
||||
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
|
||||
import { NotifyEntityFeature } from "../../data/notify";
|
||||
import { RemoteEntityFeature } from "../../data/remote";
|
||||
import { SirenEntityFeature } from "../../data/siren";
|
||||
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
|
||||
import { TodoListEntityFeature } from "../../data/todo";
|
||||
import { UpdateEntityFeature } from "../../data/update";
|
||||
import { VacuumEntityFeature } from "../../data/vacuum";
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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,6 +2,7 @@ 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) {
|
||||
@@ -57,14 +58,11 @@ export const toggleGroupEntities = (
|
||||
|
||||
const isOn = state === "on" || state === "open";
|
||||
|
||||
let service = isOn ? "turn_off" : "turn_on";
|
||||
let service = getToggleAction(domain, !isOn);
|
||||
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,6 +13,7 @@ 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 &&
|
||||
@@ -124,25 +125,10 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
forwardHaptic(this, "light");
|
||||
const stateDomain = computeStateDomain(this.stateObj);
|
||||
let serviceDomain;
|
||||
let service;
|
||||
|
||||
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 serviceDomain =
|
||||
stateDomain === "group" ? "homeassistant" : stateDomain;
|
||||
const service = getToggleAction(stateDomain, turnOn);
|
||||
|
||||
const currentState = this.stateObj;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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: {
|
||||
@@ -112,6 +113,16 @@ 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
-4
@@ -13,10 +13,7 @@ export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
export { CameraEntityFeature } from "./feature/camera_entity_feature";
|
||||
|
||||
interface CameraEntityAttributes extends HassEntityAttributeBase {
|
||||
model_name: string;
|
||||
|
||||
+1
-12
@@ -68,18 +68,7 @@ export type ClimateEntity = HassEntityBase & {
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
export { ClimateEntityFeature } from "./feature/climate_entity_feature";
|
||||
|
||||
const hvacModeOrdering = HVAC_MODES.reduce(
|
||||
(order, mode, index) => {
|
||||
|
||||
+2
-10
@@ -6,17 +6,9 @@ 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 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 { CoverEntityFeature };
|
||||
|
||||
export const DEFAULT_COVER_FAVORITE_POSITIONS = [0, 25, 75, 100];
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum SirenEntityFeature {
|
||||
TURN_ON = 1,
|
||||
TURN_OFF = 2,
|
||||
TONES = 4,
|
||||
VOLUME_SET = 8,
|
||||
DURATION = 16,
|
||||
}
|
||||
@@ -45,6 +45,69 @@ 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,6 +40,9 @@ 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;
|
||||
@@ -82,29 +85,6 @@ 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";
|
||||
|
||||
+1
-7
@@ -1,7 +1 @@
|
||||
export enum SirenEntityFeature {
|
||||
TURN_ON = 1,
|
||||
TURN_OFF = 2,
|
||||
TONES = 4,
|
||||
VOLUME_SET = 8,
|
||||
DURATION = 16,
|
||||
}
|
||||
export { SirenEntityFeature } from "./feature/siren_entity_feature";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mdiPlus,
|
||||
mdiShape,
|
||||
mdiTune,
|
||||
mdiVectorPolyline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -144,6 +145,10 @@ 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>
|
||||
@@ -252,6 +257,9 @@ 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,6 +27,10 @@ class MatterConfigRouter extends HassRouterPage {
|
||||
tag: "matter-options-page",
|
||||
load: () => import("./matter-options-page"),
|
||||
},
|
||||
visualization: {
|
||||
tag: "matter-network-visualization",
|
||||
load: () => import("./matter-network-visualization"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
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
@@ -0,0 +1,515 @@
|
||||
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,6 +14,7 @@ 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 {
|
||||
@@ -95,12 +96,7 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
const onEntities = this._getOnEntities();
|
||||
const domain = computeDomain(this._config.entities[0]);
|
||||
|
||||
let service: string;
|
||||
if (domain === "cover") {
|
||||
service = onEntities.length > 0 ? "close_cover" : "open_cover";
|
||||
} else {
|
||||
service = onEntities.length > 0 ? "turn_off" : "turn_on";
|
||||
}
|
||||
const service = getToggleAction(domain, onEntities.length === 0);
|
||||
|
||||
this.hass.callService(domain, service, {
|
||||
entity_id: this._config.entities,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 = (
|
||||
@@ -11,9 +12,11 @@ export const turnOnOffEntities = (
|
||||
entityIds.forEach((entityId) => {
|
||||
if (STATES_OFF.includes(hass.states[entityId].state) === turnOn) {
|
||||
const stateDomain = computeDomain(entityId);
|
||||
const serviceDomain = ["cover", "lock"].includes(stateDomain)
|
||||
? stateDomain
|
||||
: "homeassistant";
|
||||
// Entities with non-standard toggle action need separate calls
|
||||
const serviceDomain =
|
||||
getToggleAction(stateDomain, true) !== "turn_on"
|
||||
? stateDomain
|
||||
: "homeassistant";
|
||||
|
||||
if (!(serviceDomain in domainsToCall)) {
|
||||
domainsToCall[serviceDomain] = [];
|
||||
@@ -23,17 +26,7 @@ export const turnOnOffEntities = (
|
||||
});
|
||||
|
||||
Object.keys(domainsToCall).forEach((domain) => {
|
||||
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 service = getToggleAction(domain, turnOn);
|
||||
|
||||
const entities = domainsToCall[domain];
|
||||
hass.callService(domain, service, { entity_id: entities });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { getToggleAction } from "../../../../common/entity/get_toggle_action";
|
||||
import type { HomeAssistant, ServiceCallResponse } from "../../../../types";
|
||||
|
||||
export const turnOnOffEntity = (
|
||||
@@ -9,27 +10,7 @@ export const turnOnOffEntity = (
|
||||
const stateDomain = computeDomain(entityId);
|
||||
const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain;
|
||||
|
||||
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";
|
||||
}
|
||||
const service = getToggleAction(stateDomain, turnOn);
|
||||
|
||||
return hass.callService(serviceDomain, service, { entity_id: entityId });
|
||||
};
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
|
||||
interface CustomizableListSchemaParams {
|
||||
field: string;
|
||||
customize: boolean;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const customizableListSchema = ({
|
||||
field,
|
||||
customize,
|
||||
options,
|
||||
}: CustomizableListSchemaParams) =>
|
||||
[
|
||||
@@ -16,21 +14,18 @@ export const customizableListSchema = ({
|
||||
name: "customize",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
...(customize
|
||||
? ([
|
||||
{
|
||||
name: field,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: field,
|
||||
visible: { field: "customize", value: true },
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
|
||||
// `customize` is form-only and never stored in the config.
|
||||
|
||||
@@ -37,11 +37,7 @@ export class HuiAlarmModesCardFeatureEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity | undefined,
|
||||
customizeModes: boolean
|
||||
) =>
|
||||
(localize: LocalizeFunc, stateObj: HassEntity | undefined) =>
|
||||
[
|
||||
{
|
||||
name: "customize_modes",
|
||||
@@ -49,27 +45,24 @@ export class HuiAlarmModesCardFeatureEditor
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
...(customizeModes
|
||||
? ([
|
||||
{
|
||||
name: "modes",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
reorder: true,
|
||||
options: stateObj
|
||||
? supportedAlarmModes(stateObj).map((mode) => ({
|
||||
value: mode,
|
||||
label: `${localize(
|
||||
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
|
||||
)}`,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "modes",
|
||||
visible: { field: "customize_modes", value: true },
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
reorder: true,
|
||||
options: stateObj
|
||||
? supportedAlarmModes(stateObj).map((mode) => ({
|
||||
value: mode,
|
||||
label: `${localize(
|
||||
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
|
||||
)}`,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
@@ -87,11 +80,7 @@ export class HuiAlarmModesCardFeatureEditor
|
||||
? this.hass.states[this.context?.entity_id]
|
||||
: undefined;
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
stateObj,
|
||||
data.customize_modes
|
||||
);
|
||||
const schema = this._schema(this.hass.localize, stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
+4
-4
@@ -2,6 +2,7 @@ import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -32,13 +33,12 @@ export class HuiCounterActionsCardFeatureEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((customize: boolean) =>
|
||||
private _schema = memoizeOne((localize: LocalizeFunc) =>
|
||||
customizableListSchema({
|
||||
field: "actions",
|
||||
customize,
|
||||
options: COUNTER_ACTIONS.map((action) => ({
|
||||
value: action,
|
||||
label: this.hass!.localize(
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.features.types.counter-actions.actions_list.${action}`
|
||||
),
|
||||
})),
|
||||
@@ -51,7 +51,7 @@ export class HuiCounterActionsCardFeatureEditor
|
||||
}
|
||||
|
||||
const data = customizableListData(this._config, "actions");
|
||||
const schema = this._schema(data.customize);
|
||||
const schema = this._schema(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -61,38 +61,36 @@ export class HuiEnergyDevicesCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((localize: LocalizeFunc, type: string) => {
|
||||
private _schema = memoizeOne((localize: LocalizeFunc) => {
|
||||
const schema: HaFormSchema[] = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
...(type === "energy-devices-graph"
|
||||
? ([
|
||||
{
|
||||
name: "modes",
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
mode: "list",
|
||||
options: chartModeOpts.map((mode) => ({
|
||||
value: mode,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_compound_stats",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "modes",
|
||||
required: false,
|
||||
visible: { field: "type", value: "energy-devices-graph" },
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
mode: "list",
|
||||
options: chartModeOpts.map((mode) => ({
|
||||
value: mode,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_compound_stats",
|
||||
required: false,
|
||||
visible: { field: "type", value: "energy-devices-graph" },
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "max_devices",
|
||||
required: false,
|
||||
@@ -114,7 +112,7 @@ export class HuiEnergyDevicesCardEditor
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = this._schema(this.hass.localize, this._config.type);
|
||||
const schema = this._schema(this.hass.localize);
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
string,
|
||||
union,
|
||||
} from "superstruct";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
@@ -24,6 +23,36 @@ import type {
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
{
|
||||
name: "title",
|
||||
visible: { field: "type", operator: "not_eq", value: "energy-compare" },
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "show_legend",
|
||||
visible: {
|
||||
field: "type",
|
||||
operator: "in",
|
||||
value: ["power-sources-graph", "energy-usage-graph"],
|
||||
},
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "link_dashboard",
|
||||
visible: { field: "type", value: "energy-distribution" },
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
name: "collection_key",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
@@ -72,46 +101,11 @@ export class HuiEnergyGraphCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((type: string) => {
|
||||
const schema: HaFormSchema[] = [
|
||||
...(type !== "energy-compare"
|
||||
? [{ name: "title", selector: { text: {} } }]
|
||||
: []),
|
||||
...(type === "power-sources-graph" || type === "energy-usage-graph"
|
||||
? [
|
||||
{
|
||||
name: "show_legend",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(type === "energy-distribution"
|
||||
? [
|
||||
{
|
||||
name: "link_dashboard",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "string",
|
||||
name: "collection_key",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
return schema;
|
||||
});
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = this._schema(this._config.type);
|
||||
|
||||
const data = {
|
||||
...this._config,
|
||||
};
|
||||
@@ -119,7 +113,7 @@ export class HuiEnergyGraphCardEditor
|
||||
return html` <ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
|
||||
+16
-29
@@ -74,11 +74,7 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity | undefined,
|
||||
customizeModes: boolean
|
||||
) => {
|
||||
(localize: LocalizeFunc, stateObj: HassEntity | undefined) => {
|
||||
const d = this.descriptor;
|
||||
const styleListId = d.styleListI18nFeatureId ?? d.i18nFeatureId;
|
||||
return [
|
||||
@@ -103,25 +99,20 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
...(customizeModes
|
||||
? ([
|
||||
{
|
||||
name: d.modeField,
|
||||
selector: {
|
||||
select: {
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options: d
|
||||
.getAvailableModesOrdered(stateObj)
|
||||
.map((mode) => ({
|
||||
value: mode,
|
||||
label: d.getModeLabel(this.hass!, stateObj, mode),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: d.modeField,
|
||||
visible: { field: "customize_modes", value: true },
|
||||
selector: {
|
||||
select: {
|
||||
reorder: true,
|
||||
multiple: true,
|
||||
options: d.getAvailableModesOrdered(stateObj).map((mode) => ({
|
||||
value: mode,
|
||||
label: d.getModeLabel(this.hass!, stateObj, mode),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
}
|
||||
);
|
||||
@@ -143,11 +134,7 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
|
||||
customize_modes: this._config[modeField as keyof TConfig] !== undefined,
|
||||
};
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
stateObj,
|
||||
data.customize_modes
|
||||
);
|
||||
const schema = this._schema(this.hass.localize, stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -80,7 +80,7 @@ export class HuiGaugeCardEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(showSeverity: boolean, entityId?: string) =>
|
||||
(entityId?: string) =>
|
||||
[
|
||||
{
|
||||
name: "entity",
|
||||
@@ -132,28 +132,25 @@ export class HuiGaugeCardEditor
|
||||
{ name: "show_severity", selector: { boolean: {} } },
|
||||
],
|
||||
},
|
||||
...(showSeverity
|
||||
? ([
|
||||
{
|
||||
name: "severity",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "green",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "yellow",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "red",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
{
|
||||
name: "severity",
|
||||
type: "grid",
|
||||
visible: { field: "show_severity", value: true },
|
||||
schema: [
|
||||
{
|
||||
name: "green",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "yellow",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "red",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
@@ -197,10 +194,7 @@ export class HuiGaugeCardEditor
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = this._schema(
|
||||
this._config!.severity !== undefined,
|
||||
this._config!.entity
|
||||
);
|
||||
const schema = this._schema(this._config!.entity);
|
||||
const data = {
|
||||
show_severity: this._config!.severity !== undefined,
|
||||
...this._config,
|
||||
|
||||
+13
-15
@@ -34,20 +34,18 @@ export class HuiLawnMowerCommandsCardFeatureEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(stateObj: HassEntity | undefined, customize: boolean) =>
|
||||
customizableListSchema({
|
||||
field: "commands",
|
||||
customize,
|
||||
options: LAWN_MOWER_COMMANDS.filter(
|
||||
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
|
||||
).map((command) => ({
|
||||
value: command,
|
||||
label: this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
|
||||
),
|
||||
})),
|
||||
})
|
||||
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
|
||||
customizableListSchema({
|
||||
field: "commands",
|
||||
options: LAWN_MOWER_COMMANDS.filter(
|
||||
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
|
||||
).map((command) => ({
|
||||
value: command,
|
||||
label: this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
|
||||
),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
@@ -60,7 +58,7 @@ export class HuiLawnMowerCommandsCardFeatureEditor
|
||||
: undefined;
|
||||
|
||||
const data = customizableListData(this._config, "commands");
|
||||
const schema = this._schema(stateObj, data.customize);
|
||||
const schema = this._schema(stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -51,7 +51,7 @@ export class HuiMarkdownCardEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc, text_only: boolean) =>
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "style",
|
||||
@@ -73,9 +73,11 @@ export class HuiMarkdownCardEditor
|
||||
},
|
||||
},
|
||||
},
|
||||
...(!text_only
|
||||
? ([{ name: "title", selector: { text: {} } }] as const)
|
||||
: []),
|
||||
{
|
||||
name: "title",
|
||||
visible: { field: "style", operator: "not_eq", value: "text-only" },
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
required: true,
|
||||
@@ -131,10 +133,7 @@ export class HuiMarkdownCardEditor
|
||||
style: this._config.text_only ? "text-only" : "card",
|
||||
};
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
this._config.text_only || false
|
||||
);
|
||||
const schema = this._schema(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
+2
-3
@@ -38,11 +38,10 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
|
||||
(stateObj: MediaPlayerEntity | undefined) =>
|
||||
[
|
||||
...customizableListSchema({
|
||||
field: "controls",
|
||||
customize,
|
||||
options: MEDIA_PLAYER_PLAYBACK_CONTROLS.filter(
|
||||
(control) =>
|
||||
stateObj && supportsMediaPlayerPlaybackControl(stateObj, control)
|
||||
@@ -69,7 +68,7 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
|
||||
: undefined;
|
||||
|
||||
const data = customizableListData(this._config, "controls");
|
||||
const schema = this._schema(stateObj, data.customize);
|
||||
const schema = this._schema(stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
+14
-16
@@ -32,21 +32,19 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
|
||||
customizableListSchema({
|
||||
field: "sound_modes",
|
||||
customize,
|
||||
options:
|
||||
stateObj?.attributes.sound_mode_list?.map((mode) => ({
|
||||
value: mode,
|
||||
label: this.hass!.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"sound_mode",
|
||||
mode
|
||||
),
|
||||
})) ?? [],
|
||||
})
|
||||
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
|
||||
customizableListSchema({
|
||||
field: "sound_modes",
|
||||
options:
|
||||
stateObj?.attributes.sound_mode_list?.map((mode) => ({
|
||||
value: mode,
|
||||
label: this.hass!.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"sound_mode",
|
||||
mode
|
||||
),
|
||||
})) ?? [],
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
@@ -60,7 +58,7 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
|
||||
: undefined;
|
||||
|
||||
const data = customizableListData(this._config, "sound_modes");
|
||||
const schema = this._schema(stateObj, data.customize);
|
||||
const schema = this._schema(stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
+14
-16
@@ -32,21 +32,19 @@ export class HuiMediaPlayerSourceCardFeatureEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
|
||||
customizableListSchema({
|
||||
field: "sources",
|
||||
customize,
|
||||
options:
|
||||
stateObj?.attributes.source_list?.map((source) => ({
|
||||
value: source,
|
||||
label: this.hass!.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"source",
|
||||
source
|
||||
),
|
||||
})) ?? [],
|
||||
})
|
||||
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
|
||||
customizableListSchema({
|
||||
field: "sources",
|
||||
options:
|
||||
stateObj?.attributes.source_list?.map((source) => ({
|
||||
value: source,
|
||||
label: this.hass!.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"source",
|
||||
source
|
||||
),
|
||||
})) ?? [],
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
@@ -60,7 +58,7 @@ export class HuiMediaPlayerSourceCardFeatureEditor
|
||||
: undefined;
|
||||
|
||||
const data = customizableListData(this._config, "sources");
|
||||
const schema = this._schema(stateObj, data.customize);
|
||||
const schema = this._schema(stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
+18
-18
@@ -38,15 +38,10 @@ export class HuiPrecipitationForecastCardFeatureEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
stateObj: HassEntity | undefined,
|
||||
forecastType: ForecastResolution,
|
||||
localize: HomeAssistant["localize"]
|
||||
) => {
|
||||
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
|
||||
const supportedTypes = stateObj
|
||||
? getSupportedForecastTypes(stateObj)
|
||||
: [];
|
||||
const isHourly = forecastType === "hourly";
|
||||
return [
|
||||
{
|
||||
name: "forecast_type",
|
||||
@@ -67,17 +62,22 @@ export class HuiPrecipitationForecastCardFeatureEditor
|
||||
},
|
||||
},
|
||||
},
|
||||
isHourly
|
||||
? {
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
}
|
||||
: {
|
||||
name: "days_to_show",
|
||||
default: DEFAULT_DAYS_TO_SHOW,
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
visible: { field: "forecast_type", value: "hourly" },
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "days_to_show",
|
||||
default: DEFAULT_DAYS_TO_SHOW,
|
||||
visible: {
|
||||
field: "forecast_type",
|
||||
operator: "not_eq",
|
||||
value: "hourly",
|
||||
},
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "precipitation_type",
|
||||
required: true,
|
||||
@@ -141,7 +141,7 @@ export class HuiPrecipitationForecastCardFeatureEditor
|
||||
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
|
||||
};
|
||||
|
||||
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
|
||||
const schema = this._schema(stateObj, this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
+17
-25
@@ -38,8 +38,7 @@ export class HuiSelectOptionsCardFeatureEditor
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
formatEntityState: FormatEntityStateFunc,
|
||||
stateObj: HassEntity | undefined,
|
||||
customizeOptions: boolean
|
||||
stateObj: HassEntity | undefined
|
||||
) =>
|
||||
[
|
||||
{
|
||||
@@ -48,24 +47,21 @@ export class HuiSelectOptionsCardFeatureEditor
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
...(customizeOptions
|
||||
? ([
|
||||
{
|
||||
name: "options",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
reorder: true,
|
||||
options:
|
||||
stateObj?.attributes.options?.map((option) => ({
|
||||
value: option,
|
||||
label: formatEntityState(stateObj, option),
|
||||
})) || [],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "options",
|
||||
visible: { field: "customize_options", value: true },
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
reorder: true,
|
||||
options:
|
||||
stateObj?.attributes.options?.map((option) => ({
|
||||
value: option,
|
||||
label: formatEntityState(stateObj, option),
|
||||
})) || [],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
@@ -83,11 +79,7 @@ export class HuiSelectOptionsCardFeatureEditor
|
||||
customize_options: this._config.options !== undefined,
|
||||
};
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.formatEntityState,
|
||||
stateObj,
|
||||
data.customize_options
|
||||
);
|
||||
const schema = this._schema(this.hass.formatEntityState, stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -157,8 +157,6 @@ export class HuiStatisticsGraphCardEditor
|
||||
localize: LocalizeFunc,
|
||||
statisticIds: string[] | undefined,
|
||||
metaDatas: StatisticsMetaData[] | undefined,
|
||||
showFitOption: boolean,
|
||||
hiddenLegend: boolean,
|
||||
enableDateSelect: boolean
|
||||
) => {
|
||||
const units = new Set<string>();
|
||||
@@ -291,15 +289,18 @@ export class HuiStatisticsGraphCardEditor
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
...(showFitOption
|
||||
? [
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
visible: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "min_y_axis", operator: "exists" },
|
||||
{ field: "max_y_axis", operator: "exists" },
|
||||
],
|
||||
},
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "logarithmic_scale",
|
||||
required: false,
|
||||
@@ -310,15 +311,16 @@ export class HuiStatisticsGraphCardEditor
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
...(!hiddenLegend
|
||||
? [
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
visible: {
|
||||
field: "hide_legend",
|
||||
operator: "not_eq",
|
||||
value: true,
|
||||
},
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -397,9 +399,6 @@ export class HuiStatisticsGraphCardEditor
|
||||
this.hass.localize,
|
||||
this._configEntities,
|
||||
this._metaDatas,
|
||||
this._config!.min_y_axis !== undefined ||
|
||||
this._config!.max_y_axis !== undefined,
|
||||
!!this._config!.hide_legend,
|
||||
!!this._config!.energy_date_selection
|
||||
);
|
||||
const configured_stat_types = this._config!.stat_types
|
||||
|
||||
+18
-18
@@ -38,15 +38,10 @@ export class HuiTemperatureForecastCardFeatureEditor
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
stateObj: HassEntity | undefined,
|
||||
forecastType: ForecastResolution,
|
||||
localize: HomeAssistant["localize"]
|
||||
) => {
|
||||
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
|
||||
const supportedTypes = stateObj
|
||||
? getSupportedForecastTypes(stateObj)
|
||||
: [];
|
||||
const isHourly = forecastType === "hourly";
|
||||
return [
|
||||
{
|
||||
name: "forecast_type",
|
||||
@@ -67,17 +62,22 @@ export class HuiTemperatureForecastCardFeatureEditor
|
||||
},
|
||||
},
|
||||
},
|
||||
isHourly
|
||||
? {
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
}
|
||||
: {
|
||||
name: "days_to_show",
|
||||
default: DEFAULT_DAYS_TO_SHOW,
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
visible: { field: "forecast_type", value: "hourly" },
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "days_to_show",
|
||||
default: DEFAULT_DAYS_TO_SHOW,
|
||||
visible: {
|
||||
field: "forecast_type",
|
||||
operator: "not_eq",
|
||||
value: "hourly",
|
||||
},
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
@@ -117,7 +117,7 @@ export class HuiTemperatureForecastCardFeatureEditor
|
||||
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
|
||||
};
|
||||
|
||||
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
|
||||
const schema = this._schema(stateObj, this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
+13
-15
@@ -37,20 +37,18 @@ export class HuiVacuumCommandsCardFeatureEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(stateObj: HassEntity | undefined, customize: boolean) =>
|
||||
customizableListSchema({
|
||||
field: "commands",
|
||||
customize,
|
||||
options: VACUUM_COMMANDS.filter(
|
||||
(command) => stateObj && supportsVacuumCommand(stateObj, command)
|
||||
).map((command) => ({
|
||||
value: command,
|
||||
label: this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
|
||||
),
|
||||
})),
|
||||
})
|
||||
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
|
||||
customizableListSchema({
|
||||
field: "commands",
|
||||
options: VACUUM_COMMANDS.filter(
|
||||
(command) => stateObj && supportsVacuumCommand(stateObj, command)
|
||||
).map((command) => ({
|
||||
value: command,
|
||||
label: this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
|
||||
),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
@@ -63,7 +61,7 @@ export class HuiVacuumCommandsCardFeatureEditor
|
||||
: undefined;
|
||||
|
||||
const data = customizableListData(this._config, "commands");
|
||||
const schema = this._schema(stateObj, data.customize);
|
||||
const schema = this._schema(stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
|
||||
@@ -8328,6 +8328,7 @@
|
||||
"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}",
|
||||
@@ -8376,6 +8377,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,6 +35,7 @@ describe("canToggleDomain", () => {
|
||||
services: {
|
||||
cover: {
|
||||
open_cover: null,
|
||||
close_cover: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
@@ -62,6 +63,7 @@ describe("canToggleDomain", () => {
|
||||
services: {
|
||||
lock: {
|
||||
lock: null,
|
||||
unlock: null,
|
||||
},
|
||||
},
|
||||
} as unknown as HomeAssistant,
|
||||
@@ -81,4 +83,59 @@ 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"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
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