diff --git a/src/knx-router.ts b/src/knx-router.ts index e7f81e12..29ab2392 100644 --- a/src/knx-router.ts +++ b/src/knx-router.ts @@ -1,4 +1,4 @@ -import { mdiNetwork, mdiFolderMultipleOutline } from "@mdi/js"; +import { mdiCogOutline, mdiNetwork, mdiFolderMultipleOutline } from "@mdi/js"; import { customElement, property, state } from "lit/decorators"; import { HassRouterPage, RouterOptions } from "@ha/layouts/hass-router-page"; @@ -16,6 +16,11 @@ export const knxMainTabs: PageNavigation[] = [ path: `/knx/info`, iconPath: mdiFolderMultipleOutline, }, + { + translationKey: "settings_title", + path: `/knx/settings`, + iconPath: mdiCogOutline, + }, { translationKey: "group_monitor_title", path: `/knx/group_monitor`, @@ -43,14 +48,21 @@ class KnxRouter extends HassRouterPage { info: { tag: "knx-info", load: () => { - logger.info("Importing knx-info"); + logger.info("Importing info view"); return import("./views/info"); }, }, + settings: { + tag: "knx-settings", + load: () => { + logger.info("Importing settings view"); + return import("./views/settings"); + }, + }, group_monitor: { tag: "knx-group-monitor", load: () => { - logger.info("Importing knx-group-monitor"); + logger.info("Importing group-monitor view"); return import("./views/group_monitor"); }, }, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index c0f99a5a..80ee4e0f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -16,6 +16,7 @@ "info_issue_tracker_knx_frontend": "the KNX frontend panel", "info_issue_tracker_xknxproject": "project data parsing", "info_issue_tracker_xknx": "KNX connection or DPT decoding", + "settings_title": "Settings", "group_monitor_title": "Group Monitor", "group_monitor_time": "Time", "group_monitor_direction": "Direction", diff --git a/src/services/websocket.service.ts b/src/services/websocket.service.ts index 3646ce2a..432e00bc 100644 --- a/src/services/websocket.service.ts +++ b/src/services/websocket.service.ts @@ -1,5 +1,14 @@ import { HomeAssistant } from "@ha/types"; -import { KNXInfoData, TelegramDict, GroupMonitorInfoData } from "../types/websocket"; +import { + SettingsInfoData, + KNXInfoData, + TelegramDict, + GatewayDescriptor, + GroupMonitorInfoData, + ConfigEntryData, +} from "../types/websocket"; + +// INFO export const getKnxInfoData = (hass: HomeAssistant): Promise => hass.callWS({ @@ -22,6 +31,50 @@ export const removeProjectFile = (hass: HomeAssistant): Promise => type: "knx/project_file_remove", }); +// SETTINGS + +export const getSettingsInfoData = (hass: HomeAssistant): Promise => + hass.callWS({ + type: "knx/settings/info", + }); + +export const subscribeGatewayScanner = ( + hass: HomeAssistant, + local_interface: string | null, + callback: (gateway: GatewayDescriptor) => void, +) => + hass.connection.subscribeMessage(callback, { + type: "knx/settings/subscribe_gateway_scanner", + local_interface: local_interface, + }); + +export const processKeyringFile = ( + hass: HomeAssistant, + file_id: string, + password: string, +): Promise => + hass.callWS({ + type: "knx/settings/keyring_file_process", + file_id: file_id, + password: password, + }); + +export const removeKeyringFile = (hass: HomeAssistant): Promise => + hass.callWS({ + type: "knx/settings/keyring_file_remove", + }); + +export const writeConnectionData = ( + hass: HomeAssistant, + changeset: Partial, +): Promise => + hass.callWS({ + type: "knx/settings/write_config_entry_data", + changeset: changeset, + }); + +// GROUP MONITOR + export const getGroupMonitorInfo = (hass: HomeAssistant): Promise => hass.callWS({ type: "knx/group_monitor_info", diff --git a/src/types/websocket.ts b/src/types/websocket.ts index 3a5c1d51..bb90c2e3 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -32,3 +32,93 @@ export interface TelegramDict { unit: string | null; value: string | number | boolean | null; } + +export const enum ConnectionType { + Automatic = "automatic", + RoutingPlain = "routing", + RoutingSecure = "routing_secure", + TunnellingUDP = "tunneling", + TunnellingTCP = "tunneling_tcp", + TunnellingSecure = "tunneling_tcp_secure", +} + +export interface SettingsInfoData { + config_entry: ConfigEntryData; + local_interfaces: string[]; + keyfile_data: KeyfileData | null; +} + +export type ConfigEntryData = ConnectionData & IntegrationSettingsData; + +export interface ConnectionData { + connection_type: ConnectionType; + individual_address?: string; + local_ip?: string | null; // not required + multicast_group: string | null; + multicast_port: number; + route_back?: boolean | null; // not required + host?: string | null; // only required for tunnelling + port?: number | null; // only required for tunnelling + tunnel_endpoint_ia?: string | null; + // KNX secure + user_id?: number | null; // not required + user_password?: string | null; // not required + device_authentication?: string | null; // not required + knxkeys_filename?: string; // not required + knxkeys_password?: string; // not required + backbone_key?: string | null; // not required + sync_latency_tolerance?: number | null; // not required +} + +export interface IntegrationSettingsData { + // OptionsFlow only + state_updater: boolean; + rate_limit?: number; + // Integration only (not forwarded to xknx) + telegram_log_size?: number; // not required +} + +export interface KeyfileData { + project_name: string; + timestamp: string; + created_by: string; + secure_backbone: SecureBackbone | null; + tunnel_interfaces: TunnelInterface[]; + ds_group_addresses: string[]; // ["1/2/3"] +} + +export interface SecureBackbone { + multicast_address: string; + latency: number; +} + +export interface TunnelInterface { + host: string; // "1.1.10" + individual_address: string; + user_id: number | null; // no user_id -> plain tunnelling + ds_group_addresses: string[]; +} + +export interface GatewayDescriptor { + name: string; + ip_addr: string; + port: number; + individual_address: string; // todo convert + local_interface: string; + local_ip: string; + supports_routing: boolean; + supports_tunnelling: boolean; + supports_tunnelling_tcp: boolean; + supports_secure: boolean; + core_version: number; + routing_requires_secure: boolean; // todo unwrap + tunnelling_requires_secure: boolean; // todo unwrap + tunnelling_slots: TunnelingSlot[]; // todo flatten +} + +interface TunnelingSlot { + individual_address: string; + authorized: boolean; + free: boolean; + usable: boolean; +} diff --git a/src/views/settings.ts b/src/views/settings.ts new file mode 100644 index 00000000..8a96ca04 --- /dev/null +++ b/src/views/settings.ts @@ -0,0 +1,772 @@ +import { mdiFileUpload } from "@mdi/js"; +import { css, nothing, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; + +import "@ha/components/ha-card"; +import "@ha/components/ha-expansion-panel"; +import "@ha/layouts/hass-tabs-subpage"; +import "@ha/components/ha-button"; +import "@ha/components/buttons/ha-progress-button"; +import "@ha/components/ha-file-upload"; +import "@ha/components/ha-selector/ha-selector"; +import { uploadFile } from "@ha/data/file_upload"; +import { extractApiErrorMessage } from "@ha/data/hassio/common"; +import { SelectSelector } from "@ha/data/selector"; +import { showAlertDialog, showConfirmationDialog } from "@ha/dialogs/generic/show-dialog-box"; +import { HomeAssistant, Route } from "@ha/types"; +import { haStyle } from "@ha/resources/styles"; + +import { knxMainTabs } from "../knx-router"; +import { + getSettingsInfoData, + subscribeGatewayScanner, + writeConnectionData, + processKeyringFile, + removeKeyringFile, +} from "../services/websocket.service"; + +import { KNX } from "../types/knx"; +import { + ConnectionType, + SettingsInfoData, + IntegrationSettingsData, + ConnectionData, + KeyfileData, +} from "../types/websocket"; +import { KNXLogger } from "../tools/knx-logger"; + +const logger = new KNXLogger("settings"); + +const enum ConnectionMainType { + Automatic = "automatic", + Tunnelling = "tunnelling", + Routing = "routing", +} + +const connectionTypeSelector: SelectSelector = { + select: { + multiple: false, + custom_value: false, + mode: "list", + options: [ + { value: ConnectionMainType.Automatic, label: "Automatic" }, + { value: ConnectionMainType.Tunnelling, label: "Tunnelling" }, + { value: ConnectionMainType.Routing, label: "Routing" }, + ], + }, +}; + +const connectionTunnellingSelector: SelectSelector = { + select: { + multiple: false, + custom_value: false, + mode: "dropdown", + options: [ + { value: ConnectionType.TunnellingUDP, label: "UDP" }, + { value: ConnectionType.TunnellingTCP, label: "TCP" }, + { value: ConnectionType.TunnellingSecure, label: "Secure" }, + ], + }, +}; + +@customElement("knx-settings") +export class KNXSettingsView extends LitElement { + @property({ type: Object }) public hass!: HomeAssistant; + + @property({ attribute: false }) public knx!: KNX; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @property({ type: Object }) public route?: Route; + + @state() private unsubscribe?: () => void; + + @state() private _localInterfaces: string[] = []; + + @state() private _keyfile_data: KeyfileData | null = null; + + @state() private newConnectionData!: ConnectionData; + + @state() private oldConnectionData!: ConnectionData; + + @state() private newSettingsData!: IntegrationSettingsData; + + @state() private oldSettingsData!: IntegrationSettingsData; + + @state() private _uploadingKeyring = false; + + @state() private _uploadKeyringFile?: File; + + @state() private _uploadKeyringPassword?: string; + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = undefined; + } + } + + protected async firstUpdated() { + this._loadKnxSettingsInfo(); + this.unsubscribe = await subscribeGatewayScanner( + this.hass, + this.oldConnectionData?.local_ip ?? null, + (message) => { + logger.debug(message); + this.requestUpdate(); + }, + ); + } + + private _loadKnxSettingsInfo() { + getSettingsInfoData(this.hass).then( + (settingsInfoData) => { + const { config_entry, local_interfaces, keyfile_data } = settingsInfoData; + // split ConfigEntryData into ConnectionData and IntegrationSettingsData to be + // able to have independent save buttons activation states + const connectionData: ConnectionData = { + connection_type: config_entry.connection_type, + individual_address: config_entry.individual_address, + local_ip: config_entry.local_ip, + multicast_group: config_entry.multicast_group, + multicast_port: config_entry.multicast_port, + route_back: config_entry.route_back, + host: config_entry.host, + port: config_entry.port, + tunnel_endpoint_ia: config_entry.tunnel_endpoint_ia, + user_id: config_entry.user_id, + user_password: config_entry.user_password, + device_authentication: config_entry.device_authentication, + knxkeys_filename: config_entry.knxkeys_filename, + knxkeys_password: config_entry.knxkeys_password, + backbone_key: config_entry.backbone_key, + sync_latency_tolerance: config_entry.sync_latency_tolerance, + }; + this.newConnectionData = connectionData; + this.oldConnectionData = { ...connectionData }; + + const settingsData: IntegrationSettingsData = { + state_updater: config_entry.state_updater, + rate_limit: config_entry.rate_limit, + telegram_log_size: config_entry.telegram_log_size, + }; + this.newSettingsData = settingsData; + this.oldSettingsData = { ...settingsData }; + + this._localInterfaces = local_interfaces; + + this._keyfile_data = keyfile_data; + + logger.debug("settingsInfoData", settingsInfoData); + this.requestUpdate(); + }, + (err) => { + logger.error("getSettingsInfoData", err); + }, + ); + } + + protected _updateConnectionSetting(item_name: string, value_fn: (v: any) => any = (v) => v) { + return (ev: CustomEvent): void => { + const new_value = value_fn(ev.detail.value); + logger.debug("Update connection setting", item_name, "to", new_value, typeof new_value); + this.newConnectionData[item_name] = new_value; + this.requestUpdate(); // mutable object changes need requestUpdate + }; + } + + protected _updateIntegrationSetting(item_name: string, value_fn: (v: any) => any = (v) => v) { + return (ev: CustomEvent): void => { + const new_value = value_fn(ev.detail.value); + logger.debug("Update integration setting", item_name, "to", new_value, typeof new_value); + this.newSettingsData[item_name] = new_value; + this.requestUpdate(); // mutable object changes need requestUpdate + }; + } + + protected render(): TemplateResult | void { + if (!this.newConnectionData) { + return html`Loading...`; + } + const connectionDataUnchanged = + JSON.stringify(this.oldConnectionData, Object.keys(this.oldConnectionData).sort()) === + JSON.stringify(this.newConnectionData, Object.keys(this.newConnectionData).sort()); + + const settingsDataUnchanged = + JSON.stringify(this.oldSettingsData, Object.keys(this.oldSettingsData).sort()) === + JSON.stringify(this.newSettingsData, Object.keys(this.newSettingsData).sort()); + + return html` + +
+ +
+ ${this.connectionSettingsCardContent()} + + ${Object.entries(this.newConnectionData).map( + ([key, val]) => html` +
+
${key}
+
${val}
+
+ `, + )} +
+
+ + ${this.hass.localize("ui.common.save")} + +
+
+ +
+ ${this.integrationSettingsCardContent()} + + ${Object.entries(this.newSettingsData).map( + ([key, val]) => html` +
+
${key}
+
${val}
+
+ `, + )} +
+
+ + ${this.hass.localize("ui.common.save")} + +
+
+ ${this.keyringCardContent()} +
+
+ `; + } + + private async _saveConnectionSettings(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; + + try { + logger.debug("_saveConnectionSettings", this.newConnectionData); + await writeConnectionData(this.hass, this.newConnectionData); + } catch (err: any) { + logger.debug("Error _saveConnectionSettings", err); + showAlertDialog(this, { + title: "Error saving connection settings", + text: extractApiErrorMessage(err), + }); + } + button.progress = false; + } + + private async _saveIntegrationSettings(ev: CustomEvent): Promise { + const button = ev.currentTarget as any; + button.progress = true; + + try { + logger.debug("_saveIntegrationSettings", this.newSettingsData); + await writeConnectionData(this.hass, this.newSettingsData); + } catch (err: any) { + logger.debug("Error _saveIntegrationSettings", err); + showAlertDialog(this, { + title: "Error saving integration settings", + text: extractApiErrorMessage(err), + }); + } + button.progress = false; + } + + private integrationSettingsCardContent(): TemplateResult { + return html` + ${this._advanecedIntegrationSettings()}`; + } + + private _advanecedIntegrationSettings() { + return this.hass.userData?.showAdvanced + ? html`` + : nothing; + } + + protected _mainConnectionTypeFromInfoData(): ConnectionMainType { + switch (this.newConnectionData?.connection_type) { + case ConnectionType.TunnellingUDP: + case ConnectionType.TunnellingTCP: + case ConnectionType.TunnellingSecure: + return ConnectionMainType.Tunnelling; + case ConnectionType.RoutingPlain: + case ConnectionType.RoutingSecure: + return ConnectionMainType.Routing; + default: + return ConnectionMainType.Automatic; + } + } + + protected connectionSettingsCardContent(): TemplateResult { + const currentMainTypeSelection = this._mainConnectionTypeFromInfoData(); + + return html` + + ${this.connectionSettingsForType(currentMainTypeSelection)} + ${this._advancedConnectionSettings()} + `; + } + + protected _changeConnectionMain(ev: CustomEvent): void { + logger.debug( + "connectionMainChanged", + this.newConnectionData.connection_type, + "to", + ev.detail.value, + ); + const connectionMainType: ConnectionMainType = ev.detail.value; + switch (connectionMainType) { + case ConnectionMainType.Tunnelling: + if (this.oldConnectionData.connection_type === ConnectionType.TunnellingSecure) { + this.newConnectionData.connection_type = ConnectionType.TunnellingSecure; + } else if (this.oldConnectionData.connection_type === ConnectionType.TunnellingTCP) { + this.newConnectionData.connection_type = ConnectionType.TunnellingTCP; + } else { + this.newConnectionData.connection_type = ConnectionType.TunnellingUDP; + } + break; + case ConnectionMainType.Routing: + if (this.oldConnectionData.connection_type === ConnectionType.RoutingSecure) { + this.newConnectionData.connection_type = ConnectionType.RoutingSecure; + } else { + this.newConnectionData.connection_type = ConnectionType.RoutingPlain; + } + break; + default: + this.newConnectionData.connection_type = ConnectionType.Automatic; + } + this.requestUpdate(); // mutable object changes need requestUpdate + } + + protected connectionSettingsForType(connectionMainType: ConnectionMainType) { + switch (connectionMainType) { + case ConnectionMainType.Tunnelling: + return this._connectionSettingsTunnelling(); + case ConnectionMainType.Routing: + return this._connectionSettingsRouting(); + default: + // Automatic doesn't need any specific settings + return nothing; + } + } + + protected _connectionSettingsTunnelling(): TemplateResult { + return html` + + + + ${this.newConnectionData.connection_type === ConnectionType.TunnellingUDP + ? html`` + : html` `} + ${this.newConnectionData.connection_type === ConnectionType.TunnellingSecure + ? html` + + ` + : nothing} + `; + } + + protected _connectionSettingsRouting(): TemplateResult { + return html` + + v ? ConnectionType.RoutingSecure : ConnectionType.RoutingPlain, + )} + > + + + + ${this.newConnectionData.connection_type === ConnectionType.RoutingSecure + ? html` + ` + : nothing} + `; + } + + private _advancedConnectionSettings() { + const interfaces = this._localInterfaces.map((iface) => ({ label: iface, value: iface })); + interfaces.unshift({ label: "Automatic", value: "" }); // empty string is automatic -> null + + return this.hass.userData?.showAdvanced + ? html` v || null)} + >` + : nothing; + } + + protected keyringCardContent(): TemplateResult { + return html`
+ ${this._keyfile_data ? this._keyringInfoContent(this._keyfile_data) : nothing} +
+ +
+ ${ + this._uploadKeyringFile + ? html`
+ + +
` + : nothing + } +
+ +
+ ${ + this._keyfile_data + ? html` + ${this.hass.localize("ui.common.delete")} + ` + : nothing + } + + ${this._keyfile_data ? "Replace Keyfile" : "Upload Keyfile"} + +
`; + } + + private _keyfilePicked(ev) { + this._uploadKeyringFile = ev.detail.files[0]; + logger.debug("_keyfilePicked", ev.detail.files[0]); + } + + private _keyfileCleared(ev) { + this._uploadKeyringFile = undefined; + logger.debug("_keyfileCleared", ev.detail); + } + + private _uploadKeyringPasswordChanged(ev) { + this._uploadKeyringPassword = ev.detail.value; + } + + private async _uploadKeyring(_ev) { + const file = this._uploadKeyringFile; + if (typeof file === "undefined") { + return; + } + + let error: Error | undefined; + this._uploadingKeyring = true; + try { + const keyring_file_id = await uploadFile(this.hass, file); + await processKeyringFile(this.hass, keyring_file_id, this._uploadKeyringPassword || ""); + } catch (err: any) { + error = err; + showAlertDialog(this, { + title: "Upload failed", + text: extractApiErrorMessage(err), + confirmText: "ok", + }); + } finally { + if (!error) { + this._uploadKeyringFile = undefined; + this._uploadKeyringPassword = undefined; + } + this._uploadingKeyring = false; + // this.loadKnxInfo(); + } + } + + protected _keyringInfoContent(keyfile_data: KeyfileData): TemplateResult { + const timestamp = new Date(keyfile_data.timestamp).toLocaleString(); + + return html`
+
Project name
+
${keyfile_data.project_name}
+
+
+
Timestamp
+
${timestamp}
+
+
+
Created by
+
${keyfile_data.created_by}
+
`; + } + // private async _removeKeyring(_ev) { + // const confirmed = await showConfirmationDialog(this, { + // text: this.knx.localize("info_project_delete"), + // }); + // if (!confirmed) { + // logger.debug("User cancelled deletion"); + // return; + // } + + // try { + // await removeProjectFile(this.hass); + // } catch (err: any) { + // showAlertDialog(this, { + // title: "Deletion failed", + // text: extractApiErrorMessage(err), + // confirmText: "ok", + // }); + // } finally { + // this.loadKnxInfo(); + // } + // } + + // static get styles(): CSSResultGroup { + // return haStyle; + // } + static get styles() { + return css` + .columns { + display: flex; + justify-content: center; + align-items: flex-start; + } + + @media screen and (max-width: 1232px) { + .columns { + flex-direction: column; + align-items: stretch; + } + + .knx-delete-project-button { + top: 20px; + } + + .knx-info { + margin-right: 8px; + max-width: 96.5%; + } + } + + @media screen and (min-width: 1233px) { + .knx-info { + width: 400px; + } + } + + .knx-info { + margin-left: 8px; + margin-top: 8px; + } + + .knx-info-section { + display: flex; + flex-direction: column; + } + + .knx-content-row { + display: flex; + flex-direction: row; + justify-content: space-between; + } + + .knx-content-row > div:nth-child(2) { + margin-left: 1rem; + } + + .knx-content-button { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + } + + .knx-warning { + --mdc-theme-primary: var(--error-color); + } + + .knx-project-description { + margin-top: -8px; + padding: 0px 16px 16px; + } + + .knx-delete-project-button { + position: absolute; + bottom: 0; + right: 0; + } + + .knx-bug-report { + margin-top: 20px; + } + + .knx-bug-report > ul > li > a { + text-decoration: none; + color: var(--mdc-theme-primary); + } + + ha-file-upload, + ha-selector-text { + width: 100%; + margin: 0 8px 8px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-settings": KNXSettingsView; + } +}