import type { MessageRecord , ValueID } from "@zwave-js/core" ; import { CommandClasses , encodeBitMask , MAX_NODES , Maybe , MessageOrCCLogEntry , parseBitMask , validatePayload , ZWaveError , ZWaveErrorCodes , } from "@zwave-js/core" ; import { pick } from "@zwave-js/shared" ; import type { Driver } from "../driver/Driver" ; import { MessagePriority } from "../message/Constants" ; import { PhysicalCCAPI } from "./API" ; import { getGroupCountValueId as getAssociationGroupCountValueId } from "./AssociationCC" ; import { API , CCCommand , CCCommandOptions , ccValue , CommandClass , commandClass , CommandClassDeserializationOptions , CommandClassOptions , expectedCCResponse , gotDeserializationOptions , implementedVersion , } from "./CommandClass" ; publicAPI export interface AssociationAddress { nodeId : number ; endpoint ? : number ; } publicAPI deprecated export type Association = AssociationAddress ; /** Returns the ValueID used to store the maximum number of nodes of an association group */ export function getMaxNodesValueId ( endpointIndex , groupId ) { return { commandClass : CommandClasses [ "Multi Channel Association" ] , endpoint : endpointIndex , property : "maxNodes" , propertyKey : groupId } ; } /** Returns the ValueID used to store the node IDs of a multi channel association group */ export function getNodeIdsValueId ( endpointIndex , groupId ) { return { commandClass : CommandClasses [ "Multi Channel Association" ] , endpoint : endpointIndex , property : "nodeIds" , propertyKey : groupId } ; } /** Returns the ValueID used to store the endpoint addresses of a multi channel association group */ export function getEndpointsValueId ( endpointIndex , groupId ) { return { commandClass : CommandClasses [ "Multi Channel Association" ] , endpoint : endpointIndex , property : "endpoints" , propertyKey : groupId } ; } /** Returns the ValueID used to store the number of multi channel association group */ export function getGroupCountValueId ( endpointIndex ) { return { commandClass : CommandClasses [ "Multi Channel Association" ] , endpoint : endpointIndex , property : "groupCount" } ; } export interface EndpointAddress { nodeId : number ; endpoint : number | number [ ] ; } function endpointAddressesToString ( endpoints ) { return endpoints . map ( ( { nodeId , endpoint } ) => { if ( typeof endpoint === "number" ) { return `${ nodeId }:${ endpoint }` ; } else { return `${ nodeId }:[${ endpoint . map ( String ) . join ( ", " ) }]` ; } } ) . join ( ", " ) ; } const MULTI_CHANNEL_ASSOCIATION_MARKER = 0x00 ; function serializeMultiChannelAssociationDestination ( nodeIds , endpoints ) { const nodeAddressBytes = nodeIds . length ; const endpointAddressBytes = endpoints . length * 2 ; const payload = Buffer . allocUnsafe ( nodeAddressBytes + ( endpointAddressBytes > 0 ? 1 : 0 ) + endpointAddressBytes ) ; for ( let i = 0 ; i < nodeIds . length ; i ++ ) { payload [ i ] = nodeIds [ i ] ; } if ( endpointAddressBytes > 0 ) { let offset = nodeIds . length ; payload [ offset ] = MULTI_CHANNEL_ASSOCIATION_MARKER ; offset += 1 ; for ( let i = 0 ; i < endpoints . length ; i ++ ) { const endpoint = endpoints [ i ] ; const destination = typeof endpoint . endpoint === "number" ? endpoint . endpoint & 127 : encodeBitMask ( endpoint . endpoint , 7 ) [ 0 ] | 128 ; payload [ offset + 2 * i ] = endpoint . nodeId ; payload [ offset + 2 * i + 1 ] = destination ; } } return payload ; } function deserializeMultiChannelAssociationDestination ( data ) { const nodeIds = [ ] ; let endpointOffset = data . length ; for ( let i = 0 ; i < data . length ; i ++ ) { if ( data [ i ] === MULTI_CHANNEL_ASSOCIATION_MARKER ) { endpointOffset = i + 1 ; break ; } nodeIds . push ( data [ i ] ) ; } const endpoints = [ ] ; for ( let i = endpointOffset ; i < data . length ; i += 2 ) { const nodeId = data [ i ] ; const isBitMask = ! ! ( data [ i + 1 ] & 128 ) ; const destination = data [ i + 1 ] & 127 ; const endpoint = isBitMask ? parseBitMask ( Buffer . from ( [ destination ] ) ) : destination ; endpoints . push ( { nodeId , endpoint } ) ; } return { nodeIds , endpoints } ; } export enum MultiChannelAssociationCommand { Set = 0x01 , Get = 0x02 , Report = 0x03 , Remove = 0x04 , SupportedGroupingsGet = 0x05 , SupportedGroupingsReport = 0x06 } @ API ( CommandClasses [ "Multi Channel Association" ] ) export class MultiChannelAssociationCCAPI extends PhysicalCCAPI { public supportsCommand ( cmd ) { switch ( cmd ) { case MultiChannelAssociationCommand . Get : case MultiChannelAssociationCommand . Set : case MultiChannelAssociationCommand . Remove : case MultiChannelAssociationCommand . SupportedGroupingsGet : return true ; } return super . supportsCommand ( cmd ) ; } /**
     * Returns the number of association groups a node supports.
     * Association groups are consecutive, starting at 1.
     */ public async getGroupCount ( ) { this . assertSupportsCommand ( MultiChannelAssociationCommand , MultiChannelAssociationCommand . SupportedGroupingsGet ) ; const cc = new MultiChannelAssociationCCSupportedGroupingsGet ( this . driver , { nodeId : this . endpoint . nodeId , endpoint : this . endpoint . index } ) ; const response = await this . driver . sendCommand < MultiChannelAssociationCCSupportedGroupingsReport > ( cc , this . commandOptions ) ; return response ?. groupCount ; } /**
     * Returns information about an association group.
     */ public async getGroup ( groupId ) { this . assertSupportsCommand ( MultiChannelAssociationCommand , MultiChannelAssociationCommand . Get ) ; const cc = new MultiChannelAssociationCCGet ( this . driver , { nodeId : this . endpoint . nodeId , endpoint : this . endpoint . index , groupId } ) ; const response = await this . driver . sendCommand < MultiChannelAssociationCCReport > ( cc , this . commandOptions ) ; if ( response ) { return pick ( response , [ "maxNodes" , "nodeIds" , "endpoints" ] ) ; } } /**
     * Adds new nodes or endpoints to an association group
     */ public async addDestinations ( options ) { this . assertSupportsCommand ( MultiChannelAssociationCommand , MultiChannelAssociationCommand . Set ) ; const cc = new MultiChannelAssociationCCSet ( this . driver , { nodeId : this . endpoint . nodeId , endpoint : this . endpoint . index , ... options } ) ; await this . driver . sendCommand ( cc , this . commandOptions ) ; } /**
     * Removes nodes or endpoints from an association group
     */ public async removeDestinations ( options ) { this . assertSupportsCommand ( MultiChannelAssociationCommand , MultiChannelAssociationCommand . Remove ) ; if ( ! options . groupId && this . version === 1 ) { const currentDestinations = this . endpoint . createCCInstanceInternal ( MultiChannelAssociationCC ) ! . getAllDestinationsCached ( ) ; for ( const [ group , destinations ] of currentDestinations ) { const cc = new MultiChannelAssociationCCRemove ( this . driver , { nodeId : this . endpoint . nodeId , endpoint : this . endpoint . index , groupId : group , nodeIds : destinations . filter ( ( d ) => ! d . endpoint ) . map ( ( d ) => d . nodeId ) , endpoints : destinations . filter ( ( d ) : d is AssociationAddress & { endpoint : number ; } => ! ! d . endpoint ) } ) ; await this . driver . sendCommand ( cc , this . commandOptions ) ; } } else { const cc = new MultiChannelAssociationCCRemove ( this . driver , { nodeId : this . endpoint . nodeId , endpoint : this . endpoint . index , ... options } ) ; await this . driver . sendCommand ( cc , this . commandOptions ) ; } } } @ commandClass ( CommandClasses [ "Multi Channel Association" ] ) @ implementedVersion ( 4 ) export class MultiChannelAssociationCC extends CommandClass { declare ccCommand : MultiChannelAssociationCommand ; public constructor ( driver , options ) { super ( driver , options ) ; this . registerValue ( getMaxNodesValueId ( 0 , 0 ) . property , true ) ; this . registerValue ( getNodeIdsValueId ( 0 , 0 ) . property , true ) ; this . registerValue ( getEndpointsValueId ( 0 , 0 ) . property , true ) ; } public determineRequiredCCInterviews ( ) { return [ ... super . determineRequiredCCInterviews ( ) , CommandClasses [ "Z-Wave Plus Info" ] , CommandClasses [ "Multi Channel" ] , CommandClasses . Association , ] ; } /**
     * Returns the number of association groups reported by the node/endpoint.
     * This only works AFTER the interview process
     */ public getGroupCountCached ( ) { return ( this . getValueDB ( ) . getValue ( getGroupCountValueId ( this . endpointIndex ) ) || 0 ) ; } /**
     * Returns the number of nodes an association group supports.
     * This only works AFTER the interview process
     */ public getMaxNodesCached ( groupId ) { return ( this . getValueDB ( ) . getValue ( getMaxNodesValueId ( this . endpointIndex , groupId ) ) ?? 0 ) ; } /**
     * Returns all the destinations of all association groups reported by the node/endpoint.
     * This only works AFTER the interview process
     */ public getAllDestinationsCached ( ) { const ret = new Map < number , AssociationAddress [ ] > ( ) ; const groupCount = this . getGroupCountCached ( ) ; const valueDB = this . getValueDB ( ) ; for ( let i = 1 ; i <= groupCount ; i ++ ) { const groupDestinations = [ ] ; const nodes = valueDB . getValue < number [ ] > ( getNodeIdsValueId ( this . endpointIndex , i ) ) ?? [ ] ; groupDestinations . push ( ... nodes . map ( ( nodeId ) => ( { nodeId } ) ) ) ; const endpoints = valueDB . getValue < EndpointAddress [ ] > ( getEndpointsValueId ( this . endpointIndex , i ) ) ?? [ ] ; for ( const ep of endpoints ) { if ( typeof ep . endpoint === "number" ) { groupDestinations . push ( { nodeId : ep . nodeId , endpoint : ep . endpoint } ) ; } else { groupDestinations . push ( ... ep . endpoint . map ( ( e ) => ( { nodeId : ep . nodeId , endpoint : e } ) ) ) ; } } ret . set ( i , groupDestinations . filter ( ( addr , index ) => index === groupDestinations . findIndex ( ( { nodeId , endpoint } ) => nodeId === addr . nodeId && endpoint === addr . endpoint ) ) ) ; } return ret ; } public async interview ( ) { const node = this . getNode ( ) ! ; const endpoint = this . getEndpoint ( ) ! ; const mcAPI = endpoint . commandClasses [ "Multi Channel Association" ] ; this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : `Interviewing ${ this . ccName }...` , direction : "none" } ) ; this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : "querying number of multi channel association groups..." , direction : "outbound" } ) ; const mcGroupCount = await mcAPI . getGroupCount ( ) ; if ( mcGroupCount != undefined ) { this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : `supports ${ mcGroupCount } multi channel association groups` , direction : "inbound" } ) ; } else { this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : "Querying multi channel association groups timed out, skipping interview..." , level : "warn" } ) ; return ; } await this . refreshValues ( ) ; await endpoint . configureLifelineAssociations ( ) ; this . interviewComplete = true ; } public async refreshValues ( ) { const node = this . getNode ( ) ! ; const endpoint = this . getEndpoint ( ) ! ; const mcAPI = endpoint . commandClasses [ "Multi Channel Association" ] . withOptions ( { priority : MessagePriority . NodeQuery } ) ; const assocAPI = endpoint . commandClasses . Association . withOptions ( { priority : MessagePriority . NodeQuery } ) ; const mcGroupCount = this . getValueDB ( ) . getValue ( getGroupCountValueId ( this . endpointIndex ) ) ?? 0 ; const assocGroupCount = this . getValueDB ( ) . getValue < number > ( getAssociationGroupCountValueId ( this . endpointIndex ) ) || mcGroupCount ; for ( let groupId = 1 ; groupId <= mcGroupCount ; groupId ++ ) { this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : `querying multi channel association group #${ groupId }...` , direction : "outbound" } ) ; const group = await mcAPI . getGroup ( groupId ) ; if ( ! group ) continue ; const logMessage = `received information for multi channel association group #${ groupId }:
maximum # of nodes:           ${ group . maxNodes }
currently assigned nodes:     ${ group . nodeIds . map ( String ) . join ( ", " ) }
currently assigned endpoints: ${ group . endpoints . map ( ( { nodeId , endpoint } ) => { if ( typeof endpoint === "number" ) { return `${ nodeId }:${ endpoint }` ; } else { return `${ nodeId }:[${ endpoint . map ( String ) . join ( ", " ) }]` ; } } ) . join ( "" ) }` ; this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : logMessage , direction : "inbound" } ) ; } if ( assocAPI . isSupported ( ) && assocGroupCount > mcGroupCount ) { this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : `querying additional non-multi-channel association groups...` , direction : "outbound" } ) ; for ( let groupId = mcGroupCount + 1 ; groupId <= assocGroupCount ; groupId ++ ) { this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : `querying association group #${ groupId }...` , direction : "outbound" } ) ; const group = await assocAPI . getGroup ( groupId ) ; if ( ! group ) continue ; const logMessage = `received information for association group #${ groupId }:
maximum # of nodes:           ${ group . maxNodes }
currently assigned nodes:     ${ group . nodeIds . map ( String ) . join ( ", " ) }` ; this . driver . controllerLog . logNode ( node . id , { endpoint : this . endpointIndex , message : logMessage , direction : "inbound" } ) ; } } } } type MultiChannelAssociationCCSetOptions = { groupId : number ; } & ( { nodeIds : number [ ] ; } | { endpoints : EndpointAddress [ ] ; } | { nodeIds : number [ ] ; endpoints : EndpointAddress [ ] ; } ) ; @ CCCommand ( MultiChannelAssociationCommand . Set ) export class MultiChannelAssociationCCSet extends MultiChannelAssociationCC { public constructor ( driver , options ) { super ( driver , options ) ; if ( gotDeserializationOptions ( options ) ) { throw new ZWaveError ( `${ this . constructor . name }: deserialization not implemented` , ZWaveErrorCodes . Deserialization_NotImplemented ) ; } else { if ( options . groupId < 1 ) { throw new ZWaveError ( "The group id must be positive!" , ZWaveErrorCodes . Argument_Invalid ) ; } this . groupId = options . groupId ; this . nodeIds = ( "nodeIds" in options && options . nodeIds ) || [ ] ; if ( this . nodeIds . some ( ( n ) => n < 1 || n > MAX_NODES ) ) { throw new ZWaveError ( `All node IDs must be between 1 and ${ MAX_NODES }!` , ZWaveErrorCodes . Argument_Invalid ) ; } this . endpoints = ( "endpoints" in options && options . endpoints ) || [ ] ; } } public groupId : number ; public nodeIds : number [ ] ; public endpoints : EndpointAddress [ ] ; public serialize ( ) { this . payload = Buffer . concat ( [ Buffer . from ( [ this . groupId ] ) , serializeMultiChannelAssociationDestination ( this . nodeIds , this . endpoints ) , ] ) ; return super . serialize ( ) ; } public toLogEntry ( ) { return { ... super . toLogEntry ( ) , message : { "group id" : this . groupId , "node ids" : this . nodeIds . join ( ", " ) , endpoints : endpointAddressesToString ( this . endpoints ) } } ; } } interface MultiChannelAssociationCCRemoveOptions { /** The group from which to remove the nodes. If none is specified, the nodes will be removed from all groups. */ groupId ? : number ; /** The nodes to remove. If no nodeIds and no endpoint addresses are specified, ALL nodes will be removed. */ nodeIds ? : number [ ] ; /** The single endpoints to remove. If no nodeIds and no endpoint addresses are specified, ALL will be removed. */ endpoints ? : EndpointAddress [ ] ; } @ CCCommand ( MultiChannelAssociationCommand . Remove ) export class MultiChannelAssociationCCRemove extends MultiChannelAssociationCC { public constructor ( driver , options ) { super ( driver , options ) ; if ( gotDeserializationOptions ( options ) ) { throw new ZWaveError ( `${ this . constructor . name }: deserialization not implemented` , ZWaveErrorCodes . Deserialization_NotImplemented ) ; } else { if ( ! options . groupId ) { if ( this . version === 1 ) { throw new ZWaveError ( `Node ${ this . nodeId as number } only supports MultiChannelAssociationCC V1 which requires the group Id to be set` , ZWaveErrorCodes . Argument_Invalid ) ; } } else if ( options . groupId < 0 ) { throw new ZWaveError ( "The group id must be positive!" , ZWaveErrorCodes . Argument_Invalid ) ; } this . groupId = options . groupId ; this . nodeIds = options . nodeIds ; this . endpoints = options . endpoints ; } } public groupId ? : number ; public nodeIds ? : number [ ] ; public endpoints ? : EndpointAddress [ ] ; public serialize ( ) { this . payload = Buffer . concat ( [ Buffer . from ( [ this . groupId || 0 ] ) , serializeMultiChannelAssociationDestination ( this . nodeIds || [ ] , this . endpoints || [ ] ) , ] ) ; return super . serialize ( ) ; } public toLogEntry ( ) { const message = { "group id" : this . groupId } ; if ( this . nodeIds ) { message [ "node ids" ] = this . nodeIds . join ( ", " ) ; } if ( this . endpoints ) { message . endpoints = endpointAddressesToString ( this . endpoints ) ; } return { ... super . toLogEntry ( ) , message } ; } } @ CCCommand ( MultiChannelAssociationCommand . Report ) export class MultiChannelAssociationCCReport extends MultiChannelAssociationCC { public constructor ( driver , options ) { super ( driver , options ) ; validatePayload ( this . payload . length >= 3 ) ; this . _groupId = this . payload [ 0 ] ; this . _maxNodes = this . payload [ 1 ] ; this . _reportsToFollow = this . payload [ 2 ] ; ( { nodeIds : this . _nodeIds , endpoints : this . _endpoints } = deserializeMultiChannelAssociationDestination ( this . payload . slice ( 3 ) ) ) ; } private _groupId : number ; public get groupId ( ) : number { return this . _groupId ; } private _maxNodes : number ; public get maxNodes ( ) : number { return this . _maxNodes ; } private _nodeIds : number [ ] ; public get nodeIds ( ) : readonly number [ ] { return this . _nodeIds ; } private _endpoints : EndpointAddress [ ] ; public get endpoints ( ) : readonly EndpointAddress [ ] { return this . _endpoints ; } private _reportsToFollow : number ; public get reportsToFollow ( ) : number { return this . _reportsToFollow ; } public getPartialCCSessionId ( ) { return { groupId : this . _groupId } ; } public expectMoreMessages ( ) { return this . _reportsToFollow > 0 ; } public mergePartialCCs ( partials ) { this . _nodeIds = [ ... partials , this ] . map ( ( report ) => report . _nodeIds ) . reduce ( ( prev , cur ) => prev . concat ( ... cur ) , [ ] ) ; this . _endpoints = [ ... partials , this ] . map ( ( report ) => report . _endpoints ) . reduce ( ( prev , cur ) => prev . concat ( ... cur ) , [ ] ) ; this . getValueDB ( ) . setValue ( getMaxNodesValueId ( this . endpointIndex , this . _groupId ) , this . _maxNodes ) ; this . getValueDB ( ) . setValue ( getNodeIdsValueId ( this . endpointIndex , this . _groupId ) , this . _nodeIds ) ; this . getValueDB ( ) . setValue ( getEndpointsValueId ( this . endpointIndex , this . _groupId ) , this . _endpoints ) ; } public toLogEntry ( ) { return { ... super . toLogEntry ( ) , message : { "group id" : this . groupId , "maximum # of nodes" : this . maxNodes , "node ids" : this . nodeIds . join ( ", " ) , endpoints : endpointAddressesToString ( this . endpoints ) } } ; } } interface MultiChannelAssociationCCGetOptions extends CCCommandOptions { groupId : number ; } @ CCCommand ( MultiChannelAssociationCommand . Get ) @ expectedCCResponse ( MultiChannelAssociationCCReport ) export class MultiChannelAssociationCCGet extends MultiChannelAssociationCC { public constructor ( driver , options ) { super ( driver , options ) ; if ( gotDeserializationOptions ( options ) ) { throw new ZWaveError ( `${ this . constructor . name }: deserialization not implemented` , ZWaveErrorCodes . Deserialization_NotImplemented ) ; } else { if ( options . groupId < 1 ) { throw new ZWaveError ( "The group id must be positive!" , ZWaveErrorCodes . Argument_Invalid ) ; } this . groupId = options . groupId ; } } public groupId : number ; public serialize ( ) { this . payload = Buffer . from ( [ this . groupId ] ) ; return super . serialize ( ) ; } public toLogEntry ( ) { return { ... super . toLogEntry ( ) , message : { "group id" : this . groupId } } ; } } @ CCCommand ( MultiChannelAssociationCommand . SupportedGroupingsReport ) export class MultiChannelAssociationCCSupportedGroupingsReport extends MultiChannelAssociationCC { public constructor ( driver , options ) { super ( driver , options ) ; validatePayload ( this . payload . length >= 1 ) ; this . _groupCount = this . payload [ 0 ] ; this . persistValues ( ) ; } private _groupCount : number ; @ ccValue ( { internal : true } ) public get groupCount ( ) : number { return this . _groupCount ; } public toLogEntry ( ) { return { ... super . toLogEntry ( ) , message : { "group count" : this . groupCount } } ; } } @ CCCommand ( MultiChannelAssociationCommand . SupportedGroupingsGet ) @ expectedCCResponse ( MultiChannelAssociationCCSupportedGroupingsReport ) export class MultiChannelAssociationCCSupportedGroupingsGet extends MultiChannelAssociationCC { }