diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 73497d9ab7..149c939f8a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,13 +7,20 @@ Z-Wave JS UI is a full-featured Z-Wave Control Panel and MQTT Gateway built with ## Commit and PR Standards **ALWAYS follow conventional commit standards for all commits and PR titles/descriptions:** +**ALWAYS split work into multiple commits instead of doing a single big commit at the end of implementation.** ### Commit Messages - Use conventional commit format: `type(scope): description` - Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` +- **Split large features into multiple focused commits:** + - Backend changes should be separate from frontend changes + - Each major component or functionality should be its own commit + - Bug fixes should be separate commits from feature additions - Examples: - - `feat(ui): add device configuration panel` - - `fix(zwave): resolve connection timeout issues` + - `feat(backend): add groups storage and CRUD APIs` + - `feat(frontend): add Groups management interface` + - `fix(backend): store multicast group instances locally` + - `refactor(frontend): replace dialog with app.confirm pattern` - `docs: update installation instructions` - `chore: update dependencies` @@ -22,6 +29,106 @@ Z-Wave JS UI is a full-featured Z-Wave Control Panel and MQTT Gateway built with - Descriptions should clearly explain the changes and their impact - Include issue references (e.g., "Fixes #1234") +## Frontend Development Patterns + +### Using app.confirm for Forms Instead of Creating Dialog Components + +**ALWAYS use app.confirm with inputs instead of creating dedicated dialog components for simple forms.** + +The `app.confirm` method supports form inputs and should be used instead of creating new Vue dialog components. Check `src/components/Confirm.vue` for supported input types. + +#### Supported Input Types: +- `text` - Text field +- `number` - Number input +- `boolean` - Switch input +- `checkbox` - Checkbox input +- `list` - Select/Autocomplete/Combobox (supports `multiple: true`) +- `array` - Complex list inputs + +#### Example Usage (from SmartStart.vue): +```javascript +async editItem(existingItem) { + let inputs = [ + { + type: 'text', + label: 'Name', + required: true, + key: 'name', + hint: 'The node name', + default: existingItem ? existingItem.name : '', + }, + { + type: 'list', + label: 'Protocol', + required: true, + key: 'protocol', + items: protocolsItems, + hint: 'Inclusion protocol to use', + default: existingItem ? existingItem.protocol : Protocols.ZWave, + }, + { + type: 'checkbox', + label: 'S2 Access Control', + key: 's2AccessControl', + default: existingItem ? existingItem.securityClasses.s2AccessControl : false, + }, + ] + + let result = await this.app.confirm( + (existingItem ? 'Update' : 'New') + ' entry', + '', + 'info', + { + confirmText: existingItem ? 'Update' : 'Add', + width: 500, + inputs, + }, + ) + + // cancelled + if (Object.keys(result).length === 0) { + return + } + + // Handle result... +} +``` + +#### Input Configuration Options: +- `type`: Input type (required) +- `label`: Field label (required) +- `key`: Property key for result object (required) +- `required`: Whether field is required +- `default`: Default value +- `hint`: Help text shown below field +- `rules`: Array of validation functions +- `disabled`: Whether field is disabled +- `multiple`: For list types, allows multiple selection +- `items`: For list types, array of options with `title`/`value` properties + +#### Multiple Selection Example (Groups.vue): +```javascript +{ + type: 'list', + label: 'Nodes', + required: true, + key: 'nodeIds', + multiple: true, + items: this.physicalNodes.map(node => ({ + title: node.name || `Node ${node.id}`, + value: node.id + })), + hint: 'Select at least 1 node for the multicast group', + default: existingGroup ? existingGroup.nodeIds : [], + rules: [(value) => { + if (!value || value.length === 0) { + return 'Please select at least one node' + } + return true + }], +} +``` + ## Bootstrap and Development Setup ### Prerequisites and Installation diff --git a/api/config/store.ts b/api/config/store.ts index d9a1df4f6d..94346aeb8c 100644 --- a/api/config/store.ts +++ b/api/config/store.ts @@ -5,7 +5,7 @@ import { MqttConfig } from '../lib/MqttClient' import { ZnifferConfig } from '../lib/ZnifferManager' import { ZwaveConfig, deviceConfigPriorityDir } from '../lib/ZwaveClient' -export type StoreKeys = 'settings' | 'scenes' | 'nodes' | 'users' +export type StoreKeys = 'settings' | 'scenes' | 'nodes' | 'users' | 'groups' export interface StoreFile { file: string @@ -18,6 +18,12 @@ export interface User { token?: string } +export interface Group { + id: number + name: string + nodeIds: number[] +} + export interface Settings { mqtt?: MqttConfig zwave?: ZwaveConfig @@ -38,6 +44,7 @@ const store: Record = { scenes: { file: 'scenes.json', default: [] }, nodes: { file: 'nodes.json', default: {} }, users: { file: 'users.json', default: [] }, + groups: { file: 'groups.json', default: [] }, } export default store diff --git a/api/lib/ZwaveClient.ts b/api/lib/ZwaveClient.ts index 9855de7b07..61e30012ff 100644 --- a/api/lib/ZwaveClient.ts +++ b/api/lib/ZwaveClient.ts @@ -6,6 +6,8 @@ import { Duration, Firmware, isUnsupervisedOrSucceeded, + NODE_ID_BROADCAST, + NODE_ID_BROADCAST_LR, Route, RouteKind, SecurityClass, @@ -115,7 +117,7 @@ import { } from 'zwave-js' import { getEnumMemberName, parseQRCodeString } from 'zwave-js/Utils' import { configDbDir, logsDir, nvmBackupsDir, storeDir } from '../config/app' -import store from '../config/store' +import store, { Group } from '../config/store' import jsonStore from './jsonStore' import * as LogManager from './logger' import * as utils from './utils' @@ -165,6 +167,10 @@ export const allowedApis = validateMethods([ '_addSceneValue', '_removeSceneValue', '_activateScene', + '_createGroup', + '_updateGroup', + '_deleteGroup', + '_getGroups', 'refreshNeighbors', 'getNodeNeighbors', 'discoverNodeNeighbors', @@ -582,6 +588,7 @@ export type ZUINode = { defaultVolume?: number protocol?: Protocols supportsLongRange?: boolean + virtual?: boolean } export type NodeEvent = { @@ -698,7 +705,9 @@ class ZwaveClient extends TypedEventEmitter { private destroyed = false private _driverReady: boolean private scenes: ZUIScene[] + private groups: Group[] private _nodes: Map + private _multicastGroups: Map private storeNodes: Record> private _devices: Record> private driverInfo: ZUIDriverInfo @@ -822,8 +831,10 @@ class ZwaveClient extends TypedEventEmitter { this.closed = false this.driverReady = false this.scenes = jsonStore.get(store.scenes) + this.groups = jsonStore.get(store.groups) this._nodes = new Map() + this._multicastGroups = new Map() this._devices = {} this.driverInfo = {} @@ -972,9 +983,13 @@ class ZwaveClient extends TypedEventEmitter { } /** - * Returns the driver ZWaveNode object + * Returns the driver ZWaveNode object, or multicast group/broadcast node for virtual nodes (nodeId > 0xfff) */ - getNode(nodeId: number): ZWaveNode { + getNode(nodeId: number): ZWaveNode | any { + // For virtual nodes (multicast groups and broadcast nodes), return the stored multicast group instance + if (nodeId > 0xfff) { + return this._multicastGroups.get(nodeId) || null + } return this._driver.controller.nodes.get(nodeId) } @@ -2486,6 +2501,9 @@ class ZwaveClient extends TypedEventEmitter { this.sendToSocket(socketEvents.valueUpdated, valueId) this.emit('valueChanged', valueId, node, changed) + + // Update virtual nodes that contain this node + this._updateVirtualNodesForNode(valueId.nodeId) } public emitStatistics( @@ -2832,6 +2850,270 @@ class ZwaveClient extends TypedEventEmitter { return true } + // === GROUPS MANAGEMENT === + + /** + * Get next available group ID (above 0xfff) + */ + private _getNextGroupId(): number { + const existingIds = this.groups.map((g) => g.id) + const minId = 0xfff + let nextId = minId + 1 + while (existingIds.includes(nextId)) { + nextId++ + } + return nextId + } + + /** + * Create a new group + */ + async _createGroup(name: string, nodeIds: number[]): Promise { + const id = this._getNextGroupId() + const group: Group = { id, name, nodeIds } + + this.groups.push(group) + await jsonStore.put(store.groups, this.groups) + + // Create virtual multicast node + this._createVirtualNode(group) + + return group + } + + /** + * Update an existing group + */ + async _updateGroup( + id: number, + name: string, + nodeIds: number[], + ): Promise { + const groupIndex = this.groups.findIndex((g) => g.id === id) + if (groupIndex === -1) { + return null + } + + this.groups[groupIndex].name = name + this.groups[groupIndex].nodeIds = nodeIds + + await jsonStore.put(store.groups, this.groups) + + // Update virtual multicast node + this._updateVirtualNode(this.groups[groupIndex]) + + return this.groups[groupIndex] + } + + /** + * Delete a group + */ + async _deleteGroup(id: number): Promise { + const groupIndex = this.groups.findIndex((g) => g.id === id) + if (groupIndex === -1) { + return false + } + + // Remove virtual node + this._nodes.delete(id) + + // Remove stored multicast group instance + this._multicastGroups.delete(id) + + this.groups.splice(groupIndex, 1) + await jsonStore.put(store.groups, this.groups) + + // Emit node removed event + this.sendToSocket(socketEvents.nodeRemoved, { id }) + + return true + } + + /** + * Get all groups + */ + _getGroups(): Group[] { + return this.groups + } + + /** + * Create virtual node for multicast group + */ + private _createVirtualNode(group: Group): void { + if (!this.driverReady) return + + try { + // Create and store the multicast group instance + const multicastGroup = this._driver.controller.getMulticastGroup(group.nodeIds) + this._multicastGroups.set(group.id, multicastGroup) + + const virtualNode: ZUINode = { + id: group.id, + name: group.name, + virtual: true, + ready: true, + available: true, + failed: false, + inited: true, + values: {}, + eventsQueue: [], + } + + this._nodes.set(group.id, virtualNode) + + // Emit node added event + this.sendToSocket(socketEvents.nodeAdded, virtualNode) + + // Update virtual node values based on member nodes + this._updateVirtualNodeValues(group) + } catch (error) { + logger.error( + `Error creating virtual node for group ${group.id}: ${error.message}`, + ) + } + } + + /** + * Update virtual node for multicast group + */ + private _updateVirtualNode(group: Group): void { + const virtualNode = this._nodes.get(group.id) + if (!virtualNode) { + // Create if doesn't exist + this._createVirtualNode(group) + return + } + + virtualNode.name = group.name + + // Update virtual node values based on member nodes + this._updateVirtualNodeValues(group) + + // Emit node updated event + this.emitNodeUpdate(virtualNode, { name: virtualNode.name }) + } + + /** + * Update virtual node values based on member node values + */ + private _updateVirtualNodeValues(group: Group): void { + const virtualNode = this._nodes.get(group.id) + if (!virtualNode) return + + // Get all member nodes + const memberNodes = group.nodeIds + .map((id) => this._nodes.get(id)) + .filter(Boolean) + + if (memberNodes.length === 0) return + + // Collect all unique value IDs from member nodes + const allValueIds = new Set() + for (const node of memberNodes) { + if (node.values) { + Object.keys(node.values).forEach((vId) => allValueIds.add(vId)) + } + } + + // For each value ID, determine the virtual value + for (const vId of allValueIds) { + const memberValues = memberNodes + .map((node) => node.values?.[vId]?.value) + .filter((value) => value !== undefined) + + if (memberValues.length === 0) continue + + // If all values are the same, use that value; otherwise undefined + const firstValue = memberValues[0] + const allSame = memberValues.every((value) => value === firstValue) + const virtualValue = allSame ? firstValue : undefined + + // Get value metadata from first member node + const sampleValueId = memberNodes.find((node) => node.values?.[vId]) + ?.values?.[vId] + if (!sampleValueId) continue + + // Create virtual value ID + const virtualValueId: ZUIValueId = { + ...sampleValueId, + nodeId: group.id, + value: virtualValue, + lastUpdate: Date.now(), + } + + if (!virtualNode.values) virtualNode.values = {} + virtualNode.values[vId] = virtualValueId + } + } + + /** + * Create broadcast nodes + */ + private _createBroadcastNodes(): void { + if (!this.driverReady) return + + try { + // Create and store standard broadcast node + const broadcastNode = this._driver.controller.getBroadcastNode() + this._multicastGroups.set(NODE_ID_BROADCAST, broadcastNode) + + const broadcastVirtualNode: ZUINode = { + id: NODE_ID_BROADCAST, + name: 'Broadcast', + virtual: true, + ready: true, + available: true, + failed: false, + inited: true, + values: {}, + eventsQueue: [], + } + + this._nodes.set(NODE_ID_BROADCAST, broadcastVirtualNode) + this.sendToSocket(socketEvents.nodeAdded, broadcastVirtualNode) + + // Create LR broadcast node + // Note: LR broadcast might not be available in all drivers, so we'll use the same approach + const broadcastLRVirtualNode: ZUINode = { + id: NODE_ID_BROADCAST_LR, + name: 'Broadcast LR', + virtual: true, + ready: true, + available: true, + failed: false, + inited: true, + values: {}, + eventsQueue: [], + } + + this._nodes.set(NODE_ID_BROADCAST_LR, broadcastLRVirtualNode) + this.sendToSocket(socketEvents.nodeAdded, broadcastLRVirtualNode) + } catch (error) { + logger.error(`Error creating broadcast nodes: ${error.message}`) + } + } + + /** + * Update virtual nodes when a member node's value changes + */ + private _updateVirtualNodesForNode(nodeId: number): void { + // Find all groups that contain this node + const groupsWithNode = this.groups.filter((group) => + group.nodeIds.includes(nodeId), + ) + + // Update virtual node values for each group + for (const group of groupsWithNode) { + this._updateVirtualNodeValues(group) + + const virtualNode = this._nodes.get(group.id) + if (virtualNode) { + // Emit update for the virtual node + this.sendToSocket(socketEvents.nodeUpdated, virtualNode) + } + } + } + /** * Get the nodes array */ @@ -3889,7 +4171,11 @@ class ZwaveClient extends TypedEventEmitter { rounds = 5, ): Promise { if (this.driverReady) { - const result = await this.getNode(nodeId).checkLifelineHealth( + const zwaveNode = this.getNode(nodeId) + if (!zwaveNode || nodeId > 0xfff) { + throw new Error(`Node ${nodeId} not found or is a virtual node`) + } + const result = await zwaveNode.checkLifelineHealth( rounds, this._onHealthCheckProgress.bind(this, { nodeId, @@ -3907,7 +4193,11 @@ class ZwaveClient extends TypedEventEmitter { options: any, ): Promise { if (this.driverReady) { - const result = await this.getNode(nodeId).checkLinkReliability({ + const zwaveNode = this.getNode(nodeId) + if (!zwaveNode || nodeId > 0xfff) { + throw new Error(`Node ${nodeId} not found or is a virtual node`) + } + const result = await zwaveNode.checkLinkReliability({ ...options, onProgress: (progress) => this._onLinkReliabilityCheckProgress({ nodeId }, progress), @@ -3921,7 +4211,11 @@ class ZwaveClient extends TypedEventEmitter { abortLinkReliabilityCheck(nodeId: number): void { if (this.driverReady) { - this.getNode(nodeId).abortLinkReliabilityCheck() + const zwaveNode = this.getNode(nodeId) + if (!zwaveNode || nodeId > 0xfff) { + throw new Error(`Node ${nodeId} not found or is a virtual node`) + } + zwaveNode.abortLinkReliabilityCheck() return } @@ -4577,6 +4871,14 @@ class ZwaveClient extends TypedEventEmitter { // needs home hex to be set await this.getStoreNodes() + // Create broadcast nodes and recreate virtual nodes for groups + this._createBroadcastNodes() + + // Recreate virtual nodes for existing groups + for (const group of this.groups) { + this._createVirtualNode(group) + } + for (const [, node] of this._driver.controller.nodes) { // node added will not be triggered if the node is in cache this._createNode(node.id) @@ -4753,11 +5055,14 @@ class ZwaveClient extends TypedEventEmitter { } this._updateControllerStatus(message) - this._onNodeEvent( - 'status changed', - this.getNode(this.driver.controller.ownNodeId), - status, - ) + const controllerNode = this.getNode(this.driver.controller.ownNodeId) + if (controllerNode) { + this._onNodeEvent( + 'status changed', + controllerNode, + status, + ) + } this.emit('event', EventSource.CONTROLLER, 'status changed', status) } diff --git a/src/App.vue b/src/App.vue index 73741a00fc..69075ae531 100644 --- a/src/App.vue +++ b/src/App.vue @@ -493,6 +493,12 @@ export default { path: Routes.scenes, }) + pages.splice(4, 0, { + icon: 'group_work', + title: 'Groups', + path: Routes.groups, + }) + pages.push({ icon: 'share', title: 'Network graph', diff --git a/src/components/nodes-table/ExpandedNode.vue b/src/components/nodes-table/ExpandedNode.vue index 84a7e905bf..fdfdbcfbe8 100644 --- a/src/components/nodes-table/ExpandedNode.vue +++ b/src/components/nodes-table/ExpandedNode.vue @@ -8,13 +8,19 @@ > - Device + {{ node.virtual ? 'Virtual Node' : 'Device' }} +
- + {{ node.hexId }}
{{ @@ -38,7 +44,7 @@ - + +