Compare commits

...

7 Commits

Author SHA1 Message Date
Petar Petrov f9d8a86905 Refine Matter graph HA node: theme-aware memoization, drop dead branch 2026-07-23 10:38:57 +03:00
Petar Petrov 0f75e1074b Add central Home Assistant node to Matter network graph 2026-07-23 10:24:13 +03:00
Petar Petrov 300b34f876 Add Matter network topology visualization page 2026-07-23 09:44:11 +03:00
karwosts 1f14f8689c Cleanup some toggle behavior (#53242)
* Cleanup some toggle behavior

* fix tests

* prior behavior did not consider off

* Revert "prior behavior did not consider off"

This reverts commit 59cc93d308.

* Attempt rearranging features to address bundle growth failure

* Revert a no-longer-needed change to setup.ts

* Add test

* test

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-23 06:09:03 +00:00
karwosts 56966ee04b Add uom to selector-selector number (#53245) 2026-07-23 08:45:44 +03:00
Aidan Timson 598a987ba5 Share dependencies across E2E jobs (#53114)
* Share dependencies across E2E jobs

* Add container-compatible dependency cache

* Use one dependency cache key

* Centralise dependency cache preparation

* Separate Playwright dependency cache
2026-07-23 08:19:23 +03:00
Paul Bottein 8775d3f4c5 Use ha-form conditional field visibility in dashboard editors (#53239)
* Use ha-form conditional field visibility in dashboard editors

* Keep view and section editors on localize context
2026-07-22 16:09:17 +01:00
50 changed files with 2051 additions and 512 deletions
@@ -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/**') }}
+4 -1
View File
@@ -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
+2 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+4 -14
View File
@@ -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;
};
+7 -5
View File
@@ -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)
);
}
+5 -5
View File
@@ -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";
+72
View File
@@ -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 -4
View File
@@ -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";
}
}
+4 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
+10
View File
@@ -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,
}
+7
View File
@@ -0,0 +1,7 @@
export enum SirenEntityFeature {
TURN_ON = 1,
TURN_OFF = 2,
TONES = 4,
VOLUME_SET = 8,
DURATION = 16,
}
+63
View File
@@ -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;
+3 -23
View File
@@ -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
View File
@@ -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 };
}
@@ -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
@@ -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}
@@ -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,
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+42
View File
@@ -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");
});
});