Compare commits

...

7 Commits

Author SHA1 Message Date
Paulus Schoutsen 6714608627 Update src/panels/config/integrations/integration-panels/radio_frequency/radio-frequency-transmitters.ts
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-17 06:43:56 -04:00
Paulus Schoutsen 530af3112c Add transmitters list to radio frequency panel
Show transmitter status and a devices data table (name, type, last used)
with links to the device info page, and use the radio tower domain icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:52:57 -04:00
copilot-swe-agent[bot] 261e08d0ed Restore page filter check in canShowPage 2026-06-06 16:25:49 +00:00
copilot-swe-agent[bot] 2763ed22b8 Align canShowPage with dev PageNavigation type 2026-06-06 15:42:12 +00:00
copilot-swe-agent[bot] 359b56e7f3 Merge origin/dev into rf-panel 2026-06-06 15:33:04 +00:00
Paulus Schoutsen 0751aa0b66 Tweaks 2026-06-05 22:20:20 -04:00
Paulus Schoutsen 37100069ac Add rf panel 2026-06-03 21:12:28 +02:00
9 changed files with 468 additions and 3 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
isCore(page) || isLoadedIntegration(hass, page);
(isCore(page) || isLoadedIntegration(hass, page)) &&
(!page.filter || page.filter(hass));
export const isLoadedIntegration = (
hass: HomeAssistant,
+2 -2
View File
@@ -39,6 +39,7 @@ import {
mdiMicrophoneMessage,
mdiMotionSensor,
mdiPalette,
mdiRadioTower,
mdiRayVertex,
mdiRemote,
mdiRobot,
@@ -52,7 +53,6 @@ import {
mdiThermostat,
mdiTimerOutline,
mdiToggleSwitch,
mdiVideoInputAntenna,
mdiWater,
mdiWaterPercent,
mdiWeatherPartlyCloudy,
@@ -129,7 +129,7 @@ export const FALLBACK_DOMAIN_ICONS = {
plant: mdiFlower,
power: mdiFlash,
proximity: mdiAppleSafari,
radio_frequency: mdiVideoInputAntenna,
radio_frequency: mdiRadioTower,
remote: mdiRemote,
scene: mdiPalette,
schedule: mdiCalendarClock,
+22
View File
@@ -0,0 +1,22 @@
import type { HomeAssistant } from "../types";
export const DOMAIN = "radio_frequency";
export interface RadioFrequencyTransmitter {
entity_id: string;
device_id: string | null;
config_entry_id: string | null;
supported_frequency_ranges: [number, number][];
supported_modulations: string[];
}
interface RadioFrequencyTransmitterList {
transmitters: RadioFrequencyTransmitter[];
}
export const fetchRadioFrequencyTransmitters = (
hass: HomeAssistant
): Promise<RadioFrequencyTransmitterList> =>
hass.callWS({
type: "radio_frequency/list",
});
+1
View File
@@ -36,6 +36,7 @@ export interface PageNavigation {
core?: boolean;
/** Hide from non-admin users in filtered navigation and quick bar. */
adminOnly?: boolean;
filter?: (hass: HomeAssistant) => boolean;
iconPath?: string;
iconSecondaryPath?: string;
iconViewBox?: string;
+24
View File
@@ -20,6 +20,7 @@ import {
mdiPalette,
mdiPaletteSwatch,
mdiPuzzle,
mdiRadioTower,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
@@ -35,6 +36,7 @@ import {
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { CloudStatus } from "../../data/cloud";
@@ -55,6 +57,14 @@ declare global {
}
}
const getHasDomainCheck = (domain: string) => {
const prefix = `${domain}.`;
const checkRegistry = memoizeOne((entries: HomeAssistant["entities"]) =>
Object.values(entries).some((entry) => entry.entity_id.startsWith(prefix))
);
return (hass: HomeAssistant) => checkRegistry(hass.entities);
};
export const configSections: Record<string, PageNavigation[]> = {
dashboard: [
{
@@ -166,6 +176,15 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "bluetooth",
adminOnly: true,
},
{
path: "/config/radio-frequency",
iconPath: mdiRadioTower,
iconColor: "#E74011",
component: "radio_frequency",
translationKey: "radio_frequency",
adminOnly: true,
filter: getHasDomainCheck("radio_frequency"),
},
{
path: "/insteon",
iconPath:
@@ -667,6 +686,11 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-section-updates",
load: () => import("./core/ha-config-section-updates"),
},
"radio-frequency": {
tag: "radio-frequency-config-dashboard-router",
load: () =>
import("./integrations/integration-panels/radio_frequency/radio-frequency-config-dashboard-router"),
},
repairs: {
tag: "ha-config-repairs-dashboard",
load: () => import("./repairs/ha-config-repairs-dashboard"),
@@ -0,0 +1,41 @@
import { customElement, property } from "lit/decorators";
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../../../types";
@customElement("radio-frequency-config-dashboard-router")
class RadioFrequencyConfigDashboardRouter extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
showLoading: true,
routes: {
dashboard: {
tag: "radio-frequency-config-dashboard",
load: () => import("./radio-frequency-config-dashboard"),
},
transmitters: {
tag: "radio-frequency-transmitters",
load: () => import("./radio-frequency-transmitters"),
},
},
};
protected updatePageEl(el): void {
el.route = this.routeTail;
el.hass = this.hass;
el.isWide = this.isWide;
el.narrow = this.narrow;
}
}
declare global {
interface HTMLElementTagNameMap {
"radio-frequency-config-dashboard-router": RadioFrequencyConfigDashboardRouter;
}
}
@@ -0,0 +1,211 @@
import { mdiCheck, mdiCloseCircleOutline } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import { UNAVAILABLE } from "../../../../../data/entity/entity";
import { FALLBACK_DOMAIN_ICONS } from "../../../../../data/icons";
import type { RadioFrequencyTransmitter } from "../../../../../data/radio_frequency";
import {
DOMAIN,
fetchRadioFrequencyTransmitters,
} from "../../../../../data/radio_frequency";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
@customElement("radio-frequency-config-dashboard")
export class RadioFrequencyConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _transmitters: RadioFrequencyTransmitter[] = [];
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._fetchTransmitters();
}
}
private async _fetchTransmitters(): Promise<void> {
const result = await fetchRadioFrequencyTransmitters(this.hass);
this._transmitters = result.transmitters;
}
protected render(): TemplateResult {
const total = this._transmitters.length;
const online = this._transmitters.filter((transmitter) => {
const stateObj = this.hass.states[transmitter.entity_id];
return stateObj && stateObj.state !== UNAVAILABLE;
}).length;
const isOffline = online === 0;
const status = isOffline ? "offline" : "online";
const statusIcon = isOffline ? mdiCloseCircleOutline : mdiCheck;
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.radio_frequency.title")}
back-path="/config"
>
<div class="container">
<ha-card class="network-status">
<div class="card-content">
<div class="heading">
<div class="icon ${status}">
<ha-svg-icon .path=${statusIcon}></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(
`ui.panel.config.radio_frequency.status_${status}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.radio_frequency.transmitters_summary",
{ online, total }
)}
</small>
</div>
<ha-svg-icon
class="logo"
.path=${FALLBACK_DOMAIN_ICONS[DOMAIN]}
></ha-svg-icon>
</div>
</div>
</ha-card>
<ha-card class="network-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href="/config/radio-frequency/transmitters"
>
<ha-svg-icon
slot="start"
.path=${FALLBACK_DOMAIN_ICONS[DOMAIN]}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.radio_frequency.devices_count",
{ count: total }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card {
margin: 0px auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding: 0;
}
.network-card {
overflow: hidden;
}
.network-card .card-content {
padding: 0;
}
.network-status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.network-status div.heading .logo {
margin-inline-start: auto;
--mdc-icon-size: 40px;
}
.network-status div.heading .icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.network-status div.heading .icon.online {
--icon-color: var(--success-color);
}
.network-status div.heading .icon.offline {
--icon-color: var(--error-color);
}
.network-status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color, var(--primary-color));
opacity: 0.2;
}
.network-status div.heading .icon ha-svg-icon {
color: var(--icon-color, var(--primary-color));
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.network-status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.network-status small {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"radio-frequency-config-dashboard": RadioFrequencyConfigDashboard;
}
}
@@ -0,0 +1,145 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { navigate } from "../../../../../common/navigate";
import { computeStateName } from "../../../../../common/entity/compute_state_name";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../../components/data-table/ha-data-table";
import type { RadioFrequencyTransmitter } from "../../../../../data/radio_frequency";
import { fetchRadioFrequencyTransmitters } from "../../../../../data/radio_frequency";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
interface RadioFrequencyTransmitterRow {
id: string;
name: string;
type: string;
last_used: string;
device_id: string | null;
}
@customElement("radio-frequency-transmitters")
export class RadioFrequencyTransmitters extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _transmitters: RadioFrequencyTransmitter[] = [];
private _tabs: PageNavigation[] = [
{
translationKey: "ui.panel.config.radio_frequency.navigation.transmitters",
path: "/config/radio-frequency/transmitters",
},
];
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._fetchTransmitters();
}
}
private async _fetchTransmitters(): Promise<void> {
const result = await fetchRadioFrequencyTransmitters(this.hass);
this._transmitters = result.transmitters;
}
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<RadioFrequencyTransmitterRow> = {
name: {
title: localize("ui.panel.config.radio_frequency.name"),
sortable: true,
filterable: true,
showNarrow: true,
main: true,
flex: 2,
direction: "asc",
},
type: {
title: localize("ui.panel.config.radio_frequency.type"),
sortable: true,
filterable: true,
groupable: true,
},
last_used: {
title: localize("ui.panel.config.radio_frequency.last_used"),
sortable: true,
filterable: true,
},
};
return columns;
}
);
private _data = memoizeOne(
(
transmitters: RadioFrequencyTransmitter[],
states: HomeAssistant["states"],
localize: LocalizeFunc
): RadioFrequencyTransmitterRow[] =>
transmitters.map((transmitter) => {
const stateObj = states[transmitter.entity_id];
return {
id: transmitter.entity_id,
name: stateObj ? computeStateName(stateObj) : transmitter.entity_id,
type: localize("ui.panel.config.radio_frequency.type_transmitter"),
last_used: stateObj ? this.hass.formatEntityState(stateObj) : "—",
device_id: transmitter.device_id,
};
})
);
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${this._tabs}
back-path="/config/radio-frequency/dashboard"
.columns=${this._columns(this.hass.localize)}
.data=${this._data(
this._transmitters,
this.hass.states,
this.hass.localize
)}
.noDataText=${this.hass.localize(
"ui.panel.config.radio_frequency.no_transmitters"
)}
@row-click=${this._handleRowClicked}
clickable
></hass-tabs-subpage-data-table>
`;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const transmitter = this._transmitters.find(
(t) => t.entity_id === ev.detail.id
);
if (transmitter?.device_id) {
navigate(`/config/devices/device/${transmitter.device_id}`);
}
}
static styles: CSSResultGroup = haStyle;
}
declare global {
interface HTMLElementTagNameMap {
"radio-frequency-transmitters": RadioFrequencyTransmitters;
}
}
+20
View File
@@ -1616,6 +1616,7 @@
"zwave_js": "[%key:ui::panel::config::dashboard::zwave_js::main%]",
"thread": "[%key:ui::panel::config::dashboard::thread::main%]",
"bluetooth": "[%key:ui::panel::config::dashboard::bluetooth::main%]",
"radio_frequency": "[%key:ui::panel::config::dashboard::radio_frequency::main%]",
"knx": "[%key:ui::panel::config::dashboard::knx::main%]",
"insteon": "[%key:ui::panel::config::dashboard::insteon::main%]",
"voice-assistants": "[%key:ui::panel::config::dashboard::voice_assistants::main%]",
@@ -2718,6 +2719,10 @@
"main": "Bluetooth",
"secondary": "Local device connectivity"
},
"radio_frequency": {
"main": "Radio frequency",
"secondary": "Control radio-based devices."
},
"knx": {
"main": "KNX",
"secondary": "Building automation standard"
@@ -7187,6 +7192,21 @@
"known_devices": "Known devices",
"unknown_devices": "Unknown devices"
},
"radio_frequency": {
"title": "Radio frequency",
"status_online": "Online",
"status_offline": "Offline",
"transmitters_summary": "{online}/{total} transmitters online",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"no_transmitters": "No radio frequency transmitters found",
"name": "Name",
"type": "Type",
"type_transmitter": "Transmitter",
"last_used": "Last used",
"navigation": {
"transmitters": "Transmitters"
}
},
"dhcp": {
"title": "DHCP discovery",
"mac_address": "MAC address",