6774 lines
158 KiB
TypeScript
6774 lines
158 KiB
TypeScript
// eslint-disable-next-line one-var
|
|
import {
|
|
CommandClasses,
|
|
ConfigurationMetadata,
|
|
dskToString,
|
|
Duration,
|
|
Firmware,
|
|
isUnsupervisedOrSucceeded,
|
|
Route,
|
|
RouteKind,
|
|
SecurityClass,
|
|
SupervisionResult,
|
|
SupervisionStatus,
|
|
ValueMetadataNumeric,
|
|
ValueMetadataString,
|
|
ZWaveDataRate,
|
|
ZWaveErrorCodes,
|
|
Protocols,
|
|
createDefaultTransportFormat,
|
|
FirmwareFileFormat,
|
|
tryUnzipFirmwareFile,
|
|
} from '@zwave-js/core'
|
|
import { JSONTransport } from '@zwave-js/log-transport-json'
|
|
import { isDocker } from '@zwave-js/shared'
|
|
import {
|
|
AssociationAddress,
|
|
AssociationGroup,
|
|
ControllerFirmwareUpdateProgress,
|
|
ControllerFirmwareUpdateResult,
|
|
ControllerFirmwareUpdateStatus,
|
|
ControllerStatistics,
|
|
ControllerStatus,
|
|
DataRate,
|
|
Driver,
|
|
ExclusionOptions,
|
|
ExclusionStrategy,
|
|
extractFirmware,
|
|
FirmwareUpdateCapabilities,
|
|
FirmwareUpdateProgress,
|
|
FirmwareUpdateResult,
|
|
FirmwareUpdateStatus,
|
|
FLiRS,
|
|
FoundNode,
|
|
GetFirmwareUpdatesOptions,
|
|
guessFirmwareFileFormat,
|
|
RebuildRoutesOptions,
|
|
RebuildRoutesStatus,
|
|
InclusionGrant,
|
|
InclusionOptions,
|
|
InclusionResult,
|
|
InclusionStrategy,
|
|
InterviewStage,
|
|
libVersion,
|
|
LifelineHealthCheckResult,
|
|
LifelineHealthCheckSummary,
|
|
MultilevelSwitchCommand,
|
|
NodeInterviewFailedEventArgs,
|
|
NodeStatistics,
|
|
NodeStatus,
|
|
NodeType,
|
|
PlannedProvisioningEntry,
|
|
ProtocolVersion,
|
|
QRCodeVersion,
|
|
QRProvisioningInformation,
|
|
RefreshInfoOptions,
|
|
RemoveNodeReason,
|
|
ReplaceNodeOptions,
|
|
RFRegion,
|
|
RouteHealthCheckResult,
|
|
RouteHealthCheckSummary,
|
|
ScheduleEntryLockCC,
|
|
ScheduleEntryLockDailyRepeatingSchedule,
|
|
ScheduleEntryLockScheduleKind,
|
|
ScheduleEntryLockSlotId,
|
|
ScheduleEntryLockWeekDaySchedule,
|
|
ScheduleEntryLockYearDaySchedule,
|
|
SerialAPISetupCommand,
|
|
SetValueAPIOptions,
|
|
setValueFailed,
|
|
SetValueResult,
|
|
SetValueStatus,
|
|
setValueWasUnsupervisedOrSucceeded,
|
|
SmartStartProvisioningEntry,
|
|
TranslatedValueID,
|
|
UserCodeCC,
|
|
UserIDStatus,
|
|
ValueID,
|
|
ValueMetadata,
|
|
ValueType,
|
|
ZWaveError,
|
|
ZWaveNode,
|
|
ZWaveNodeEvents,
|
|
ZWaveNodeFirmwareUpdateFinishedCallback,
|
|
ZWaveNodeFirmwareUpdateProgressCallback,
|
|
ZWaveNodeMetadataUpdatedArgs,
|
|
ZWaveNodeValueAddedArgs,
|
|
ZWaveNodeValueNotificationArgs,
|
|
ZWaveNodeValueRemovedArgs,
|
|
ZWaveNodeValueUpdatedArgs,
|
|
ZWaveNotificationCallback,
|
|
ZWaveOptions,
|
|
ZWavePlusNodeType,
|
|
ZWavePlusRoleType,
|
|
FirmwareUpdateInfo,
|
|
PartialZWaveOptions,
|
|
InclusionUserCallbacks,
|
|
InclusionState,
|
|
ProvisioningEntryStatus,
|
|
AssociationCheckResult,
|
|
LinkReliabilityCheckResult,
|
|
} from 'zwave-js'
|
|
import { getEnumMemberName, parseQRCodeString } from 'zwave-js/Utils'
|
|
import { logsDir, nvmBackupsDir, storeDir } from '../config/app'
|
|
import store from '../config/store'
|
|
import jsonStore from './jsonStore'
|
|
import * as LogManager from './logger'
|
|
import * as utils from './utils'
|
|
|
|
import { serverVersion, ZwavejsServer } from '@zwave-js/server'
|
|
import { ensureDir, exists, mkdirp, writeFile } from 'fs-extra'
|
|
import { Server as SocketServer } from 'socket.io'
|
|
import { TypedEventEmitter } from './EventEmitter'
|
|
import { GatewayValue } from './Gateway'
|
|
|
|
import { ConfigManager, DeviceConfig } from '@zwave-js/config'
|
|
import { readFile } from 'fs/promises'
|
|
import backupManager, { NVM_BACKUP_PREFIX } from './BackupManager'
|
|
import { socketEvents } from './SocketEvents'
|
|
import { isUint8Array } from 'util/types'
|
|
|
|
export const deviceConfigPriorityDir = storeDir + '/config'
|
|
|
|
export const configManager = new ConfigManager({
|
|
deviceConfigPriorityDir,
|
|
})
|
|
|
|
const logger = LogManager.module('Z-Wave')
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const loglevels = require('triple-beam').configs.npm.levels
|
|
|
|
const NEIGHBORS_LOCK_REFRESH = 60 * 1000
|
|
|
|
function validateMethods<T extends readonly (keyof ZwaveClient)[]>(
|
|
methods: T,
|
|
): T {
|
|
return methods
|
|
}
|
|
|
|
// ZwaveClient Apis that can be called with MQTT apis
|
|
export const allowedApis = validateMethods([
|
|
'setNodeName',
|
|
'setNodeLocation',
|
|
'setNodeDefaultSetValueOptions',
|
|
'_createScene',
|
|
'_removeScene',
|
|
'_setScenes',
|
|
'_getScenes',
|
|
'_sceneGetValues',
|
|
'_addSceneValue',
|
|
'_removeSceneValue',
|
|
'_activateScene',
|
|
'refreshNeighbors',
|
|
'getNodeNeighbors',
|
|
'discoverNodeNeighbors',
|
|
'getAssociations',
|
|
'checkAssociation',
|
|
'addAssociations',
|
|
'removeAssociations',
|
|
'removeAllAssociations',
|
|
'removeNodeFromAllAssociations',
|
|
'getNodes',
|
|
'getInfo',
|
|
'refreshValues',
|
|
'refreshCCValues',
|
|
'pollValue',
|
|
'setPowerlevel',
|
|
'setRFRegion',
|
|
'updateControllerNodeProps',
|
|
'startInclusion',
|
|
'startExclusion',
|
|
'stopInclusion',
|
|
'stopExclusion',
|
|
'replaceFailedNode',
|
|
'hardReset',
|
|
'softReset',
|
|
'rebuildNodeRoutes',
|
|
'getPriorityRoute',
|
|
'setPriorityRoute',
|
|
'assignReturnRoutes',
|
|
'getPriorityReturnRoute',
|
|
'getPrioritySUCReturnRoute',
|
|
'getCustomReturnRoute',
|
|
'getCustomSUCReturnRoute',
|
|
'assignPriorityReturnRoute',
|
|
'assignPrioritySUCReturnRoute',
|
|
'assignCustomReturnRoutes',
|
|
'assignCustomSUCReturnRoutes',
|
|
'deleteReturnRoutes',
|
|
'deleteSUCReturnRoutes',
|
|
'removePriorityRoute',
|
|
'beginRebuildingRoutes',
|
|
'stopRebuildingRoutes',
|
|
'isFailedNode',
|
|
'removeFailedNode',
|
|
'refreshInfo',
|
|
'updateFirmware',
|
|
'firmwareUpdateOTW',
|
|
'abortFirmwareUpdate',
|
|
'dumpNode',
|
|
'getAvailableFirmwareUpdates',
|
|
'firmwareUpdateOTA',
|
|
'sendCommand',
|
|
'writeValue',
|
|
'writeBroadcast',
|
|
'writeMulticast',
|
|
'driverFunction',
|
|
'checkForConfigUpdates',
|
|
'installConfigUpdate',
|
|
'shutdownZwaveAPI',
|
|
'pingNode',
|
|
'restart',
|
|
'grantSecurityClasses',
|
|
'validateDSK',
|
|
'abortInclusion',
|
|
'backupNVMRaw',
|
|
'restoreNVM',
|
|
'getProvisioningEntries',
|
|
'getProvisioningEntry',
|
|
'unprovisionSmartStartNode',
|
|
'provisionSmartStartNode',
|
|
'parseQRCodeString',
|
|
'checkLifelineHealth',
|
|
'abortHealthCheck',
|
|
'checkRouteHealth',
|
|
'checkLinkReliability',
|
|
'abortLinkReliabilityCheck',
|
|
'syncNodeDateAndTime',
|
|
'manuallyIdleNotificationValue',
|
|
'getSchedules',
|
|
'cancelGetSchedule',
|
|
'setSchedule',
|
|
'setEnabledSchedule',
|
|
] as const)
|
|
|
|
export type ZwaveNodeEvents = ZWaveNodeEvents | 'statistics updated'
|
|
|
|
export type ValueIdObserver = (
|
|
this: ZwaveClient,
|
|
node: ZUINode,
|
|
valueId: ZUIValueId,
|
|
) => void
|
|
|
|
// Define CommandClasses and properties that should be observed
|
|
const observedCCProps: {
|
|
[key in CommandClasses]?: Record<string, ValueIdObserver>
|
|
} = {
|
|
[CommandClasses.Battery]: {
|
|
level(node, value) {
|
|
const levels: { [key: number]: number } = node.batteryLevels || {}
|
|
|
|
levels[value.endpoint] = value.value
|
|
node.batteryLevels = levels
|
|
node.minBatteryLevel = Math.min(...Object.values(levels))
|
|
|
|
this.emitNodeUpdate(node, {
|
|
batteryLevels: levels,
|
|
minBatteryLevel: node.minBatteryLevel,
|
|
})
|
|
},
|
|
},
|
|
[CommandClasses['User Code']]: {
|
|
userIdStatus(node, value) {
|
|
const userId = value.propertyKey as number
|
|
const status = value.value as UserIDStatus
|
|
|
|
if (!node.userCodes) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
status === undefined ||
|
|
status === UserIDStatus.Available ||
|
|
status === UserIDStatus.StatusNotAvailable
|
|
) {
|
|
node.userCodes.available = node.userCodes.available.filter(
|
|
(id) => id !== userId,
|
|
)
|
|
} else {
|
|
node.userCodes.available.push(userId)
|
|
}
|
|
|
|
if (status === UserIDStatus.Enabled) {
|
|
node.userCodes.enabled.push(userId)
|
|
} else {
|
|
node.userCodes.enabled = node.userCodes.enabled.filter(
|
|
(id) => id !== userId,
|
|
)
|
|
}
|
|
|
|
this.emitNodeUpdate(node, {
|
|
userCodes: node.userCodes,
|
|
})
|
|
},
|
|
},
|
|
[CommandClasses['Node Naming and Location']]: {
|
|
name(node, value) {
|
|
this.setNodeName(node.id, value.value).catch((error) => {
|
|
logger.error(`Error while setting node name: ${error.message}`)
|
|
})
|
|
},
|
|
location(node, value) {
|
|
this.setNodeLocation(node.id, value.value).catch((error) => {
|
|
logger.error(
|
|
`Error while setting node location: ${error.message}`,
|
|
)
|
|
})
|
|
},
|
|
},
|
|
}
|
|
|
|
export type SensorTypeScale = {
|
|
key: string | number
|
|
sensor: string
|
|
label: string
|
|
unit?: string
|
|
description?: string
|
|
}
|
|
|
|
export type AllowedApis = (typeof allowedApis)[number]
|
|
|
|
const ZWAVEJS_LOG_FILE = utils.joinPath(logsDir, 'zwavejs_%DATE%.log')
|
|
|
|
export type ZUIValueIdState = {
|
|
text: string
|
|
value: number | string | boolean
|
|
}
|
|
|
|
export type ZUIClientStatus = {
|
|
driverReady: boolean
|
|
status: boolean
|
|
config: ZwaveConfig
|
|
}
|
|
|
|
export type ZUIGroupAssociation = {
|
|
groupId: number
|
|
nodeId: number
|
|
endpoint?: number
|
|
targetEndpoint?: number
|
|
}
|
|
|
|
export type ZUIValueId = {
|
|
id: string
|
|
nodeId: number
|
|
type: ValueType
|
|
readable: boolean
|
|
writeable: boolean
|
|
toUpdate?: boolean
|
|
description?: string
|
|
label?: string
|
|
default: any
|
|
stateless: boolean
|
|
ccSpecific: Record<string, any>
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
unit?: string
|
|
minLength?: number
|
|
maxLength?: number
|
|
states?: ZUIValueIdState[]
|
|
list?: boolean
|
|
lastUpdate?: number
|
|
value?: any
|
|
targetValue?: string
|
|
isCurrentValue?: boolean
|
|
conf?: GatewayValue
|
|
allowManualEntry?: boolean
|
|
commandClassVersion?: number
|
|
} & TranslatedValueID
|
|
|
|
export type ZUIValueIdScene = ZUIValueId & {
|
|
timeout: number
|
|
}
|
|
|
|
export type ZUIScene = {
|
|
sceneid: number
|
|
label: string
|
|
values: ZUIValueIdScene[]
|
|
}
|
|
|
|
export type ZUIDeviceClass = {
|
|
basic: number
|
|
generic: number
|
|
specific: number
|
|
}
|
|
|
|
export type ZUINodeGroups = {
|
|
text: string
|
|
value: number
|
|
endpoint: number
|
|
maxNodes: number
|
|
isLifeline: boolean
|
|
multiChannel: boolean
|
|
}
|
|
|
|
export type CallAPIResult<T extends AllowedApis> =
|
|
| {
|
|
success: true
|
|
message: string
|
|
result: ReturnType<ZwaveClient[T]>
|
|
args?: Parameters<ZwaveClient[T]>
|
|
}
|
|
| {
|
|
success: false
|
|
message: string
|
|
args?: Parameters<ZwaveClient[T]>
|
|
}
|
|
|
|
export type HassDevice = {
|
|
type:
|
|
| 'sensor'
|
|
| 'light'
|
|
| 'binary_sensor'
|
|
| 'cover'
|
|
| 'climate'
|
|
| 'lock'
|
|
| 'switch'
|
|
| 'fan'
|
|
| 'number'
|
|
object_id: string
|
|
discovery_payload: { [key: string]: any }
|
|
discoveryTopic?: string
|
|
values?: string[]
|
|
action_map?: { [key: number]: string }
|
|
setpoint_topic?: { [key: number]: string }
|
|
default_setpoint?: string
|
|
persistent?: boolean
|
|
ignoreDiscovery?: boolean
|
|
fan_mode_map?: { [key: string]: number }
|
|
mode_map?: { [key: string]: number }
|
|
id?: string
|
|
}
|
|
|
|
export class DriverNotReadyError extends Error {
|
|
public constructor() {
|
|
super('Driver is not ready')
|
|
|
|
// We need to set the prototype explicitly
|
|
Object.setPrototypeOf(this, DriverNotReadyError.prototype)
|
|
Object.getPrototypeOf(this).name = 'DriverNotReadyError'
|
|
}
|
|
}
|
|
|
|
export interface BackgroundRSSIValue {
|
|
current: number
|
|
average: number
|
|
}
|
|
|
|
export interface BackgroundRSSIPoint {
|
|
channel0: BackgroundRSSIValue
|
|
channel1: BackgroundRSSIValue
|
|
channel2?: BackgroundRSSIValue
|
|
channel3?: BackgroundRSSIValue
|
|
timestamp: number
|
|
}
|
|
|
|
export interface FwFile {
|
|
name: string
|
|
data: Buffer | Uint8Array
|
|
target?: number
|
|
}
|
|
|
|
export interface ZUIEndpoint {
|
|
index: number
|
|
label?: string
|
|
deviceClass: {
|
|
basic: number
|
|
generic: number
|
|
specific: number
|
|
}
|
|
}
|
|
|
|
export enum ZUIScheduleEntryLockMode {
|
|
DAILY = 'daily',
|
|
WEEKLY = 'weekly',
|
|
YEARLY = 'yearly',
|
|
}
|
|
|
|
export interface ZUISchedule {
|
|
[ZUIScheduleEntryLockMode.DAILY]: ZUIScheduleConfig<ScheduleEntryLockDailyRepeatingSchedule>
|
|
[ZUIScheduleEntryLockMode.WEEKLY]: ZUIScheduleConfig<ScheduleEntryLockWeekDaySchedule>
|
|
[ZUIScheduleEntryLockMode.YEARLY]: ZUIScheduleConfig<ScheduleEntryLockYearDaySchedule>
|
|
}
|
|
|
|
export type ZUISlot<T> = T & { enabled: boolean } & ScheduleEntryLockSlotId
|
|
|
|
export interface ZUIScheduleConfig<T> {
|
|
numSlots: number
|
|
slots: ZUISlot<T>[]
|
|
}
|
|
|
|
export type ZUINode = {
|
|
id: number
|
|
deviceConfig?: DeviceConfig
|
|
manufacturerId?: number
|
|
productId?: number
|
|
productLabel?: string
|
|
productDescription?: string
|
|
statistics?: ControllerStatistics | NodeStatistics
|
|
applicationRoute?: Route
|
|
priorityReturnRoute?: Record<number, Route>
|
|
prioritySUCReturnRoute?: Route
|
|
customReturnRoute?: Record<number, Route[]>
|
|
customSUCReturnRoutes?: Route[]
|
|
productType?: number
|
|
manufacturer?: string
|
|
firmwareVersion?: string
|
|
sdkVersion?: string
|
|
protocolVersion?: ProtocolVersion
|
|
zwavePlusVersion?: number | undefined
|
|
zwavePlusNodeType?: ZWavePlusNodeType | undefined
|
|
zwavePlusRoleType?: ZWavePlusRoleType | undefined
|
|
nodeType?: NodeType
|
|
endpointsCount?: number
|
|
endpoints?: ZUIEndpoint[]
|
|
isSecure?: boolean | 'unknown'
|
|
security?: string | undefined
|
|
supportsBeaming?: boolean
|
|
supportsSecurity?: boolean
|
|
supportsTime?: boolean
|
|
isListening?: boolean
|
|
isControllerNode?: boolean
|
|
powerlevel?: number
|
|
measured0dBm?: number
|
|
RFRegion?: RFRegion
|
|
rfRegions?: { text: string; value: number }[]
|
|
isFrequentListening?: FLiRS
|
|
isRouting?: boolean
|
|
keepAwake?: boolean
|
|
deviceClass?: ZUIDeviceClass
|
|
neighbors?: number[]
|
|
loc?: string
|
|
name?: string
|
|
hassDevices?: { [key: string]: HassDevice }
|
|
deviceId?: string
|
|
hasDeviceConfigChanged?: boolean
|
|
hexId?: string
|
|
values?: { [key: string]: ZUIValueId }
|
|
groups?: ZUINodeGroups[]
|
|
ready: boolean
|
|
available: boolean
|
|
failed: boolean
|
|
lastActive?: number
|
|
dbLink?: string
|
|
maxDataRate?: DataRate
|
|
interviewStage?: keyof typeof InterviewStage
|
|
status?: keyof typeof NodeStatus
|
|
inited: boolean
|
|
rebuildRoutesProgress?: RebuildRoutesStatus | undefined
|
|
minBatteryLevel?: number
|
|
batteryLevels?: { [key: number]: number }
|
|
firmwareUpdate?: FirmwareUpdateProgress
|
|
firmwareCapabilities?: FirmwareUpdateCapabilities
|
|
eventsQueue: NodeEvent[]
|
|
bgRSSIPoints?: BackgroundRSSIPoint[]
|
|
schedule?: ZUISchedule
|
|
userCodes?: {
|
|
total: number
|
|
available: number[]
|
|
enabled: number[]
|
|
}
|
|
defaultTransitionDuration?: string
|
|
defaultVolume?: number
|
|
protocol?: Protocols
|
|
supportsLongRange?: boolean
|
|
}
|
|
|
|
export type NodeEvent = {
|
|
event: ZwaveNodeEvents | 'status changed'
|
|
args: any[]
|
|
time: Date
|
|
}
|
|
|
|
export type ZwaveConfig = {
|
|
enabled?: boolean
|
|
allowBootloaderOnly?: boolean
|
|
port?: string
|
|
networkKey?: string
|
|
securityKeys?: utils.DeepPartial<{
|
|
S2_Unauthenticated: string
|
|
S2_Authenticated: string
|
|
S2_AccessControl: string
|
|
S0_Legacy: string
|
|
}>
|
|
securityKeysLongRange?: utils.DeepPartial<{
|
|
S2_Authenticated: string
|
|
S2_AccessControl: string
|
|
}>
|
|
serverEnabled?: boolean
|
|
enableSoftReset?: boolean
|
|
disableWatchdog?: boolean
|
|
deviceConfigPriorityDir?: string
|
|
serverPort?: number
|
|
serverHost?: string
|
|
logEnabled?: boolean
|
|
maxFiles?: number
|
|
logLevel?: LogManager.LogLevel
|
|
commandsTimeout?: number
|
|
sendToSleepTimeout?: number
|
|
responseTimeout?: number
|
|
enableStatistics?: boolean
|
|
disclaimerVersion?: number
|
|
options?: ZWaveOptions
|
|
// healNetwork?: boolean
|
|
healHour?: number
|
|
logToFile?: boolean
|
|
nodeFilter?: string[]
|
|
scales?: SensorTypeScale[]
|
|
serverServiceDiscoveryDisabled?: boolean
|
|
maxNodeEventsQueueSize?: number
|
|
higherReportsTimeout?: boolean
|
|
disableControllerRecovery?: boolean
|
|
rf?: {
|
|
region?: RFRegion
|
|
txPower?: {
|
|
powerlevel: number
|
|
measured0dBm: number
|
|
}
|
|
}
|
|
}
|
|
|
|
export type ZUIDriverInfo = {
|
|
uptime?: number
|
|
lastUpdate?: number
|
|
status?: ZwaveClientStatus
|
|
cntStatus?: string
|
|
inclusionState?: InclusionState
|
|
appVersion?: string
|
|
zwaveVersion?: string
|
|
serverVersion?: string
|
|
error?: string | undefined
|
|
homeid?: number
|
|
name?: string
|
|
controllerId?: number
|
|
newConfigVersion?: string | undefined
|
|
}
|
|
|
|
export enum ZwaveClientStatus {
|
|
CONNECTED = 'connected',
|
|
BOOTLOADER_READY = 'bootloader ready',
|
|
DRIVER_READY = 'driver ready',
|
|
SCAN_DONE = 'scan done',
|
|
DRIVER_FAILED = 'driver failed',
|
|
CLOSED = 'closed',
|
|
}
|
|
|
|
export enum EventSource {
|
|
DRIVER = 'driver',
|
|
CONTROLLER = 'controller',
|
|
NODE = 'node',
|
|
}
|
|
|
|
export interface ZwaveClientEventCallbacks {
|
|
nodeStatus: (node: ZUINode) => void
|
|
nodeLastActive: (node: ZUINode) => void
|
|
nodeInited: (node: ZUINode) => void
|
|
event: (source: EventSource, eventName: string, ...args: any) => void
|
|
scanComplete: () => void
|
|
driverStatus: (status: boolean) => void
|
|
notification: (node: ZUINode, valueId: ZUIValueId, data: any) => void
|
|
nodeRemoved: (node: Partial<ZUINode>) => void
|
|
valueChanged: (
|
|
valueId: ZUIValueId,
|
|
node: ZUINode,
|
|
changed?: boolean,
|
|
) => void
|
|
valueWritten: (valueId: ZUIValueId, node: ZUINode, value: unknown) => void
|
|
}
|
|
|
|
export type ZwaveClientEvents = Extract<keyof ZwaveClientEventCallbacks, string>
|
|
|
|
class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
|
|
private cfg: ZwaveConfig
|
|
private socket: SocketServer
|
|
private closed: boolean
|
|
private destroyed = false
|
|
private _driverReady: boolean
|
|
private scenes: ZUIScene[]
|
|
private _nodes: Map<number, ZUINode>
|
|
private storeNodes: Record<number, Partial<ZUINode>>
|
|
private _devices: Record<string, Partial<ZUINode>>
|
|
private driverInfo: ZUIDriverInfo
|
|
private status: ZwaveClientStatus
|
|
// used to store node info before inclusion like name and location
|
|
private tmpNode: utils.DeepPartial<ZUINode>
|
|
// tells if a node replacement is in progress
|
|
private isReplacing = false
|
|
|
|
private hasUserCallbacks = false
|
|
|
|
private _error: string | undefined
|
|
private _scanComplete: boolean
|
|
private _cntStatus: string
|
|
|
|
private lastUpdate: number
|
|
|
|
private _driver: Driver
|
|
|
|
private server: ZwavejsServer
|
|
private statelessTimeouts: Record<string, NodeJS.Timeout>
|
|
private commandsTimeout: NodeJS.Timeout
|
|
private healTimeout: NodeJS.Timeout
|
|
private updatesCheckTimeout: NodeJS.Timeout
|
|
private pollIntervals: Record<string, NodeJS.Timeout>
|
|
|
|
private _lockNeighborsRefresh: boolean
|
|
private _lockGetSchedule: boolean
|
|
private _cancelGetSchedule: boolean
|
|
|
|
private nvmEvent: string
|
|
|
|
private backoffRetry = 0
|
|
private restartTimeout: NodeJS.Timeout
|
|
|
|
private driverFunctionCache: utils.Snippet[] = []
|
|
|
|
// Foreach valueId, we store a callback function to be called when the value changes
|
|
private valuesObservers: Record<string, ValueIdObserver> = {}
|
|
|
|
private _grantResolve: (grant: InclusionGrant | false) => void | null
|
|
private _dskResolve: (dsk: string | false) => void | null
|
|
|
|
private throttledFunctions: Map<
|
|
string,
|
|
{ lastUpdate: number; fn: () => void; timeout: NodeJS.Timeout }
|
|
> = new Map()
|
|
|
|
private inclusionUserCallbacks: InclusionUserCallbacks = {
|
|
grantSecurityClasses: this._onGrantSecurityClasses.bind(this),
|
|
validateDSKAndEnterPIN: this._onValidateDSK.bind(this),
|
|
abort: this._onAbortInclusion.bind(this),
|
|
}
|
|
private _inclusionState: InclusionState = undefined
|
|
|
|
private _controllerListenersAdded: boolean = false
|
|
public get driverReady() {
|
|
return this.driver && this._driverReady && !this.closed
|
|
}
|
|
|
|
public set driverReady(ready) {
|
|
if (this._driverReady !== ready) {
|
|
this._driverReady = ready
|
|
this.emit('driverStatus', ready)
|
|
}
|
|
}
|
|
|
|
public get cntStatus() {
|
|
return this._cntStatus
|
|
}
|
|
|
|
public get scanComplete() {
|
|
return this._scanComplete
|
|
}
|
|
|
|
public get error() {
|
|
return this._error
|
|
}
|
|
|
|
public get driver() {
|
|
return this._driver
|
|
}
|
|
|
|
public get nodes() {
|
|
return this._nodes
|
|
}
|
|
|
|
public get devices() {
|
|
return this._devices
|
|
}
|
|
|
|
public get maxNodeEventsQueueSize() {
|
|
return this.cfg.maxNodeEventsQueueSize || 100
|
|
}
|
|
|
|
public get cacheSnippets(): utils.Snippet[] {
|
|
return this.driverFunctionCache
|
|
}
|
|
|
|
constructor(config: ZwaveConfig, socket: SocketServer) {
|
|
super()
|
|
|
|
this.cfg = config
|
|
this.socket = socket
|
|
|
|
this.init()
|
|
}
|
|
|
|
get homeHex() {
|
|
return this.driverInfo.name
|
|
}
|
|
|
|
/**
|
|
* Init internal vars
|
|
*/
|
|
init() {
|
|
this.statelessTimeouts = {}
|
|
this.pollIntervals = {}
|
|
|
|
this._lockNeighborsRefresh = false
|
|
|
|
this.closed = false
|
|
this.driverReady = false
|
|
this.scenes = jsonStore.get(store.scenes)
|
|
|
|
this._nodes = new Map()
|
|
this.storeNodes = jsonStore.get(store.nodes)
|
|
|
|
// convert store nodes from array to object
|
|
if (Array.isArray(this.storeNodes)) {
|
|
const storeNodes = {}
|
|
|
|
for (let i = 0; i < this.storeNodes.length; i++) {
|
|
if (this.storeNodes[i]) {
|
|
storeNodes[i] = this.storeNodes[i]
|
|
}
|
|
}
|
|
|
|
this.storeNodes = storeNodes
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
this.updateStoreNodes(false)
|
|
}
|
|
|
|
this._devices = {}
|
|
this.driverInfo = {}
|
|
this.healTimeout = null
|
|
|
|
this.status = ZwaveClientStatus.CLOSED
|
|
}
|
|
|
|
/**
|
|
* Restart client connection
|
|
*
|
|
*/
|
|
async restart(): Promise<void> {
|
|
await this.close(true)
|
|
this.init()
|
|
await this.connect()
|
|
}
|
|
|
|
backoffRestart(): void {
|
|
// fix edge case where client is half closed and restart is called
|
|
if (this.checkIfDestroyed()) {
|
|
return
|
|
}
|
|
|
|
const timeout = Math.min(2 ** this.backoffRetry * 1000, 15000)
|
|
this.backoffRetry++
|
|
|
|
logger.info(
|
|
`Restarting client in ${timeout / 1000} seconds, retry ${
|
|
this.backoffRetry
|
|
}`,
|
|
)
|
|
|
|
this.restartTimeout = setTimeout(() => {
|
|
this.restart().catch((error) => {
|
|
logger.error(`Error while restarting driver: ${error.message}`)
|
|
})
|
|
}, timeout)
|
|
}
|
|
|
|
/**
|
|
* Checks if this client is destroyed and if so closes it
|
|
* @returns True if client is destroyed
|
|
*/
|
|
checkIfDestroyed() {
|
|
if (this.destroyed) {
|
|
logger.debug(
|
|
`Client listening on '${this.cfg.port}' is destroyed, closing`,
|
|
)
|
|
this.close(true).catch((error) => {
|
|
logger.error(`Error while closing driver: ${error.message}`)
|
|
})
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Used to schedule next network rebuildNodeRoutes at hours: cfg.healHours
|
|
*/
|
|
// scheduleHeal() {
|
|
// if (!this.cfg.healNetwork) {
|
|
// return
|
|
// }
|
|
|
|
// const now = new Date()
|
|
// let start: Date
|
|
// const hour = this.cfg.healHour
|
|
|
|
// if (now.getHours() < hour) {
|
|
// start = new Date(
|
|
// now.getFullYear(),
|
|
// now.getMonth(),
|
|
// now.getDate(),
|
|
// hour,
|
|
// 0,
|
|
// 0,
|
|
// 0
|
|
// )
|
|
// } else {
|
|
// start = new Date(
|
|
// now.getFullYear(),
|
|
// now.getMonth(),
|
|
// now.getDate() + 1,
|
|
// hour,
|
|
// 0,
|
|
// 0,
|
|
// 0
|
|
// )
|
|
// }
|
|
|
|
// const wait = start.getTime() - now.getTime()
|
|
|
|
// if (wait < 0) {
|
|
// this.scheduleHeal()
|
|
// } else {
|
|
// this.healTimeout = setTimeout(() => {
|
|
// this.rebuildNodeRoutes()
|
|
// }, wait)
|
|
// }
|
|
// }
|
|
|
|
/**
|
|
* Call `fn` function at most once every `wait` milliseconds
|
|
* */
|
|
private throttle(key: string, fn: () => void, wait: number) {
|
|
const entry = this.throttledFunctions.get(key)
|
|
const now = Date.now()
|
|
|
|
// first time it's called or wait is already passed since last call
|
|
if (!entry || entry.lastUpdate + wait < now) {
|
|
this.throttledFunctions.set(key, {
|
|
lastUpdate: now,
|
|
fn,
|
|
timeout: null,
|
|
})
|
|
fn()
|
|
} else {
|
|
// if it's called again and no timeout is set, set a timeout to call function
|
|
if (!entry.timeout) {
|
|
entry.timeout = setTimeout(
|
|
() => {
|
|
const oldEntry = this.throttledFunctions.get(key)
|
|
if (oldEntry?.fn) {
|
|
oldEntry.lastUpdate = Date.now()
|
|
fn()
|
|
}
|
|
},
|
|
entry.lastUpdate + wait - now,
|
|
)
|
|
}
|
|
// discard the old function and store the new one
|
|
entry.fn = fn
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the driver ZWaveNode object
|
|
*/
|
|
getNode(nodeId: number): ZWaveNode {
|
|
return this._driver.controller.nodes.get(nodeId)
|
|
}
|
|
|
|
setUserCallbacks() {
|
|
this.hasUserCallbacks = true
|
|
if (!this._driver || !this.cfg.serverEnabled) {
|
|
return
|
|
}
|
|
|
|
logger.info('Setting user callbacks')
|
|
|
|
this.driver.updateOptions({
|
|
inclusionUserCallbacks: {
|
|
...this.inclusionUserCallbacks,
|
|
},
|
|
})
|
|
}
|
|
|
|
removeUserCallbacks() {
|
|
this.hasUserCallbacks = false
|
|
if (!this._driver || !this.cfg.serverEnabled) {
|
|
return
|
|
}
|
|
|
|
logger.info('Removing user callbacks')
|
|
|
|
this.driver.updateOptions({
|
|
inclusionUserCallbacks: undefined,
|
|
})
|
|
|
|
// when no user is connected, give back the control to HA server
|
|
if (this.server?.['sockets'] !== undefined) {
|
|
this.server.setInclusionUserCallbacks()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the driver ZWaveNode ValueId object or null
|
|
*/
|
|
getZwaveValue(idString: string): ValueID {
|
|
if (!idString || typeof idString !== 'string') {
|
|
return null
|
|
}
|
|
|
|
const parts = idString.split('-')
|
|
|
|
if (parts.length < 3) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
commandClass: parseInt(parts[0]),
|
|
endpoint: parseInt(parts[1]),
|
|
property: parts[2],
|
|
propertyKey: parts[3],
|
|
}
|
|
}
|
|
|
|
subscribeObservers(node: ZUINode, valueId: ZUIValueId) {
|
|
const valueObserver =
|
|
observedCCProps[valueId.commandClass]?.[valueId.property]
|
|
|
|
if (valueObserver) {
|
|
this.valuesObservers[valueId.id] = valueObserver
|
|
valueObserver.call(this, node, valueId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calls driver healNetwork function and schedule next rebuildNodeRoutes
|
|
*
|
|
*/
|
|
// rebuildNodeRoutes() {
|
|
// if (this.healTimeout) {
|
|
// clearTimeout(this.healTimeout)
|
|
// this.healTimeout = null
|
|
// }
|
|
|
|
// try {
|
|
// this.beginRebuildingRoutes()
|
|
// logger.info('Network auto rebuildNodeRoutes started')
|
|
// } catch (error) {
|
|
// logger.error(
|
|
// `Error while doing scheduled network rebuildNodeRoutes ${error.message}`,
|
|
// error
|
|
// )
|
|
// }
|
|
|
|
// // schedule next
|
|
// this.scheduleHeal()
|
|
// }
|
|
|
|
/**
|
|
* Used to Update an hass device of a specific node
|
|
*
|
|
*/
|
|
updateDevice(hassDevice: HassDevice, nodeId: number, deleteDevice = false) {
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
// check for existing node and node hassdevice with given id
|
|
if (node && hassDevice.id && node.hassDevices?.[hassDevice.id]) {
|
|
if (deleteDevice) {
|
|
delete node.hassDevices[hassDevice.id]
|
|
} else {
|
|
const id = hassDevice.id
|
|
delete hassDevice.id
|
|
node.hassDevices[id] = hassDevice
|
|
}
|
|
|
|
this.emitNodeUpdate(node, {
|
|
hassDevices: node.hassDevices,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Used to Add a new hass device to a specific node
|
|
*/
|
|
addDevice(hassDevice: HassDevice, nodeId: number) {
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
// check for existing node and node hassdevice with given id
|
|
if (node && hassDevice.id) {
|
|
delete hassDevice.id
|
|
const id = hassDevice.type + '_' + hassDevice.object_id
|
|
hassDevice.persistent = false
|
|
node.hassDevices[id] = hassDevice
|
|
|
|
this.emitNodeUpdate(node, {
|
|
hassDevices: node.hassDevices,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Used to update hass devices list of a specific node and store them in `nodes.json`
|
|
*
|
|
*/
|
|
async storeDevices(
|
|
devices: { [key: string]: HassDevice },
|
|
nodeId: number,
|
|
remove: any,
|
|
) {
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
for (const id in devices) {
|
|
devices[id].persistent = !remove
|
|
}
|
|
|
|
if (remove) {
|
|
delete this.storeNodes[nodeId].hassDevices
|
|
} else {
|
|
this.storeNodes[nodeId].hassDevices = devices
|
|
}
|
|
|
|
node.hassDevices = utils.copy(devices)
|
|
await this.updateStoreNodes()
|
|
|
|
this.emitNodeUpdate(node, {
|
|
hassDevices: node.hassDevices,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Method used to close client connection, use this before destroy
|
|
*/
|
|
async close(keepListeners = false) {
|
|
this.status = ZwaveClientStatus.CLOSED
|
|
this.closed = true
|
|
this.driverReady = false
|
|
|
|
if (this.commandsTimeout) {
|
|
clearTimeout(this.commandsTimeout)
|
|
this.commandsTimeout = null
|
|
}
|
|
|
|
if (this.restartTimeout) {
|
|
clearTimeout(this.restartTimeout)
|
|
this.restartTimeout = null
|
|
}
|
|
|
|
if (this.healTimeout) {
|
|
clearTimeout(this.healTimeout)
|
|
this.healTimeout = null
|
|
}
|
|
|
|
if (this.updatesCheckTimeout) {
|
|
clearTimeout(this.updatesCheckTimeout)
|
|
this.updatesCheckTimeout = null
|
|
}
|
|
|
|
if (this.statelessTimeouts) {
|
|
for (const k in this.statelessTimeouts) {
|
|
clearTimeout(this.statelessTimeouts[k])
|
|
delete this.statelessTimeouts[k]
|
|
}
|
|
}
|
|
|
|
if (this.pollIntervals) {
|
|
for (const k in this.pollIntervals) {
|
|
clearTimeout(this.pollIntervals[k])
|
|
delete this.pollIntervals[k]
|
|
}
|
|
}
|
|
|
|
for (const [key, entry] of this.throttledFunctions) {
|
|
clearTimeout(entry.timeout)
|
|
this.throttledFunctions.delete(key)
|
|
}
|
|
|
|
if (this.server) {
|
|
await this.server.destroy()
|
|
this.server = null
|
|
}
|
|
|
|
if (this._driver) {
|
|
await this._driver.destroy()
|
|
this._driver = null
|
|
this._controllerListenersAdded = false
|
|
}
|
|
|
|
if (!keepListeners) {
|
|
this.destroyed = true
|
|
this.removeAllListeners()
|
|
}
|
|
|
|
logger.info('Client closed')
|
|
}
|
|
|
|
getStatus() {
|
|
const status: ZUIClientStatus = {
|
|
driverReady: this.driverReady,
|
|
status: this.driverReady && !this.closed,
|
|
config: this.cfg,
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
/** Used to get the general state of the client. Sent to socket on connection */
|
|
getState() {
|
|
return {
|
|
nodes: this.getNodes(),
|
|
info: this.getInfo(),
|
|
error: this.error,
|
|
cntStatus: this.cntStatus,
|
|
inclusionState: this._inclusionState,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If the node supports Schedule Lock CC parses all available schedules and cache them
|
|
*/
|
|
async getSchedules(
|
|
nodeId: number,
|
|
opts: { mode?: ZUIScheduleEntryLockMode; fromCache: boolean } = {
|
|
fromCache: true,
|
|
},
|
|
) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode?.commandClasses['Schedule Entry Lock'].isSupported()) {
|
|
throw new Error(
|
|
'Schedule Entry Lock CC not supported on node ' + nodeId,
|
|
)
|
|
}
|
|
|
|
if (this._lockGetSchedule) {
|
|
throw new Error(
|
|
'Another request is in progress, cancel it or wait...',
|
|
)
|
|
}
|
|
|
|
const promise = async () => {
|
|
this._cancelGetSchedule = false
|
|
this._lockGetSchedule = true
|
|
const { mode, fromCache } = opts
|
|
// TODO: should we check also other endpoints?
|
|
const endpointIndex = 0
|
|
const endpoint = zwaveNode.getEndpoint(endpointIndex)
|
|
|
|
const userCodes = UserCodeCC.getSupportedUsersCached(
|
|
this.driver,
|
|
endpoint,
|
|
)
|
|
|
|
const numSlots = {
|
|
numWeekDaySlots: ScheduleEntryLockCC.getNumWeekDaySlotsCached(
|
|
this.driver,
|
|
endpoint,
|
|
),
|
|
numYearDaySlots: ScheduleEntryLockCC.getNumYearDaySlotsCached(
|
|
this.driver,
|
|
endpoint,
|
|
),
|
|
numDailyRepeatingSlots:
|
|
ScheduleEntryLockCC.getNumDailyRepeatingSlotsCached(
|
|
this.driver,
|
|
endpoint,
|
|
),
|
|
}
|
|
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
const weeklySchedules: ZUISlot<ScheduleEntryLockWeekDaySchedule>[] =
|
|
node.schedule?.weekly?.slots ?? []
|
|
const yearlySchedules: ZUISlot<ScheduleEntryLockYearDaySchedule>[] =
|
|
node.schedule?.yearly?.slots ?? []
|
|
const dailySchedules: ZUISlot<ScheduleEntryLockDailyRepeatingSchedule>[] =
|
|
node.schedule?.daily?.slots ?? []
|
|
|
|
node.schedule = {
|
|
daily: {
|
|
numSlots: numSlots.numDailyRepeatingSlots,
|
|
slots: dailySchedules,
|
|
},
|
|
weekly: {
|
|
numSlots: numSlots.numWeekDaySlots,
|
|
slots: weeklySchedules,
|
|
},
|
|
yearly: {
|
|
numSlots: numSlots.numYearDaySlots,
|
|
slots: yearlySchedules,
|
|
},
|
|
}
|
|
|
|
node.userCodes = {
|
|
total: userCodes,
|
|
available: [],
|
|
enabled: [],
|
|
}
|
|
|
|
const pushSchedule = (
|
|
arr: ZUISlot<any>[],
|
|
slot: ScheduleEntryLockSlotId,
|
|
schedule:
|
|
| ScheduleEntryLockWeekDaySchedule
|
|
| ScheduleEntryLockYearDaySchedule
|
|
| ScheduleEntryLockDailyRepeatingSchedule,
|
|
enabled: boolean,
|
|
) => {
|
|
const index = arr.findIndex(
|
|
(s) => s.userId === slot.userId && s.slotId === slot.slotId,
|
|
)
|
|
if (schedule) {
|
|
const newSlot = {
|
|
...slot,
|
|
...schedule,
|
|
enabled,
|
|
}
|
|
if (index === -1) {
|
|
arr.push(newSlot)
|
|
} else {
|
|
arr[index] = newSlot
|
|
}
|
|
} else if (index !== -1) {
|
|
arr.splice(index, 1)
|
|
}
|
|
}
|
|
|
|
for (let i = 1; i <= userCodes; i++) {
|
|
const status = UserCodeCC.getUserIdStatusCached(
|
|
this.driver,
|
|
endpoint,
|
|
i,
|
|
)
|
|
|
|
if (
|
|
status === undefined ||
|
|
status === UserIDStatus.Available ||
|
|
status === UserIDStatus.StatusNotAvailable
|
|
) {
|
|
// skip query on not enabled userIds or empty codes
|
|
continue
|
|
}
|
|
|
|
node.userCodes.available.push(i)
|
|
|
|
const enabledUserId =
|
|
ScheduleEntryLockCC.getUserCodeScheduleEnabledCached(
|
|
this.driver,
|
|
endpoint,
|
|
i,
|
|
)
|
|
|
|
if (enabledUserId) {
|
|
node.userCodes.enabled.push(i)
|
|
}
|
|
|
|
const enabledType =
|
|
ScheduleEntryLockCC.getUserCodeScheduleKindCached(
|
|
this.driver,
|
|
endpoint,
|
|
i,
|
|
)
|
|
|
|
const getCached = (
|
|
kind: ScheduleEntryLockScheduleKind,
|
|
slotId: number,
|
|
) =>
|
|
ScheduleEntryLockCC.getScheduleCached(
|
|
this.driver,
|
|
endpoint,
|
|
kind,
|
|
i,
|
|
slotId,
|
|
)
|
|
|
|
if (!mode || mode === ZUIScheduleEntryLockMode.WEEKLY) {
|
|
const enabled =
|
|
enabledType === ScheduleEntryLockScheduleKind.WeekDay
|
|
|
|
for (let s = 1; s <= numSlots.numWeekDaySlots; s++) {
|
|
if (this._cancelGetSchedule) return
|
|
|
|
const slot: ScheduleEntryLockSlotId = {
|
|
userId: i,
|
|
slotId: s,
|
|
}
|
|
|
|
const schedule = fromCache
|
|
? <ScheduleEntryLockWeekDaySchedule>(
|
|
getCached(
|
|
ScheduleEntryLockScheduleKind.WeekDay,
|
|
s,
|
|
)
|
|
)
|
|
: await zwaveNode.commandClasses[
|
|
'Schedule Entry Lock'
|
|
].getWeekDaySchedule(slot)
|
|
|
|
pushSchedule(weeklySchedules, slot, schedule, enabled)
|
|
}
|
|
}
|
|
|
|
if (!mode || mode === ZUIScheduleEntryLockMode.YEARLY) {
|
|
const enabled =
|
|
enabledType === ScheduleEntryLockScheduleKind.YearDay
|
|
|
|
for (let s = 1; s <= numSlots.numYearDaySlots; s++) {
|
|
if (this._cancelGetSchedule) return
|
|
|
|
const slot: ScheduleEntryLockSlotId = {
|
|
userId: i,
|
|
slotId: s,
|
|
}
|
|
const schedule = fromCache
|
|
? <ScheduleEntryLockYearDaySchedule>(
|
|
getCached(
|
|
ScheduleEntryLockScheduleKind.YearDay,
|
|
s,
|
|
)
|
|
)
|
|
: await zwaveNode.commandClasses[
|
|
'Schedule Entry Lock'
|
|
].getYearDaySchedule(slot)
|
|
|
|
pushSchedule(yearlySchedules, slot, schedule, enabled)
|
|
}
|
|
}
|
|
|
|
if (!mode || mode === ZUIScheduleEntryLockMode.DAILY) {
|
|
const enabled =
|
|
enabledType ===
|
|
ScheduleEntryLockScheduleKind.DailyRepeating
|
|
|
|
for (let s = 1; s <= numSlots.numDailyRepeatingSlots; s++) {
|
|
if (this._cancelGetSchedule) return
|
|
|
|
const slot: ScheduleEntryLockSlotId = {
|
|
userId: i,
|
|
slotId: s,
|
|
}
|
|
const schedule = fromCache
|
|
? <ScheduleEntryLockDailyRepeatingSchedule>(
|
|
getCached(
|
|
ScheduleEntryLockScheduleKind.DailyRepeating,
|
|
s,
|
|
)
|
|
)
|
|
: await zwaveNode.commandClasses[
|
|
'Schedule Entry Lock'
|
|
].getDailyRepeatingSchedule(slot)
|
|
|
|
pushSchedule(dailySchedules, slot, schedule, enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
this.emitNodeUpdate(node, {
|
|
schedule: node.schedule,
|
|
userCodes: node.userCodes,
|
|
})
|
|
|
|
return node.schedule
|
|
}
|
|
|
|
return promise().finally(() => {
|
|
this._lockGetSchedule = false
|
|
this._cancelGetSchedule = false
|
|
})
|
|
}
|
|
|
|
cancelGetSchedule() {
|
|
this._cancelGetSchedule = true
|
|
}
|
|
|
|
async setSchedule(
|
|
nodeId: number,
|
|
type: 'daily' | 'weekly' | 'yearly',
|
|
schedule: ScheduleEntryLockSlotId &
|
|
(
|
|
| ScheduleEntryLockDailyRepeatingSchedule
|
|
| ScheduleEntryLockWeekDaySchedule
|
|
| ScheduleEntryLockYearDaySchedule
|
|
),
|
|
) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode?.commandClasses['Schedule Entry Lock'].isSupported()) {
|
|
throw new Error(
|
|
'Schedule Entry Lock CC not supported on node ' + nodeId,
|
|
)
|
|
}
|
|
|
|
const slot: ScheduleEntryLockSlotId = {
|
|
userId: schedule.userId,
|
|
slotId: schedule.slotId,
|
|
}
|
|
|
|
delete schedule.userId
|
|
delete schedule.slotId
|
|
delete schedule['enabled']
|
|
|
|
const isDelete = Object.keys(schedule).length === 0
|
|
|
|
if (isDelete) {
|
|
schedule = undefined
|
|
}
|
|
|
|
let result: SupervisionResult
|
|
|
|
if (type === 'daily') {
|
|
result = await zwaveNode.commandClasses[
|
|
'Schedule Entry Lock'
|
|
].setDailyRepeatingSchedule(
|
|
slot,
|
|
schedule as ScheduleEntryLockDailyRepeatingSchedule,
|
|
)
|
|
} else if (type === 'weekly') {
|
|
result = await zwaveNode.commandClasses[
|
|
'Schedule Entry Lock'
|
|
].setWeekDaySchedule(
|
|
slot,
|
|
schedule as ScheduleEntryLockWeekDaySchedule,
|
|
)
|
|
} else if (type === 'yearly') {
|
|
result = await zwaveNode.commandClasses[
|
|
'Schedule Entry Lock'
|
|
].setYearDaySchedule(
|
|
slot,
|
|
schedule as ScheduleEntryLockYearDaySchedule,
|
|
)
|
|
} else {
|
|
throw new Error('Invalid schedule type')
|
|
}
|
|
|
|
// means that is not using supervision, read slot and check if it matches
|
|
if (!result) {
|
|
const methods = {
|
|
daily: 'getDailyRepeatingSchedule',
|
|
weekly: 'getWeekDaySchedule',
|
|
yearly: 'getYearDaySchedule',
|
|
}
|
|
const res =
|
|
await zwaveNode.commandClasses['Schedule Entry Lock'][
|
|
methods[type]
|
|
](slot)
|
|
|
|
if (
|
|
(isDelete && !res) ||
|
|
(!isDelete && res && utils.deepEqual(res, schedule))
|
|
) {
|
|
result = {
|
|
status: SupervisionStatus.Success,
|
|
}
|
|
} else {
|
|
result = {
|
|
status: SupervisionStatus.Fail,
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result.status === SupervisionStatus.Success) {
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
// update enabled state
|
|
for (const mode in node.schedule) {
|
|
node.schedule[mode].slots = node.schedule[mode].slots.map(
|
|
(s: ZUISlot<any>) => ({
|
|
...s,
|
|
enabled: mode === type,
|
|
}),
|
|
)
|
|
}
|
|
|
|
const slots = node.schedule?.[type]?.slots
|
|
|
|
if (slots) {
|
|
const slotIndex = slots.findIndex(
|
|
(s) => s.userId === slot.userId && s.slotId === slot.slotId,
|
|
)
|
|
const newSlot = isDelete
|
|
? null
|
|
: {
|
|
...slot,
|
|
...schedule,
|
|
enabled: true,
|
|
}
|
|
|
|
if (isDelete) {
|
|
if (slotIndex !== -1) {
|
|
slots.splice(slotIndex, 1)
|
|
}
|
|
} else if (slotIndex !== -1) {
|
|
slots[slotIndex] = newSlot
|
|
} else {
|
|
slots.push(newSlot as any)
|
|
}
|
|
|
|
const isEnabledUsercode = node.userCodes?.enabled?.includes(
|
|
slot.userId,
|
|
)
|
|
|
|
if (!isDelete && !isEnabledUsercode) {
|
|
node.userCodes.enabled.push(slot.userId)
|
|
} else if (isDelete && isEnabledUsercode) {
|
|
const index = node.userCodes.enabled.indexOf(slot.userId)
|
|
if (index >= 0) {
|
|
node.userCodes.enabled.splice(index, 1)
|
|
}
|
|
}
|
|
|
|
this.emitNodeUpdate(node, {
|
|
schedule: node.schedule,
|
|
userCodes: node.userCodes,
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
async setEnabledSchedule(nodeId: number, enabled: boolean, userId: number) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw new Error('Node not found')
|
|
}
|
|
|
|
const result = await zwaveNode.commandClasses[
|
|
'Schedule Entry Lock'
|
|
].setEnabled(enabled, userId)
|
|
|
|
// if result is not defined here we don't have a way
|
|
// to know if the command was successful or not as there is no
|
|
// 'get' command for this, so we just assume it was successful
|
|
if (isUnsupervisedOrSucceeded(result)) {
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
if (userId) {
|
|
if (enabled) {
|
|
node.userCodes?.enabled.push(userId)
|
|
} else {
|
|
const index = node.userCodes?.enabled.indexOf(userId)
|
|
if (index >= 0) {
|
|
node.userCodes.enabled.splice(index, 1)
|
|
}
|
|
}
|
|
} else {
|
|
node.userCodes.enabled = enabled
|
|
? node.userCodes.available.slice()
|
|
: []
|
|
}
|
|
|
|
this.emitNodeUpdate(node, {
|
|
userCodes: node.userCodes,
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Populate node `groups`
|
|
*/
|
|
getGroups(nodeId: number, ignoreUpdate = false) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
const node = this._nodes.get(nodeId)
|
|
if (node && zwaveNode) {
|
|
let endpointGroups: ReadonlyMap<
|
|
number,
|
|
ReadonlyMap<number, AssociationGroup>
|
|
> = new Map()
|
|
try {
|
|
endpointGroups =
|
|
this._driver.controller.getAllAssociationGroups(nodeId)
|
|
} catch (error) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Error while fetching groups associations: ${error.message}`,
|
|
)
|
|
}
|
|
node.groups = []
|
|
|
|
for (const [endpoint, groups] of endpointGroups) {
|
|
for (const [groupIndex, group] of groups) {
|
|
// https://zwave-js.github.io/node-zwave-js/#/api/controller?id=associationgroup-interface
|
|
node.groups.push({
|
|
text: group.label,
|
|
endpoint: endpoint,
|
|
value: groupIndex,
|
|
maxNodes: group.maxNodes,
|
|
isLifeline: group.isLifeline,
|
|
multiChannel: group.multiChannel,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if (node && !ignoreUpdate) {
|
|
this.emitNodeUpdate(node, { groups: node.groups })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get an array of current [associations](https://zwave-js.github.io/node-zwave-js/#/api/controller?id=association-interface) of a specific group
|
|
*/
|
|
async getAssociations(
|
|
nodeId: number,
|
|
refresh = false,
|
|
): Promise<ZUIGroupAssociation[]> {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
const toReturn: ZUIGroupAssociation[] = []
|
|
|
|
if (zwaveNode) {
|
|
try {
|
|
if (refresh) {
|
|
await zwaveNode.refreshCCValues(CommandClasses.Association)
|
|
await zwaveNode.refreshCCValues(
|
|
CommandClasses['Multi Channel Association'],
|
|
)
|
|
}
|
|
// https://zwave-js.github.io/node-zwave-js/#/api/controller?id=association-interface
|
|
// the result is a map where the key is the group number and the value is the array of associations {nodeId, endpoint?}
|
|
const result =
|
|
this._driver.controller.getAllAssociations(nodeId)
|
|
for (const [source, group] of result.entries()) {
|
|
for (const [groupId, associations] of group) {
|
|
for (const a of associations) {
|
|
toReturn.push({
|
|
endpoint: source.endpoint,
|
|
groupId: groupId,
|
|
nodeId: a.nodeId,
|
|
targetEndpoint: a.endpoint,
|
|
} as ZUIGroupAssociation)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Error while fetching groups associations: ${error.message}`,
|
|
)
|
|
// node doesn't support groups associations
|
|
}
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Error while fetching groups associations, node not found`,
|
|
)
|
|
}
|
|
|
|
return toReturn
|
|
}
|
|
|
|
/**
|
|
* Check if a given association is allowed
|
|
*/
|
|
checkAssociation(
|
|
source: AssociationAddress,
|
|
groupId: number,
|
|
association: AssociationAddress,
|
|
) {
|
|
return this.driver.controller.checkAssociation(
|
|
source,
|
|
groupId,
|
|
association,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Add a node to the array of specified [associations](https://zwave-js.github.io/node-zwave-js/#/api/controller?id=association-interface)
|
|
*/
|
|
async addAssociations(
|
|
source: AssociationAddress,
|
|
groupId: number,
|
|
associations: AssociationAddress[],
|
|
) {
|
|
const zwaveNode = this.getNode(source.nodeId)
|
|
|
|
const sourceMsg = `Node ${
|
|
source.nodeId +
|
|
(source.endpoint ? ' Endpoint ' + source.endpoint : '')
|
|
}`
|
|
|
|
if (!zwaveNode) {
|
|
throw new Error(`Node ${source.nodeId} not found`)
|
|
}
|
|
|
|
const result: AssociationCheckResult[] = []
|
|
|
|
for (const a of associations) {
|
|
const checkResult = this._driver.controller.checkAssociation(
|
|
source,
|
|
groupId,
|
|
a,
|
|
)
|
|
|
|
result.push(checkResult)
|
|
|
|
if (checkResult === AssociationCheckResult.OK) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Adding Node ${a.nodeId} to Group ${groupId} of ${sourceMsg}`,
|
|
)
|
|
|
|
await this._driver.controller.addAssociations(source, groupId, [
|
|
a,
|
|
])
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Unable to add Node ${a.nodeId} to Group ${groupId} of ${sourceMsg}: ${getEnumMemberName(AssociationCheckResult, checkResult)}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Remove a node from an association group
|
|
*
|
|
*/
|
|
async removeAssociations(
|
|
source: AssociationAddress,
|
|
groupId: number,
|
|
associations: AssociationAddress[],
|
|
) {
|
|
const zwaveNode = this.getNode(source.nodeId)
|
|
|
|
const sourceMsg = `Node ${
|
|
source.nodeId +
|
|
(source.endpoint ? ' Endpoint ' + source.endpoint : '')
|
|
}`
|
|
|
|
if (zwaveNode) {
|
|
try {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Removing associations from ${sourceMsg} Group ${groupId}: %o`,
|
|
associations,
|
|
)
|
|
|
|
await this._driver.controller.removeAssociations(
|
|
source,
|
|
groupId,
|
|
associations,
|
|
)
|
|
} catch (error) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Error while removing associations from ${sourceMsg}: ${error.message}`,
|
|
)
|
|
}
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Error while removing associations from ${sourceMsg}, node not found`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove all associations
|
|
*/
|
|
async removeAllAssociations(nodeId: number) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (zwaveNode) {
|
|
try {
|
|
const allAssociations =
|
|
this._driver.controller.getAllAssociations(nodeId)
|
|
|
|
for (const [
|
|
source,
|
|
groupAssociations,
|
|
] of allAssociations.entries()) {
|
|
for (const [groupId, associations] of groupAssociations) {
|
|
if (associations.length > 0) {
|
|
await this._driver.controller.removeAssociations(
|
|
source,
|
|
groupId,
|
|
associations as AssociationAddress[],
|
|
)
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Removed ${
|
|
associations.length
|
|
} associations from Node ${
|
|
source.nodeId +
|
|
(source.endpoint
|
|
? ' Endpoint ' + source.endpoint
|
|
: '')
|
|
} group ${groupId}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Error while removing all associations from ${nodeId}: ${error.message}`,
|
|
)
|
|
}
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Node not found when calling 'removeAllAssociations'`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setting the date and time on a node could be hard, this helper method will set it using the date provided (default to now).
|
|
*
|
|
* The following CCs will be used (when supported or necessary) in this process:
|
|
* - Time Parameters CC
|
|
* - Clock CC
|
|
* - Time CC
|
|
* - Schedule Entry Lock CC (for setting the timezone)
|
|
*/
|
|
syncNodeDateAndTime(nodeId: number, date = new Date()): Promise<boolean> {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (zwaveNode) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Syncing Node ${nodeId} date and time`,
|
|
)
|
|
|
|
return zwaveNode.setDateAndTime(date)
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Node not found when calling 'syncNodeDateAndTime'`,
|
|
)
|
|
}
|
|
}
|
|
|
|
manuallyIdleNotificationValue(valueId: ZUIValueId) {
|
|
const zwaveNode = this.getNode(valueId.nodeId)
|
|
|
|
if (zwaveNode) {
|
|
zwaveNode.manuallyIdleNotificationValue(valueId)
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Node not found when calling 'manuallyIdleNotificationValue'`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove node from all associations
|
|
*/
|
|
async removeNodeFromAllAssociations(nodeId: number) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (zwaveNode) {
|
|
try {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Removing Node ${nodeId} from all associations`,
|
|
)
|
|
|
|
await this._driver.controller.removeNodeFromAllAssociations(
|
|
nodeId,
|
|
)
|
|
} catch (error) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Error while removing Node ${nodeId} from all associations: ${error.message}`,
|
|
)
|
|
}
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`Node not found when calling 'removeNodeFromAllAssociations'`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh all nodes neighbors
|
|
*/
|
|
async refreshNeighbors(): Promise<Record<number, number[]>> {
|
|
if (this._lockNeighborsRefresh === true) {
|
|
throw Error('you can refresh neighbors only once every 60 seconds')
|
|
}
|
|
|
|
this._lockNeighborsRefresh = true
|
|
|
|
// set the timeout here so if something fails later we don't keep the lock
|
|
setTimeout(
|
|
() => (this._lockNeighborsRefresh = false),
|
|
NEIGHBORS_LOCK_REFRESH,
|
|
)
|
|
|
|
const toReturn = {}
|
|
// when accessing the controller memory, the Z-Wave radio must be turned off with to avoid resource conflicts and inconsistent data
|
|
await this._driver.controller.toggleRF(false)
|
|
for (const [nodeId, node] of this._nodes) {
|
|
await this.getNodeNeighbors(nodeId, true, false)
|
|
toReturn[nodeId] = node.neighbors
|
|
}
|
|
// turn rf back to on
|
|
await this._driver.controller.toggleRF(true)
|
|
|
|
return toReturn
|
|
}
|
|
|
|
/**
|
|
* Get neighbors of a specific node
|
|
*/
|
|
async getNodeNeighbors(
|
|
nodeId: number,
|
|
preventThrow = false,
|
|
emitNodeUpdate = true,
|
|
): Promise<readonly number[]> {
|
|
try {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (zwaveNode.protocol === Protocols.ZWaveLongRange) {
|
|
return []
|
|
}
|
|
|
|
const neighbors =
|
|
await this._driver.controller.getNodeNeighbors(nodeId)
|
|
this.logNode(nodeId, 'debug', `Neighbors: ${neighbors.join(', ')}`)
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
node.neighbors = [...neighbors]
|
|
if (emitNodeUpdate) {
|
|
this.emitNodeUpdate(node, {
|
|
neighbors: node.neighbors,
|
|
})
|
|
}
|
|
}
|
|
|
|
return neighbors
|
|
} catch (error) {
|
|
this.logNode(
|
|
nodeId,
|
|
'warn',
|
|
`Error while getting neighbors from ${nodeId}: ${error.message}`,
|
|
)
|
|
|
|
if (!preventThrow) {
|
|
throw error
|
|
}
|
|
|
|
return Promise.resolve([])
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Instructs a node to (re-)discover its neighbors.
|
|
*/
|
|
async discoverNodeNeighbors(nodeId: number): Promise<boolean> {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
const result =
|
|
await this._driver.controller.discoverNodeNeighbors(nodeId)
|
|
|
|
if (result) {
|
|
// update neighbors
|
|
this.getNodeNeighbors(nodeId, true).catch(() => {
|
|
// noop
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Execute a driver function.
|
|
* More info [here](/usage/driver_function?id=driver-function)
|
|
*/
|
|
driverFunction(code: string): Promise<any> {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
if (!this.driverFunctionCache.find((c) => c.content === code)) {
|
|
const name = `CACHED_${this.driverFunctionCache.length}`
|
|
this.driverFunctionCache.push({ name, content: code })
|
|
}
|
|
|
|
const AsyncFunction = Object.getPrototypeOf(
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
async function () {},
|
|
).constructor
|
|
|
|
const fn = new AsyncFunction('driver', code)
|
|
|
|
return fn.call({ zwaveClient: this, require, logger }, this._driver)
|
|
}
|
|
|
|
/**
|
|
* Method used to start Z-Wave connection using configuration `port`
|
|
*/
|
|
async connect() {
|
|
if (this.cfg.enabled === false) {
|
|
logger.info('Z-Wave driver DISABLED')
|
|
return
|
|
}
|
|
|
|
if (this.driverReady) {
|
|
logger.info(`Driver already connected to ${this.cfg.port}`)
|
|
return
|
|
}
|
|
|
|
// this could happen when the driver fails the connect and a reconnect timeout triggers
|
|
if (this.closed || this.checkIfDestroyed()) {
|
|
return
|
|
}
|
|
|
|
if (!this.cfg?.port) {
|
|
logger.warn('Z-Wave driver not inited, no port configured')
|
|
return
|
|
}
|
|
|
|
// extend options with hidden `options`
|
|
const zwaveOptions: PartialZWaveOptions = {
|
|
allowBootloaderOnly: this.cfg.allowBootloaderOnly || false,
|
|
storage: {
|
|
cacheDir: storeDir,
|
|
deviceConfigPriorityDir:
|
|
this.cfg.deviceConfigPriorityDir || deviceConfigPriorityDir,
|
|
},
|
|
logConfig: {
|
|
// https://zwave-js.github.io/node-zwave-js/#/api/driver?id=logconfig
|
|
enabled: this.cfg.logEnabled,
|
|
level: this.cfg.logLevel
|
|
? loglevels[this.cfg.logLevel]
|
|
: 'info',
|
|
logToFile: this.cfg.logToFile,
|
|
filename: ZWAVEJS_LOG_FILE,
|
|
forceConsole: isDocker() ? !this.cfg.logToFile : false,
|
|
maxFiles: this.cfg.maxFiles || 7,
|
|
nodeFilter:
|
|
this.cfg.nodeFilter && this.cfg.nodeFilter.length > 0
|
|
? this.cfg.nodeFilter.map((n) => parseInt(n))
|
|
: undefined,
|
|
},
|
|
emitValueUpdateAfterSetValue: true,
|
|
apiKeys: {
|
|
firmwareUpdateService:
|
|
'421e29797c3c2926f84efc737352d6190354b3b526a6dce6633674dd33a8a4f964c794f5',
|
|
},
|
|
timeouts: {
|
|
report: this.cfg.higherReportsTimeout ? 10000 : undefined,
|
|
sendToSleep: this.cfg.sendToSleepTimeout,
|
|
response: this.cfg.responseTimeout,
|
|
},
|
|
features: {
|
|
unresponsiveControllerRecovery: this.cfg
|
|
.disableControllerRecovery
|
|
? false
|
|
: true,
|
|
watchdog: this.cfg.disableWatchdog ? false : true,
|
|
},
|
|
userAgent: {
|
|
[utils.pkgJson.name]: utils.pkgJson.version,
|
|
},
|
|
}
|
|
|
|
if (this.cfg.rf) {
|
|
const { region, txPower } = this.cfg.rf
|
|
|
|
zwaveOptions.rf = {}
|
|
|
|
if (typeof region === 'number') {
|
|
zwaveOptions.rf.region = region
|
|
}
|
|
|
|
if (
|
|
txPower &&
|
|
typeof txPower.measured0dBm === 'number' &&
|
|
typeof txPower.powerlevel === 'number'
|
|
) {
|
|
zwaveOptions.rf.txPower = txPower
|
|
}
|
|
}
|
|
|
|
// ensure deviceConfigPriorityDir exists to prevent warnings #2374
|
|
// lgtm [js/path-injection]
|
|
await ensureDir(zwaveOptions.storage.deviceConfigPriorityDir)
|
|
|
|
// when not set let zwavejs handle this based on the environment
|
|
if (typeof this.cfg.enableSoftReset === 'boolean') {
|
|
zwaveOptions.features.softReset = this.cfg.enableSoftReset
|
|
}
|
|
|
|
// when server is not enabled, disable the user callbacks set/remove
|
|
// so it can be used through MQTT
|
|
if (!this.cfg.serverEnabled) {
|
|
zwaveOptions.inclusionUserCallbacks = {
|
|
...this.inclusionUserCallbacks,
|
|
}
|
|
}
|
|
|
|
if (this.cfg.scales) {
|
|
const scales: Record<string | number, string | number> = {}
|
|
for (const s of this.cfg.scales) {
|
|
scales[s.key] = s.label
|
|
}
|
|
|
|
zwaveOptions.preferences = {
|
|
scales,
|
|
}
|
|
}
|
|
|
|
Object.assign(zwaveOptions, this.cfg.options)
|
|
|
|
let s0Key: string
|
|
|
|
// back compatibility
|
|
if (this.cfg.networkKey) {
|
|
s0Key = this.cfg.networkKey
|
|
delete this.cfg.networkKey
|
|
}
|
|
|
|
this.cfg.securityKeys = this.cfg.securityKeys || {}
|
|
|
|
// update settings to fix compatibility
|
|
if (s0Key && !this.cfg.securityKeys.S0_Legacy) {
|
|
this.cfg.securityKeys.S0_Legacy = s0Key
|
|
const settings = jsonStore.get(store.settings)
|
|
settings.zwave = this.cfg
|
|
await jsonStore.put(store.settings, settings)
|
|
}
|
|
|
|
utils.parseSecurityKeys(this.cfg, zwaveOptions)
|
|
|
|
const logTransport = new JSONTransport()
|
|
logTransport.format = createDefaultTransportFormat(true, false)
|
|
|
|
zwaveOptions.logConfig.transports = [logTransport]
|
|
|
|
logTransport.stream.on('data', (data) => {
|
|
this.socket.emit(socketEvents.debug, data.message.toString())
|
|
})
|
|
|
|
try {
|
|
// init driver here because if connect fails the driver is destroyed
|
|
// this could throw so include in the try/catch
|
|
this._driver = new Driver(this.cfg.port, zwaveOptions)
|
|
this._controllerListenersAdded = false
|
|
this._driver.on('error', this._onDriverError.bind(this))
|
|
this._driver.on('driver ready', this._onDriverReady.bind(this))
|
|
this._driver.on('all nodes ready', this._onScanComplete.bind(this))
|
|
this._driver.on(
|
|
'bootloader ready',
|
|
this._onBootLoaderReady.bind(this),
|
|
)
|
|
|
|
logger.info(`Connecting to ${this.cfg.port}`)
|
|
|
|
// setup user callbacks only if there are connected clients
|
|
this.hasUserCallbacks =
|
|
(await this.socket.fetchSockets()).length > 0
|
|
|
|
if (this.hasUserCallbacks) {
|
|
this.setUserCallbacks()
|
|
}
|
|
|
|
await this._driver.start()
|
|
|
|
if (this.checkIfDestroyed()) {
|
|
return
|
|
}
|
|
|
|
if (this.cfg.serverEnabled) {
|
|
this.server = new ZwavejsServer(this._driver, {
|
|
port: this.cfg.serverPort || 3000,
|
|
host: this.cfg.serverHost,
|
|
logger: LogManager.module('Z-Wave-Server'),
|
|
enableDNSServiceDiscovery:
|
|
!this.cfg.serverServiceDiscoveryDisabled,
|
|
})
|
|
|
|
this.server.on('error', () => {
|
|
// this is already logged by the server but we need this to prevent
|
|
// unhandled exceptions
|
|
})
|
|
|
|
this.server.on('hard reset', () => {
|
|
logger.info('Hard reset requested by ZwaveJS Server')
|
|
this.init()
|
|
})
|
|
}
|
|
|
|
if (this.cfg.enableStatistics) {
|
|
this.enableStatistics()
|
|
}
|
|
|
|
this.status = ZwaveClientStatus.CONNECTED
|
|
} catch (error) {
|
|
// destroy diver instance when it fails
|
|
if (this._driver) {
|
|
this._driver.destroy().catch((err) => {
|
|
logger.error(
|
|
`Error while destroying driver ${err.message}`,
|
|
error,
|
|
)
|
|
})
|
|
}
|
|
|
|
if (this.checkIfDestroyed()) {
|
|
return
|
|
}
|
|
|
|
this._onDriverError(error, true)
|
|
|
|
if (error.code !== ZWaveErrorCodes.Driver_InvalidOptions) {
|
|
this.backoffRestart()
|
|
} else {
|
|
logger.error(
|
|
`Invalid options for driver: ${error.message}`,
|
|
error,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private logNode(
|
|
node: ZWaveNode | ZUINode | number,
|
|
level: LogManager.LogLevel,
|
|
message: string,
|
|
...args: any[]
|
|
) {
|
|
const nodeId = typeof node === 'number' ? node : node.id
|
|
logger.log(
|
|
level,
|
|
`[Node ${utils.padNumber(nodeId, 3)}] ${message}`,
|
|
...args,
|
|
)
|
|
}
|
|
|
|
private onNodeNameLocationChanged(
|
|
node: ZUINode,
|
|
valueId: ZUIValueId,
|
|
value: string,
|
|
) {
|
|
const prop = valueId.property
|
|
const observer =
|
|
observedCCProps[CommandClasses['Node Naming and Location']]?.[prop]
|
|
|
|
if (observer) {
|
|
observer.call(this, node, {
|
|
...valueId,
|
|
value,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an event to socket with `data`
|
|
*
|
|
*/
|
|
private sendToSocket(evtName: string, data: any, ...args: any[]) {
|
|
if (this.socket) {
|
|
// break the sync loop to let the event loop continue #2676
|
|
process.nextTick(() => {
|
|
this.socket.emit(evtName, data, ...args)
|
|
})
|
|
}
|
|
}
|
|
|
|
private async sendInitToSockets() {
|
|
const sockets = await this.socket.fetchSockets()
|
|
|
|
for (const socket of sockets) {
|
|
// force send init to all connected sockets
|
|
socket.emit(socketEvents.init, this.getState())
|
|
}
|
|
}
|
|
|
|
public emitValueChanged(
|
|
valueId: ZUIValueId,
|
|
node: ZUINode,
|
|
changed: boolean,
|
|
) {
|
|
valueId.lastUpdate =
|
|
this.getNode(valueId.nodeId)?.getValueTimestamp(valueId) ??
|
|
Date.now()
|
|
|
|
this.sendToSocket(socketEvents.valueUpdated, valueId)
|
|
|
|
this.emit('valueChanged', valueId, node, changed)
|
|
}
|
|
|
|
public emitStatistics(
|
|
node: ZUINode,
|
|
props: Pick<
|
|
ZUINode,
|
|
| 'statistics'
|
|
| 'lastActive'
|
|
| 'applicationRoute'
|
|
| 'customSUCReturnRoutes'
|
|
| 'customReturnRoute'
|
|
| 'prioritySUCReturnRoute'
|
|
| 'priorityReturnRoute'
|
|
> & { bgRssi?: ControllerStatistics['backgroundRSSI'] },
|
|
) {
|
|
// NB: be sure that when `statistics` is defined also `lastActive` must be.
|
|
// when removing props them should be set to null or false in order to be removed on ui
|
|
this.sendToSocket(socketEvents.statistics, {
|
|
nodeId: node.id,
|
|
...Object.keys(props).reduce((acc, k) => {
|
|
if (props[k] === null) acc[k] = false
|
|
else acc[k] = props[k]
|
|
return acc
|
|
}, {} as any),
|
|
})
|
|
}
|
|
|
|
public emitNodeUpdate(
|
|
node: ZUINode,
|
|
changedProps?: utils.DeepPartial<ZUINode>,
|
|
) {
|
|
if (node.ready && !node.inited) {
|
|
node.inited = true
|
|
this.emit('nodeInited', node)
|
|
}
|
|
|
|
const isPartial = !!changedProps
|
|
|
|
if (!isPartial || utils.hasProperty(changedProps, 'status')) {
|
|
this.emit('nodeStatus', node)
|
|
}
|
|
|
|
if (isPartial) {
|
|
// we need it to have a reference of the node to update
|
|
changedProps.id = node.id
|
|
}
|
|
|
|
this.sendToSocket(
|
|
socketEvents.nodeUpdated,
|
|
changedProps ?? node,
|
|
isPartial,
|
|
)
|
|
}
|
|
|
|
// ------------NODES MANAGEMENT-----------------------------------
|
|
|
|
async updateStoreNodes(throwError = true) {
|
|
try {
|
|
logger.debug('Updating store nodes.json')
|
|
await jsonStore.put(store.nodes, this.storeNodes)
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error while updating store nodes: ${error.message}`,
|
|
error,
|
|
)
|
|
if (throwError) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates node `name` property and stores updated config in `nodes.json`
|
|
*/
|
|
async setNodeName(nodeid: number, name: string) {
|
|
if (!this.storeNodes[nodeid]) {
|
|
this.storeNodes[nodeid] = {} as ZUINode
|
|
}
|
|
|
|
const node = this._nodes.get(nodeid)
|
|
const zwaveNode = this.getNode(nodeid)
|
|
|
|
if (zwaveNode && node) {
|
|
node.name = name
|
|
if (zwaveNode.name !== name) {
|
|
zwaveNode.name = name
|
|
}
|
|
} else {
|
|
throw Error('Invalid Node ID')
|
|
}
|
|
|
|
this.storeNodes[nodeid].name = name
|
|
|
|
await this.updateStoreNodes()
|
|
|
|
this.emitNodeUpdate(node, { name: name })
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Updates node `loc` property and stores updated config in `nodes.json`
|
|
*/
|
|
async setNodeLocation(nodeid: number, loc: string) {
|
|
if (!this.storeNodes[nodeid]) {
|
|
this.storeNodes[nodeid] = {}
|
|
}
|
|
|
|
const node = this._nodes.get(nodeid)
|
|
const zwaveNode = this.getNode(nodeid)
|
|
|
|
if (node) {
|
|
node.loc = loc
|
|
if (zwaveNode.location !== loc) {
|
|
zwaveNode.location = loc
|
|
}
|
|
} else {
|
|
throw Error('Invalid Node ID')
|
|
}
|
|
|
|
this.storeNodes[nodeid].loc = loc
|
|
|
|
await this.updateStoreNodes()
|
|
this.emitNodeUpdate(node, { loc: loc })
|
|
return true
|
|
}
|
|
|
|
setNodeDefaultSetValueOptions(
|
|
nodeId: number,
|
|
props: Pick<ZUINode, 'defaultTransitionDuration' | 'defaultVolume'>,
|
|
) {
|
|
const node = this._nodes.get(nodeId)
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw Error('Invalid Node ID')
|
|
}
|
|
|
|
for (const k in props) {
|
|
zwaveNode[k] = props[k]
|
|
if (node) node[k] = props[k]
|
|
}
|
|
}
|
|
|
|
// ------------SCENES MANAGEMENT-----------------------------------
|
|
/**
|
|
* Creates a new scene with a specific `label` and stores it in `scenes.json`
|
|
*/
|
|
async _createScene(label: string) {
|
|
const id =
|
|
this.scenes.length > 0
|
|
? this.scenes[this.scenes.length - 1].sceneid + 1
|
|
: 1
|
|
this.scenes.push({
|
|
sceneid: id,
|
|
label: label,
|
|
values: [],
|
|
})
|
|
|
|
await jsonStore.put(store.scenes, this.scenes)
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Delete a scene with a specific `sceneid` and updates `scenes.json`
|
|
*/
|
|
async _removeScene(sceneid: number) {
|
|
const index = this.scenes.findIndex((s) => s.sceneid === sceneid)
|
|
|
|
if (index < 0) {
|
|
throw Error('No scene found with given sceneid')
|
|
}
|
|
|
|
this.scenes.splice(index, 1)
|
|
|
|
await jsonStore.put(store.scenes, this.scenes)
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Imports scenes Array in `scenes.json`
|
|
*/
|
|
async _setScenes(scenes: ZUIScene[]) {
|
|
// TODO: add scenes validation
|
|
this.scenes = scenes
|
|
await jsonStore.put(store.scenes, this.scenes)
|
|
|
|
return scenes
|
|
}
|
|
|
|
/**
|
|
* Get all scenes
|
|
*
|
|
*/
|
|
_getScenes(): ZUIScene[] {
|
|
return this.scenes
|
|
}
|
|
|
|
/**
|
|
* Return all values of the scene with given `sceneid`
|
|
*/
|
|
_sceneGetValues(sceneid: number) {
|
|
const scene = this.scenes.find((s) => s.sceneid === sceneid)
|
|
if (!scene) {
|
|
throw Error('No scene found with given sceneid')
|
|
}
|
|
return scene.values
|
|
}
|
|
|
|
/**
|
|
* Add a value to a scene
|
|
*
|
|
*/
|
|
async _addSceneValue(
|
|
sceneid: number,
|
|
valueId: ZUIValueIdScene,
|
|
value: any,
|
|
timeout: number,
|
|
) {
|
|
const scene = this.scenes.find((s) => s.sceneid === sceneid)
|
|
const node = this._nodes.get(valueId.nodeId)
|
|
|
|
if (!scene) {
|
|
throw Error('No scene found with given sceneid')
|
|
}
|
|
|
|
if (!node) {
|
|
throw Error(`Node ${valueId.nodeId} not found`)
|
|
} else {
|
|
// check if it is an existing valueid
|
|
if (!node.values[this._getValueID(valueId)]) {
|
|
throw Error('No value found with given valueId')
|
|
} else {
|
|
// if this valueid is already in owr scene edit it else create new one
|
|
const index = scene.values.findIndex((s) => s.id === valueId.id)
|
|
|
|
valueId = index < 0 ? valueId : scene.values[index]
|
|
valueId.value = value
|
|
valueId.timeout = timeout || 0
|
|
|
|
if (index < 0) {
|
|
scene.values.push(valueId)
|
|
}
|
|
}
|
|
}
|
|
|
|
return jsonStore.put(store.scenes, this.scenes)
|
|
}
|
|
|
|
/**
|
|
* Remove a value from scene
|
|
*/
|
|
async _removeSceneValue(sceneid: number, valueId: ZUIValueIdScene) {
|
|
const scene = this.scenes.find((s) => s.sceneid === sceneid)
|
|
|
|
if (!scene) {
|
|
throw Error('No scene found with given sceneid')
|
|
}
|
|
|
|
// get the index with also the node identifier as prefix
|
|
const index = scene.values.findIndex((s) => s.id === valueId.id)
|
|
|
|
if (index < 0) {
|
|
throw Error('No ValueId match found in given scene')
|
|
} else {
|
|
scene.values.splice(index, 1)
|
|
}
|
|
|
|
return jsonStore.put(store.scenes, this.scenes)
|
|
}
|
|
|
|
/**
|
|
* Activate a scene with given scene id
|
|
*/
|
|
_activateScene(sceneId: number): boolean {
|
|
const values = this._sceneGetValues(sceneId) || []
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
setTimeout(
|
|
() => {
|
|
this.writeValue(values[i], values[i].value).catch(
|
|
logger.error,
|
|
)
|
|
},
|
|
values[i].timeout ? values[i].timeout * 1000 : 0,
|
|
)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Get the nodes array
|
|
*/
|
|
getNodes(): ZUINode[] {
|
|
const toReturn = []
|
|
|
|
for (const [, node] of this._nodes) {
|
|
toReturn.push(node)
|
|
}
|
|
return toReturn
|
|
}
|
|
|
|
/**
|
|
* Enable Statistics
|
|
*
|
|
*/
|
|
enableStatistics() {
|
|
if (this._driver) {
|
|
this._driver.enableStatistics({
|
|
applicationName:
|
|
utils.pkgJson.name +
|
|
(this.cfg.serverEnabled ? ' / zwave-js-server' : ''),
|
|
applicationVersion: utils.pkgJson.version,
|
|
})
|
|
logger.info('Zwavejs usage statistics ENABLED')
|
|
}
|
|
|
|
logger.warn(
|
|
'Zwavejs driver is not ready yet, statistics will be enabled on driver initialization',
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Disable Statistics
|
|
*
|
|
*/
|
|
disableStatistics() {
|
|
if (this._driver) {
|
|
this._driver.disableStatistics()
|
|
logger.info('Zwavejs usage statistics DISABLED')
|
|
}
|
|
|
|
logger.warn(
|
|
'Zwavejs driver is not ready yet, statistics will be disabled on driver initialization',
|
|
)
|
|
}
|
|
|
|
getInfo() {
|
|
const info = Object.assign({}, this.driverInfo)
|
|
|
|
info.uptime = process.uptime()
|
|
info.lastUpdate = this.lastUpdate
|
|
info.status = this.status
|
|
info.error = this.error
|
|
info.cntStatus = this._cntStatus
|
|
info.inclusionState = this._inclusionState
|
|
info.appVersion = utils.getVersion()
|
|
info.zwaveVersion = libVersion
|
|
info.serverVersion = serverVersion
|
|
|
|
return info
|
|
}
|
|
|
|
/**
|
|
* Refresh all node values
|
|
*/
|
|
refreshValues(nodeId: number): Promise<void> {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
return zwaveNode.refreshValues()
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Ping a node
|
|
*/
|
|
pingNode(nodeId: number): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
return zwaveNode.ping()
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Refresh all node values of a specific CC
|
|
*/
|
|
refreshCCValues(nodeId: number, cc: CommandClasses): Promise<void> {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
return zwaveNode.refreshCCValues(cc)
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Set a poll interval
|
|
*/
|
|
setPollInterval(valueId: ZUIValueId, interval: number) {
|
|
if (this.driverReady) {
|
|
const vID = this._getValueID(valueId, true)
|
|
|
|
if (this.pollIntervals[vID]) {
|
|
clearTimeout(this.pollIntervals[vID])
|
|
}
|
|
|
|
logger.debug(`${vID} will be polled in ${interval} seconds`)
|
|
|
|
this.pollIntervals[vID] = setTimeout(
|
|
this._tryPoll.bind(this, valueId, interval),
|
|
interval * 1000,
|
|
)
|
|
} else {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks for configs updates
|
|
*
|
|
*/
|
|
async checkForConfigUpdates(): Promise<string | undefined> {
|
|
if (this.driverReady) {
|
|
this.driverInfo.newConfigVersion =
|
|
await this._driver.checkForConfigUpdates()
|
|
this.sendToSocket(socketEvents.info, this.getInfo())
|
|
return this.driverInfo.newConfigVersion
|
|
} else {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks for configs updates and installs them
|
|
*
|
|
*/
|
|
async installConfigUpdate(): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
const updated = await this._driver.installConfigUpdate()
|
|
if (updated) {
|
|
this.driverInfo.newConfigVersion = undefined
|
|
this.sendToSocket(socketEvents.info, this.getInfo())
|
|
}
|
|
return updated
|
|
} else {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If supported by the controller, this instructs it to shut down the Z-Wave API, so it can safely be removed from power. If this is successful (returns `true`), the driver instance will be destroyed and can no longer be used.
|
|
*
|
|
* > [!WARNING] The controller will have to be restarted manually (e.g. by unplugging and plugging it back in) before it can be used again!
|
|
*/
|
|
async shutdownZwaveAPI(): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
logger.info('Shutting down ZwaveJS driver...')
|
|
const success = await this._driver.shutdown()
|
|
return success
|
|
} else {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request an update of this value
|
|
*
|
|
*/
|
|
pollValue(valueId: ZUIValueId): Promise<unknown> {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(valueId.nodeId)
|
|
|
|
logger.debug(`Polling value ${this._getValueID(valueId)}`)
|
|
|
|
return zwaveNode.pollValue(valueId)
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Replace failed node
|
|
*/
|
|
async replaceFailedNode(
|
|
nodeId: number,
|
|
strategy: InclusionStrategy = InclusionStrategy.Security_S2,
|
|
options?: {
|
|
qrString?: string
|
|
provisioning?: PlannedProvisioningEntry
|
|
},
|
|
): Promise<boolean> {
|
|
try {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
if (backupManager.backupOnEvent) {
|
|
this.nvmEvent = 'before_replace_failed_node'
|
|
await backupManager.backupNvm()
|
|
}
|
|
|
|
if (this.commandsTimeout) {
|
|
clearTimeout(this.commandsTimeout)
|
|
this.commandsTimeout = null
|
|
}
|
|
|
|
this.commandsTimeout = setTimeout(
|
|
() => {
|
|
this.stopInclusion().catch(logger.error)
|
|
},
|
|
(this.cfg.commandsTimeout || 0) * 1000 || 30000,
|
|
)
|
|
|
|
this.isReplacing = true
|
|
// by default replaceFailedNode is secured, pass true to make it not secured
|
|
if (strategy === InclusionStrategy.Security_S2) {
|
|
let inclusionOptions: ReplaceNodeOptions
|
|
if (options?.qrString) {
|
|
const parsedQr = parseQRCodeString(options.qrString)
|
|
|
|
if (parsedQr) {
|
|
// when replacing a failed node you cannot use smart start so always use qrcode for provisioning
|
|
options.provisioning = parsedQr
|
|
} else {
|
|
throw Error(`Invalid QR code string`)
|
|
}
|
|
}
|
|
if (options?.provisioning) {
|
|
inclusionOptions = {
|
|
strategy,
|
|
provisioning: options.provisioning,
|
|
}
|
|
} else {
|
|
inclusionOptions = {
|
|
strategy,
|
|
}
|
|
}
|
|
return this._driver.controller.replaceFailedNode(
|
|
nodeId,
|
|
inclusionOptions,
|
|
)
|
|
} else if (
|
|
strategy === InclusionStrategy.Insecure ||
|
|
strategy === InclusionStrategy.Security_S0
|
|
) {
|
|
return this._driver.controller.replaceFailedNode(nodeId, {
|
|
strategy,
|
|
})
|
|
} else {
|
|
throw Error(
|
|
`Inclusion strategy not supported with replace failed node api`,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
this.isReplacing = false
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async getAvailableFirmwareUpdates(
|
|
nodeId: number,
|
|
options?: GetFirmwareUpdatesOptions,
|
|
) {
|
|
if (this.driverReady) {
|
|
const result =
|
|
await this._driver.controller.getAvailableFirmwareUpdates(
|
|
nodeId,
|
|
options,
|
|
)
|
|
|
|
// return [
|
|
// {
|
|
// version: '1.13',
|
|
// downgrade: true,
|
|
// channel: 'stable',
|
|
// normalizedVersion: '1.13',
|
|
// changelog: `* Fixed some bugs by [robertsLando](https://github.com/robertsLando)\n* Added other bugs\n* Very long changelog line that should not overflow the UI. Very long changelog line that should not overflow the UI Very long changelog line that should not overflow the UI`,
|
|
// files: [
|
|
// {
|
|
// target: 0,
|
|
// integrity:
|
|
// 'sha256:123456789012345678901234567890123456789012345678901234567890125',
|
|
// url: 'https://example.com/firmware0.bin',
|
|
// },
|
|
// {
|
|
// target: 1,
|
|
// integrity:
|
|
// 'sha256:123456789012345678901234567890123456789012345678901234567890123',
|
|
// url: 'https://example.com/firmware1.bin',
|
|
// },
|
|
// ],
|
|
// device: {
|
|
// manufacturerId: 123,
|
|
// productType: 456,
|
|
// productId: 789,
|
|
// firmwareVersion: '1.13',
|
|
// rfRegion: 1,
|
|
// },
|
|
// },
|
|
// {
|
|
// version: '2.00',
|
|
// downgrade: false,
|
|
// channel: 'beta',
|
|
// normalizedVersion: '1.13',
|
|
// changelog: `* Fixed some bugs by [robertsLando](https://github.com/robertsLando)\n* Added other bugs\n* Very long changelog line that should not overflow the UI. Very long changelog line that should not overflow the UI Very long changelog line that should not overflow the UI`,
|
|
// files: [
|
|
// {
|
|
// target: 0,
|
|
// integrity:
|
|
// 'sha256:123456789012345678901234567890123456789012345678901234567890125',
|
|
// url: 'https://example.com/firmware0.bin',
|
|
// },
|
|
// {
|
|
// target: 1,
|
|
// integrity:
|
|
// 'sha256:123456789012345678901234567890123456789012345678901234567890123',
|
|
// url: 'https://example.com/firmware1.bin',
|
|
// },
|
|
// ],
|
|
// device: {
|
|
// manufacturerId: 123,
|
|
// productType: 456,
|
|
// productId: 789,
|
|
// firmwareVersion: '1.13',
|
|
// rfRegion: 1,
|
|
// },
|
|
// },
|
|
// ] as FirmwareUpdateInfo[]
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
async firmwareUpdateOTA(nodeId: number, updateInfo: FirmwareUpdateInfo) {
|
|
if (this.driverReady) {
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
if (node.firmwareUpdate) {
|
|
throw Error(`Firmware update already in progress`)
|
|
}
|
|
|
|
const result = await this._driver.controller.firmwareUpdateOTA(
|
|
nodeId,
|
|
updateInfo,
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
async setPowerlevel(
|
|
powerlevel: number,
|
|
measured0dBm: number,
|
|
): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
const result = await this._driver.controller.setPowerlevel(
|
|
powerlevel,
|
|
measured0dBm,
|
|
)
|
|
|
|
await this.updateControllerNodeProps(null, ['powerlevel'])
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
async setRFRegion(region: RFRegion): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
const result = await this._driver.controller.setRFRegion(region)
|
|
await this.updateControllerNodeProps(null, ['RFRegion'])
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Start inclusion
|
|
*/
|
|
async startInclusion(
|
|
strategy: InclusionStrategy = InclusionStrategy.Default,
|
|
options?: {
|
|
forceSecurity?: boolean
|
|
provisioning?: PlannedProvisioningEntry
|
|
qrString?: string
|
|
name?: string
|
|
dsk?: string
|
|
location?: string
|
|
},
|
|
): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
if (backupManager.backupOnEvent) {
|
|
this.nvmEvent = 'before_start_inclusion'
|
|
await backupManager.backupNvm()
|
|
}
|
|
|
|
try {
|
|
if (this.commandsTimeout) {
|
|
clearTimeout(this.commandsTimeout)
|
|
this.commandsTimeout = null
|
|
}
|
|
|
|
if (options.name || options.location) {
|
|
this.tmpNode = {
|
|
name: options.name || '',
|
|
loc: options.location || '',
|
|
}
|
|
} else {
|
|
this.tmpNode = undefined
|
|
}
|
|
|
|
this.commandsTimeout = setTimeout(
|
|
() => {
|
|
this.stopInclusion().catch(logger.error)
|
|
},
|
|
(this.cfg.commandsTimeout || 0) * 1000 || 30000,
|
|
)
|
|
|
|
let inclusionOptions: InclusionOptions
|
|
|
|
switch (strategy) {
|
|
case InclusionStrategy.Insecure:
|
|
case InclusionStrategy.Security_S0:
|
|
inclusionOptions = { strategy }
|
|
break
|
|
case InclusionStrategy.SmartStart:
|
|
throw Error(
|
|
'In order to use Smart Start add you node to provisioning list',
|
|
)
|
|
case InclusionStrategy.Default:
|
|
inclusionOptions = {
|
|
strategy,
|
|
forceSecurity: options?.forceSecurity,
|
|
}
|
|
break
|
|
case InclusionStrategy.Security_S2:
|
|
if (options?.qrString) {
|
|
const parsedQr = parseQRCodeString(options.qrString)
|
|
if (!parsedQr) {
|
|
throw Error(`Invalid QR code string`)
|
|
}
|
|
|
|
if (parsedQr.version === QRCodeVersion.S2) {
|
|
options.provisioning = parsedQr
|
|
} else if (
|
|
parsedQr.version === QRCodeVersion.SmartStart
|
|
) {
|
|
this.provisionSmartStartNode(parsedQr)
|
|
return true
|
|
} else {
|
|
throw Error(`Invalid QR code version`)
|
|
}
|
|
}
|
|
if (options?.provisioning) {
|
|
inclusionOptions = {
|
|
strategy,
|
|
dsk: options.dsk,
|
|
provisioning: options.provisioning,
|
|
}
|
|
} else {
|
|
inclusionOptions = { strategy, dsk: options.dsk }
|
|
}
|
|
|
|
break
|
|
default:
|
|
inclusionOptions = { strategy }
|
|
}
|
|
|
|
this.isReplacing = false
|
|
|
|
return this._driver.controller.beginInclusion(inclusionOptions)
|
|
} catch (error) {
|
|
this.tmpNode = undefined
|
|
throw error
|
|
}
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Start exclusion
|
|
*/
|
|
async startExclusion(
|
|
options: ExclusionOptions = {
|
|
strategy: ExclusionStrategy.DisableProvisioningEntry,
|
|
},
|
|
): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
if (backupManager.backupOnEvent) {
|
|
this.nvmEvent = 'before_start_exclusion'
|
|
await backupManager.backupNvm()
|
|
}
|
|
|
|
if (this.commandsTimeout) {
|
|
clearTimeout(this.commandsTimeout)
|
|
this.commandsTimeout = null
|
|
}
|
|
|
|
this.commandsTimeout = setTimeout(
|
|
() => {
|
|
this.stopExclusion().catch(logger.error)
|
|
},
|
|
(this.cfg.commandsTimeout || 0) * 1000 || 30000,
|
|
)
|
|
|
|
return this._driver.controller.beginExclusion(options)
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Stop exclusion
|
|
*/
|
|
stopExclusion(): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
if (this.commandsTimeout) {
|
|
clearTimeout(this.commandsTimeout)
|
|
this.commandsTimeout = null
|
|
}
|
|
return this._driver.controller.stopExclusion()
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Stops inclusion
|
|
*/
|
|
stopInclusion(): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
if (this.commandsTimeout) {
|
|
clearTimeout(this.commandsTimeout)
|
|
this.commandsTimeout = null
|
|
}
|
|
return this._driver.controller.stopInclusion()
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Rebuild node routes
|
|
*/
|
|
async rebuildNodeRoutes(nodeId: number): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
let status: RebuildRoutesStatus = 'pending'
|
|
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (!node) {
|
|
throw Error(`Node ${nodeId} not found`)
|
|
}
|
|
|
|
node.rebuildRoutesProgress = status
|
|
this.sendToSocket(socketEvents.rebuildRoutesProgress, [
|
|
[nodeId, status],
|
|
])
|
|
const result =
|
|
await this._driver.controller.rebuildNodeRoutes(nodeId)
|
|
status = result ? 'done' : 'failed'
|
|
|
|
node.rebuildRoutesProgress = status
|
|
this.sendToSocket(socketEvents.rebuildRoutesProgress, [
|
|
[nodeId, status],
|
|
])
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Get priority return route from nodeId to destinationId
|
|
*/
|
|
getPriorityReturnRoute(nodeId: number, destinationId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const controllerId = this._driver.controller.ownNodeId
|
|
|
|
if (!destinationId) {
|
|
destinationId = controllerId
|
|
}
|
|
|
|
const result = this._driver.controller.getPriorityReturnRouteCached(
|
|
nodeId,
|
|
destinationId,
|
|
)
|
|
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
if (result) {
|
|
node.priorityReturnRoute[destinationId] = result
|
|
} else {
|
|
delete node.priorityReturnRoute[destinationId]
|
|
}
|
|
this.emitStatistics(node, {
|
|
priorityReturnRoute: node.priorityReturnRoute,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Assigns a priority return route from nodeId to destinationId
|
|
*/
|
|
async assignPriorityReturnRoute(
|
|
nodeId: number,
|
|
destinationNodeId: number,
|
|
repeaters: number[],
|
|
routeSpeed: ZWaveDataRate,
|
|
) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result = await this._driver.controller.assignPriorityReturnRoute(
|
|
nodeId,
|
|
destinationNodeId,
|
|
repeaters,
|
|
routeSpeed,
|
|
)
|
|
|
|
if (result) {
|
|
this.getPriorityReturnRoute(nodeId, destinationNodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Get priority return route from node to controller
|
|
*/
|
|
getPrioritySUCReturnRoute(nodeId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result =
|
|
this._driver.controller.getPrioritySUCReturnRouteCached(nodeId) ??
|
|
null
|
|
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
node.prioritySUCReturnRoute = result
|
|
this.emitStatistics(node, {
|
|
prioritySUCReturnRoute: result,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Assign a priority return route from node to controller
|
|
*/
|
|
async assignPrioritySUCReturnRoute(
|
|
nodeId: number,
|
|
repeaters: number[],
|
|
routeSpeed: ZWaveDataRate,
|
|
) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result =
|
|
await this._driver.controller.assignPrioritySUCReturnRoute(
|
|
nodeId,
|
|
repeaters,
|
|
routeSpeed,
|
|
)
|
|
|
|
if (result) {
|
|
// when changing the SUC priority return routes custom SUC return routes are removed
|
|
this.getCustomSUCReturnRoute(nodeId)
|
|
this.getPrioritySUCReturnRoute(nodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Get custom return routes from nodeId to destinationId
|
|
*/
|
|
getCustomReturnRoute(nodeId: number, destinationId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result = this._driver.controller.getCustomReturnRoutesCached(
|
|
nodeId,
|
|
destinationId,
|
|
)
|
|
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
if (result) {
|
|
node.customReturnRoute[destinationId] = result
|
|
} else {
|
|
delete node.customReturnRoute[destinationId]
|
|
}
|
|
this.emitStatistics(node, {
|
|
customReturnRoute: node.customReturnRoute,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Assigns custom return routes from a node to a destination node
|
|
*/
|
|
async assignCustomReturnRoutes(
|
|
nodeId: number,
|
|
destinationNodeId: number,
|
|
routes: Route[],
|
|
priorityRoute?: Route,
|
|
) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result = await this._driver.controller.assignCustomReturnRoutes(
|
|
nodeId,
|
|
destinationNodeId,
|
|
routes,
|
|
priorityRoute,
|
|
)
|
|
|
|
if (result) {
|
|
this.getCustomReturnRoute(nodeId, destinationNodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Get custom return routes from node to controller
|
|
*/
|
|
getCustomSUCReturnRoute(nodeId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result =
|
|
this._driver.controller.getCustomSUCReturnRoutesCached(nodeId) ?? []
|
|
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
node.customSUCReturnRoutes = result
|
|
this.emitStatistics(node, {
|
|
customSUCReturnRoutes: result,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Assigns up to 4 return routes to a node to the controller
|
|
*/
|
|
async assignCustomSUCReturnRoutes(
|
|
nodeId: number,
|
|
routes: Route[],
|
|
priorityRoute?: Route,
|
|
) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result =
|
|
await this._driver.controller.assignCustomSUCReturnRoutes(
|
|
nodeId,
|
|
routes,
|
|
priorityRoute,
|
|
)
|
|
|
|
if (result) {
|
|
// when changing the SUC return routes the priority SUC return route is removed
|
|
this.getCustomSUCReturnRoute(nodeId)
|
|
this.getPrioritySUCReturnRoute(nodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Returns the priority route for a given node ID
|
|
*/
|
|
async getPriorityRoute(nodeId: number) {
|
|
if (this.driverReady) {
|
|
const result =
|
|
await this._driver.controller.getPriorityRoute(nodeId)
|
|
|
|
if (result) {
|
|
const node = this.nodes.get(nodeId)
|
|
if (node) {
|
|
const statistics: Partial<NodeStatistics> =
|
|
node.statistics || {}
|
|
|
|
switch (result.routeKind) {
|
|
case RouteKind.Application:
|
|
node.applicationRoute = {
|
|
repeaters: result.repeaters,
|
|
routeSpeed: result.routeSpeed,
|
|
}
|
|
break
|
|
case RouteKind.NLWR:
|
|
statistics.nlwr = {
|
|
...(statistics.nlwr || {}),
|
|
repeaters: result.repeaters,
|
|
protocolDataRate: result.routeSpeed as any,
|
|
}
|
|
delete node.applicationRoute
|
|
break
|
|
case RouteKind.LWR:
|
|
statistics.lwr = {
|
|
...(statistics.lwr || {}),
|
|
repeaters: result.repeaters,
|
|
protocolDataRate: result.routeSpeed as any,
|
|
}
|
|
delete node.applicationRoute
|
|
break
|
|
}
|
|
|
|
node.statistics = statistics as NodeStatistics
|
|
|
|
this.emitStatistics(node, {
|
|
statistics: node.statistics,
|
|
lastActive: node.lastActive,
|
|
applicationRoute: node.applicationRoute || null,
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Delete ALL previously assigned return routes
|
|
*/
|
|
async deleteReturnRoutes(nodeId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result = await this._driver.controller.deleteReturnRoutes(nodeId)
|
|
|
|
if (result) {
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
node.priorityReturnRoute = null
|
|
node.customReturnRoute = null
|
|
this.emitStatistics(node, {
|
|
priorityReturnRoute: null,
|
|
customReturnRoute: null,
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Delete ALL previously assigned return routes to the controller
|
|
*/
|
|
async deleteSUCReturnRoutes(nodeId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result =
|
|
await this._driver.controller.deleteSUCReturnRoutes(nodeId)
|
|
|
|
if (result) {
|
|
const node = this.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
node.prioritySUCReturnRoute = null
|
|
node.customSUCReturnRoutes = []
|
|
this.emitStatistics(node, {
|
|
prioritySUCReturnRoute: null,
|
|
customSUCReturnRoutes: [],
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Ask the controller to automatically assign to node nodeId a set of routes to node destinationNodeId.
|
|
*/
|
|
async assignReturnRoutes(nodeId: number, destinationNodeId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result = await this._driver.controller.assignReturnRoutes(
|
|
nodeId,
|
|
destinationNodeId,
|
|
)
|
|
|
|
if (result) {
|
|
this.getCustomReturnRoute(nodeId, destinationNodeId)
|
|
this.getPriorityReturnRoute(nodeId, destinationNodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Ask the controller to automatically assign to node nodeId a set of routes to controller.
|
|
*/
|
|
async assignSUCReturnRoutes(nodeId: number) {
|
|
if (!this.driverReady) throw new DriverNotReadyError()
|
|
|
|
const result =
|
|
await this._driver.controller.assignSUCReturnRoutes(nodeId)
|
|
|
|
if (result) {
|
|
this.getCustomSUCReturnRoute(nodeId)
|
|
this.getPrioritySUCReturnRoute(nodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Sets the priority route for a given node ID
|
|
*/
|
|
async setPriorityRoute(
|
|
nodeId: number,
|
|
repeaters: number[],
|
|
routeSpeed: ZWaveDataRate,
|
|
): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
const result = await this._driver.controller.setPriorityRoute(
|
|
nodeId,
|
|
repeaters,
|
|
routeSpeed,
|
|
)
|
|
|
|
if (result) {
|
|
await this.getPriorityRoute(nodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Remove priority route for a given node ID.
|
|
*/
|
|
async removePriorityRoute(nodeId: number) {
|
|
if (this.driverReady) {
|
|
const result =
|
|
await this._driver.controller.removePriorityRoute(nodeId)
|
|
|
|
if (result) {
|
|
await this.getPriorityRoute(nodeId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Check node lifeline health
|
|
*/
|
|
async checkLifelineHealth(
|
|
nodeId: number,
|
|
rounds = 5,
|
|
): Promise<LifelineHealthCheckSummary & { targetNodeId: number }> {
|
|
if (this.driverReady) {
|
|
const result = await this.getNode(nodeId).checkLifelineHealth(
|
|
rounds,
|
|
this._onHealthCheckProgress.bind(this, {
|
|
nodeId,
|
|
targetNodeId: this.driver.controller.ownNodeId,
|
|
}),
|
|
)
|
|
return { ...result, targetNodeId: this.driver.controller.ownNodeId }
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
async checkLinkReliability(
|
|
nodeId: number,
|
|
options: any,
|
|
): Promise<LinkReliabilityCheckResult> {
|
|
if (this.driverReady) {
|
|
const result = await this.getNode(nodeId).checkLinkReliability({
|
|
...options,
|
|
onProgress: (progress) =>
|
|
this._onLinkReliabilityCheckProgress({ nodeId }, progress),
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
abortLinkReliabilityCheck(nodeId: number): void {
|
|
if (this.driverReady) {
|
|
this.getNode(nodeId).abortLinkReliabilityCheck()
|
|
return
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Check node routes health
|
|
*/
|
|
async checkRouteHealth(
|
|
nodeId: number,
|
|
targetNodeId: number,
|
|
rounds = 5,
|
|
): Promise<RouteHealthCheckSummary & { targetNodeId: number }> {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
const result = await zwaveNode.checkRouteHealth(
|
|
targetNodeId,
|
|
rounds,
|
|
this._onHealthCheckProgress.bind(this, {
|
|
nodeId,
|
|
targetNodeId,
|
|
}),
|
|
)
|
|
|
|
return { ...result, targetNodeId }
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Aborts an ongoing health check if one is currently in progress.
|
|
*/
|
|
abortHealthCheck(nodeId: number) {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw Error(`Node ${nodeId} not found`)
|
|
}
|
|
|
|
if (!zwaveNode.isHealthCheckInProgress()) {
|
|
throw Error(`Health check not in progress`)
|
|
}
|
|
|
|
return zwaveNode.abortHealthCheck()
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Check if a node is failed
|
|
*/
|
|
async isFailedNode(nodeId: number): Promise<boolean> {
|
|
if (this.driverReady) {
|
|
const node = this._nodes.get(nodeId)
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
// checks if a node was marked as failed in the controller
|
|
const result = await this._driver.controller.isFailedNode(nodeId)
|
|
if (node) {
|
|
node.failed = result
|
|
}
|
|
|
|
if (zwaveNode) {
|
|
this._onNodeStatus(zwaveNode, true)
|
|
}
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Remove a failed node
|
|
*/
|
|
async removeFailedNode(nodeId: number): Promise<void> {
|
|
if (this.driverReady) {
|
|
if (backupManager.backupOnEvent) {
|
|
this.nvmEvent = 'before_remove_failed_node'
|
|
await backupManager.backupNvm()
|
|
}
|
|
|
|
return this._driver.controller.removeFailedNode(nodeId)
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Re interview the node
|
|
*/
|
|
refreshInfo(nodeId: number, options?: RefreshInfoOptions): Promise<void> {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw Error(`Node ${nodeId} not found`)
|
|
}
|
|
|
|
return zwaveNode.refreshInfo(options)
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Used to trigger an update of controller FW
|
|
*/
|
|
async firmwareUpdateOTW(
|
|
file: FwFile,
|
|
): Promise<ControllerFirmwareUpdateResult> {
|
|
try {
|
|
if (backupManager.backupOnEvent) {
|
|
this.nvmEvent = 'before_controller_fw_update_otw'
|
|
await backupManager.backupNvm()
|
|
}
|
|
let firmware: Firmware
|
|
|
|
try {
|
|
const format = guessFirmwareFileFormat(file.name, file.data)
|
|
firmware = extractFirmware(file.data, format)
|
|
} catch (err) {
|
|
throw Error(
|
|
`Unable to extract firmware from file '${file.name}'`,
|
|
)
|
|
}
|
|
const result = await this.driver.controller.firmwareUpdateOTW(
|
|
firmware.data,
|
|
)
|
|
return result
|
|
} catch (e) {
|
|
throw Error(`Error while updating firmware: ${e.message}`)
|
|
}
|
|
}
|
|
|
|
updateFirmware(
|
|
nodeId: number,
|
|
files: FwFile[],
|
|
): Promise<FirmwareUpdateResult> {
|
|
// const result: FirmwareUpdateResult = {
|
|
// status: FirmwareUpdateStatus.Error_Checksum,
|
|
// waitTime: 10,
|
|
// success: false,
|
|
// reInterview: true,
|
|
// }
|
|
|
|
// return Promise.resolve(result)
|
|
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw Error(`Node ${nodeId} not found`)
|
|
}
|
|
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
if (node.firmwareUpdate) {
|
|
throw Error(`Firmware update already in progress`)
|
|
}
|
|
|
|
const firmwares: Firmware[] = []
|
|
|
|
for (const f of files) {
|
|
let { data, name } = f
|
|
if (data instanceof Buffer) {
|
|
try {
|
|
let format: FirmwareFileFormat
|
|
if (name.endsWith('.zip')) {
|
|
const extracted = tryUnzipFirmwareFile(data)
|
|
if (!extracted) {
|
|
throw Error(
|
|
`Unable to extract firmware from zip file '${name}'`,
|
|
)
|
|
}
|
|
|
|
format = extracted.format
|
|
name = extracted.filename
|
|
data = extracted.rawData
|
|
} else {
|
|
format = guessFirmwareFileFormat(name, data)
|
|
}
|
|
|
|
const firmware = extractFirmware(data, format)
|
|
if (f.target !== undefined) {
|
|
firmware.firmwareTarget = f.target
|
|
}
|
|
firmwares.push(firmware)
|
|
} catch (e) {
|
|
throw Error(
|
|
`Unable to extract firmware from file '${name}': ${e.message}`,
|
|
)
|
|
}
|
|
} else {
|
|
throw Error(`Invalid firmware file ${name} is not a Buffer`)
|
|
}
|
|
}
|
|
|
|
return zwaveNode.updateFirmware(firmwares)
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
async abortFirmwareUpdate(nodeId: number) {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw Error(`Node ${nodeId} not found`)
|
|
}
|
|
|
|
await zwaveNode.abortFirmwareUpdate()
|
|
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
// reset fw update progress
|
|
if (node) {
|
|
node.firmwareUpdate = undefined
|
|
|
|
this.emitNodeUpdate(node, {
|
|
firmwareUpdate: false,
|
|
} as any)
|
|
}
|
|
} else {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
}
|
|
|
|
dumpNode(nodeId: number) {
|
|
if (this.driverReady) {
|
|
const zwaveNode = this.getNode(nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw Error(`Node ${nodeId} not found`)
|
|
}
|
|
|
|
return zwaveNode.createDump()
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
beginRebuildingRoutes(options?: RebuildRoutesOptions): boolean {
|
|
if (this.driverReady) {
|
|
return this._driver.controller.beginRebuildingRoutes(options)
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
stopRebuildingRoutes(): boolean {
|
|
if (this.driverReady) {
|
|
const result = this._driver.controller.stopRebuildingRoutes()
|
|
if (result) {
|
|
const toReturn: [number, RebuildRoutesStatus][] = []
|
|
for (const [nodeId, node] of this.nodes) {
|
|
if (node.rebuildRoutesProgress === 'pending') {
|
|
node.rebuildRoutesProgress = 'skipped'
|
|
}
|
|
toReturn.push([nodeId, node.rebuildRoutesProgress])
|
|
}
|
|
this.sendToSocket(socketEvents.rebuildRoutesProgress, toReturn)
|
|
}
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
async hardReset() {
|
|
if (this.driverReady) {
|
|
await this._driver.hardReset()
|
|
this.init()
|
|
} else {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
}
|
|
|
|
softReset() {
|
|
if (this.driverReady) {
|
|
return this._driver.softReset()
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Send a custom CC command. Check available commands by selecting a CC [here](https://zwave-js.github.io/node-zwave-js/#/api/CCs/index)
|
|
*/
|
|
async sendCommand(
|
|
ctx: {
|
|
nodeId: number
|
|
endpoint: number
|
|
commandClass: CommandClasses | keyof typeof CommandClasses
|
|
},
|
|
command: string,
|
|
args: any[],
|
|
): Promise<any> {
|
|
if (this.driverReady) {
|
|
if (typeof ctx.nodeId !== 'number') {
|
|
throw Error('nodeId must be a number')
|
|
}
|
|
|
|
if (args !== undefined && !Array.isArray(args)) {
|
|
throw Error('if args is given, it must be an array')
|
|
}
|
|
|
|
// get node instance
|
|
const node = this.getNode(ctx.nodeId)
|
|
if (!node) {
|
|
throw Error(`Node ${ctx.nodeId} was not found!`)
|
|
}
|
|
|
|
// get the endpoint instance
|
|
const endpoint = node.getEndpoint(ctx.endpoint || 0)
|
|
if (!endpoint) {
|
|
throw Error(
|
|
`Endpoint ${ctx.endpoint} does not exist on Node ${ctx.nodeId}!`,
|
|
)
|
|
}
|
|
|
|
const commandClass =
|
|
typeof ctx.commandClass === 'number'
|
|
? ctx.commandClass
|
|
: CommandClasses[ctx.commandClass]
|
|
|
|
// get the command class instance to send the command
|
|
const api = endpoint.commandClasses[commandClass]
|
|
if (!api || !api.isSupported()) {
|
|
throw Error(
|
|
`Node ${ctx.nodeId}${
|
|
ctx.endpoint ? ` Endpoint ${ctx.endpoint}` : ''
|
|
} does not support CC ${
|
|
ctx.commandClass
|
|
} or it has not been implemented yet`,
|
|
)
|
|
} else if (!(command in api)) {
|
|
throw Error(
|
|
`The command ${command} does not exist for CC ${ctx.commandClass}`,
|
|
)
|
|
}
|
|
|
|
// send the command with args
|
|
const method = api[command].bind(api)
|
|
const result = args ? await method(...args) : await method()
|
|
|
|
return result
|
|
}
|
|
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
/**
|
|
* Calls a specific `client` or `ZwaveClient` method based on `apiName`
|
|
* ZwaveClients methods used are the ones that overrides default Z-Wave methods
|
|
* like nodes name and location and scenes management.
|
|
*/
|
|
async callApi<T extends AllowedApis>(
|
|
apiName: T,
|
|
...args: Parameters<ZwaveClient[T]>
|
|
) {
|
|
let err: string, result: ReturnType<ZwaveClient[T]>
|
|
|
|
logger.log('info', 'Calling api %s with args: %o', apiName, args)
|
|
|
|
if (this.driverReady || this.driver?.isInBootloader()) {
|
|
try {
|
|
const allowed =
|
|
typeof this[apiName] === 'function' &&
|
|
allowedApis.indexOf(apiName) >= 0
|
|
|
|
if (allowed) {
|
|
result = await (this as any)[apiName](...args)
|
|
// custom scenes and node/location management
|
|
} else {
|
|
err = 'Unknown API'
|
|
}
|
|
} catch (e) {
|
|
err = e.message
|
|
}
|
|
} else {
|
|
err = 'Z-Wave client not connected'
|
|
}
|
|
|
|
let toReturn: CallAPIResult<T>
|
|
|
|
if (err) {
|
|
toReturn = {
|
|
success: false,
|
|
message: err,
|
|
}
|
|
} else {
|
|
toReturn = {
|
|
success: true,
|
|
message: 'Success zwave api call',
|
|
result,
|
|
}
|
|
}
|
|
logger.log('info', `${toReturn.message} ${apiName} %o`, result)
|
|
|
|
toReturn.args = args
|
|
|
|
return toReturn
|
|
}
|
|
|
|
/**
|
|
* Send broadcast write request
|
|
*/
|
|
async writeBroadcast(
|
|
valueId: ValueID,
|
|
value: unknown,
|
|
options?: SetValueAPIOptions,
|
|
) {
|
|
if (this.driverReady) {
|
|
try {
|
|
const broadcastNode = this._driver.controller.getBroadcastNode()
|
|
|
|
await broadcastNode.setValue(valueId, value, options)
|
|
} catch (error) {
|
|
logger.error(
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
`Error while sending broadcast ${value} to CC ${
|
|
valueId.commandClass
|
|
} ${valueId.property} ${valueId.propertyKey || ''}: ${
|
|
error.message
|
|
}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send multicast write request to a group of nodes
|
|
*/
|
|
async writeMulticast(
|
|
nodes: number[],
|
|
valueId: ZUIValueId,
|
|
value: unknown,
|
|
options?: SetValueAPIOptions,
|
|
) {
|
|
if (this.driverReady) {
|
|
let fallback = false
|
|
try {
|
|
const multicastGroup =
|
|
this._driver.controller.getMulticastGroup(nodes)
|
|
await multicastGroup.setValue(valueId, value, options)
|
|
} catch (error) {
|
|
fallback = error.code === ZWaveErrorCodes.CC_NotSupported
|
|
logger.error(
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
`Error while sending multicast ${value} to CC ${
|
|
valueId.commandClass
|
|
} ${valueId.property} ${valueId.propertyKey || ''}: ${
|
|
error.message
|
|
}`,
|
|
)
|
|
}
|
|
// try single writes requests
|
|
if (fallback) {
|
|
for (const n of nodes) {
|
|
await this.writeValue({ ...valueId, nodeId: n }, value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a value of a specific zwave valueId
|
|
*/
|
|
async writeValue(
|
|
valueId: ZUIValueId,
|
|
value: any,
|
|
options?: SetValueAPIOptions,
|
|
) {
|
|
let result: SetValueResult = {
|
|
status: SetValueStatus.Fail,
|
|
}
|
|
if (this.driverReady) {
|
|
const vID = this._getValueID(valueId)
|
|
logger.log('info', `Writing %o to ${valueId.nodeId}-${vID}`, value)
|
|
|
|
try {
|
|
const zwaveNode = this.getNode(valueId.nodeId)
|
|
|
|
if (!zwaveNode) {
|
|
throw Error(`Node ${valueId.nodeId} not found`)
|
|
}
|
|
|
|
const isDuration = typeof value === 'object'
|
|
|
|
// handle multilevel switch 'start' and 'stop' commands
|
|
if (
|
|
!isDuration &&
|
|
valueId.commandClass ===
|
|
CommandClasses['Multilevel Switch'] &&
|
|
isNaN(value)
|
|
) {
|
|
if (/stop/i.test(value)) {
|
|
await zwaveNode.commandClasses[
|
|
'Multilevel Switch'
|
|
].stopLevelChange()
|
|
} else if (/start/i.test(value)) {
|
|
await zwaveNode.commandClasses[
|
|
'Multilevel Switch'
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
].startLevelChange()
|
|
} else {
|
|
throw Error('Command not valid for Multilevel Switch')
|
|
}
|
|
result = {
|
|
status: SetValueStatus.SuccessUnsupervised,
|
|
}
|
|
} else {
|
|
// coerce string to numbers when value type is number and received a string
|
|
if (
|
|
valueId.type === 'number' &&
|
|
typeof value === 'string'
|
|
) {
|
|
value = Number(value)
|
|
} else if (
|
|
valueId.property === 'hexColor' &&
|
|
typeof value === 'string' &&
|
|
value.startsWith('#')
|
|
) {
|
|
// remove the leading `#` if present
|
|
value = value.substr(1)
|
|
}
|
|
|
|
if (
|
|
typeof value === 'string' &&
|
|
utils.isBufferAsHex(value)
|
|
) {
|
|
value = utils.bufferFromHex(value)
|
|
}
|
|
|
|
const node = this.nodes.get(valueId.nodeId)
|
|
|
|
const targetValueId = node?.values[vID]
|
|
|
|
if (targetValueId) {
|
|
targetValueId.toUpdate = true
|
|
}
|
|
|
|
result = await zwaveNode.setValue(valueId, value, options)
|
|
|
|
if (setValueWasUnsupervisedOrSucceeded(result)) {
|
|
this.emit('valueWritten', valueId, node, value)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.log(
|
|
'error',
|
|
`Error while writing %o on ${vID}: ${error.message}`,
|
|
value,
|
|
)
|
|
}
|
|
// https://zwave-js.github.io/node-zwave-js/#/api/node?id=setvalue
|
|
if (setValueFailed(result)) {
|
|
logger.log(
|
|
'error',
|
|
`Unable to write %o on ${vID}: %s`,
|
|
value,
|
|
result.message ||
|
|
getEnumMemberName(SetValueStatus, result.status),
|
|
)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ---------- DRIVER EVENTS -------------------------------------
|
|
|
|
private async _onDriverReady() {
|
|
/*
|
|
Now the controller interview is complete. This means we know which nodes
|
|
are included in the network, but they might not be ready yet.
|
|
The node interview will continue in the background.
|
|
|
|
NOTE: This can be called also after an Hard Reset
|
|
*/
|
|
|
|
// driver ready
|
|
this.status = ZwaveClientStatus.DRIVER_READY
|
|
|
|
this.driverReady = true
|
|
|
|
this._inclusionState = this.driver.controller.inclusionState
|
|
|
|
logger.info('Z-Wave driver is ready')
|
|
|
|
this._updateControllerStatus('Driver ready')
|
|
|
|
try {
|
|
// this must be done only after driver is ready
|
|
this._scheduledConfigCheck().catch(() => {
|
|
/* ignore */
|
|
})
|
|
|
|
if (!this._controllerListenersAdded) {
|
|
this._controllerListenersAdded = true
|
|
this.driver.controller
|
|
.on(
|
|
'inclusion started',
|
|
this._onInclusionStarted.bind(this),
|
|
)
|
|
.on(
|
|
'exclusion started',
|
|
this._onExclusionStarted.bind(this),
|
|
)
|
|
.on(
|
|
'inclusion stopped',
|
|
this._onInclusionStopped.bind(this),
|
|
)
|
|
.on(
|
|
'exclusion stopped',
|
|
this._onExclusionStopped.bind(this),
|
|
)
|
|
.on(
|
|
'inclusion state changed',
|
|
this._onInclusionStateChanged.bind(this),
|
|
)
|
|
.on('inclusion failed', this._onInclusionFailed.bind(this))
|
|
.on('exclusion failed', this._onExclusionFailed.bind(this))
|
|
.on('node found', this._onNodeFound.bind(this))
|
|
.on('node added', this._onNodeAdded.bind(this))
|
|
.on('node removed', this._onNodeRemoved.bind(this))
|
|
.on(
|
|
'rebuild routes progress',
|
|
this._onRebuildRoutesProgress.bind(this),
|
|
)
|
|
.on(
|
|
'rebuild routes done',
|
|
this._onRebuildRoutesDone.bind(this),
|
|
)
|
|
.on(
|
|
'statistics updated',
|
|
this._onControllerStatisticsUpdated.bind(this),
|
|
)
|
|
.on(
|
|
'firmware update progress',
|
|
this._onControllerFirmwareUpdateProgress.bind(this),
|
|
)
|
|
.on(
|
|
'firmware update finished',
|
|
this._onControllerFirmwareUpdateFinished.bind(this),
|
|
)
|
|
.on(
|
|
'status changed',
|
|
this._onControllerStatusChanged.bind(this),
|
|
)
|
|
}
|
|
} catch (error) {
|
|
// Fixes freak error in "driver ready" handler #1309
|
|
logger.error(error.message)
|
|
this.backoffRestart()
|
|
return
|
|
}
|
|
|
|
// reset retries
|
|
this.backoffRetry = 0
|
|
|
|
for (const [, node] of this._driver.controller.nodes) {
|
|
// node added will not be triggered if the node is in cache
|
|
this._createNode(node.id)
|
|
this._addNode(node)
|
|
|
|
// Make sure we didn't miss the ready event
|
|
if (node.ready) {
|
|
this._onNodeReady(node)
|
|
}
|
|
}
|
|
|
|
this.driverInfo.homeid = this._driver.controller.homeId
|
|
const homeHex = '0x' + this.driverInfo?.homeid?.toString(16)
|
|
this.driverInfo.name = homeHex
|
|
this.driverInfo.controllerId = this._driver.controller.ownNodeId
|
|
|
|
this.emit('event', EventSource.DRIVER, 'driver ready', this.driverInfo)
|
|
|
|
this._error = undefined
|
|
|
|
// start server only when driver is ready. Fixes #602
|
|
if (this.cfg.serverEnabled && this.server) {
|
|
// fix prevent to start server when already inited
|
|
if (!this.server['server']) {
|
|
this.server
|
|
.start(!this.hasUserCallbacks)
|
|
.then(() => {
|
|
logger.info('Z-Wave server started')
|
|
})
|
|
.catch((error) => {
|
|
logger.error(
|
|
`Failed to start zwave-js server: ${error.message}`,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
logger.info(`Scanning network with homeid: ${homeHex}`)
|
|
|
|
await this.sendInitToSockets()
|
|
|
|
this.loadFakeNodes().catch((e) => {
|
|
logger.error(`Error while loading fake nodes: ${e.message}`)
|
|
})
|
|
}
|
|
|
|
private _onDriverError(error: ZWaveError, skipRestart = false): void {
|
|
this._error = 'Driver: ' + error.message
|
|
this.status = ZwaveClientStatus.DRIVER_FAILED
|
|
this._updateControllerStatus(this._error)
|
|
this.emit('event', EventSource.DRIVER, 'driver error', error)
|
|
|
|
if (!skipRestart && error.code === ZWaveErrorCodes.Driver_Failed) {
|
|
// this cannot be recovered by zwave-js, requires a manual restart
|
|
this.driverReady = false
|
|
this.backoffRestart()
|
|
}
|
|
}
|
|
|
|
private _onControllerFirmwareUpdateProgress(
|
|
progress: ControllerFirmwareUpdateProgress,
|
|
) {
|
|
const nodeId = this.driver.controller.ownNodeId
|
|
const node = this.nodes.get(nodeId)
|
|
if (node) {
|
|
node.firmwareUpdate = {
|
|
sentFragments: progress.sentFragments,
|
|
totalFragments: progress.totalFragments,
|
|
progress: progress.progress,
|
|
currentFile: 1,
|
|
totalFiles: 1,
|
|
}
|
|
|
|
// send at most 4msg per second
|
|
this.throttle(
|
|
this._onControllerFirmwareUpdateProgress.name,
|
|
this.emitNodeUpdate.bind(this, node, {
|
|
firmwareUpdate: node.firmwareUpdate,
|
|
} as utils.DeepPartial<ZUINode>),
|
|
250,
|
|
)
|
|
}
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.CONTROLLER,
|
|
'controller firmware update progress',
|
|
this.zwaveNodeToJSON(this.driver.controller.nodes.get(nodeId)),
|
|
progress,
|
|
)
|
|
}
|
|
|
|
private _onControllerFirmwareUpdateFinished(
|
|
result: ControllerFirmwareUpdateResult,
|
|
) {
|
|
const nodeId = this.driver.controller.ownNodeId
|
|
const node = this.nodes.get(nodeId)
|
|
const zwaveNode = this.driver.controller.nodes.get(nodeId)
|
|
|
|
if (node) {
|
|
node.firmwareUpdate = undefined
|
|
|
|
this.emitNodeUpdate(node, {
|
|
firmwareUpdate: false,
|
|
firmwareUpdateResult: {
|
|
success: result.success,
|
|
status: getEnumMemberName(
|
|
ControllerFirmwareUpdateStatus,
|
|
result.status,
|
|
),
|
|
},
|
|
} as any)
|
|
}
|
|
|
|
logger.info(
|
|
`Controller ${zwaveNode.id} firmware update OTW finished ${
|
|
result.success ? 'successfully' : 'with error'
|
|
}.\n Status: ${getEnumMemberName(
|
|
ControllerFirmwareUpdateStatus,
|
|
result.status,
|
|
)}. Result: ${JSON.stringify(result)}.`,
|
|
)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.CONTROLLER,
|
|
'controller firmware update finished',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
result,
|
|
)
|
|
}
|
|
|
|
private _onControllerStatisticsUpdated(stats: ControllerStatistics) {
|
|
let controllerNode: ZUINode
|
|
try {
|
|
controllerNode = this.nodes.get(this.driver.controller.ownNodeId)
|
|
} catch (e) {
|
|
// This should not happen, but it does. Don't crash!
|
|
return
|
|
}
|
|
|
|
if (controllerNode) {
|
|
const oldStatistics =
|
|
controllerNode.statistics as ControllerStatistics
|
|
controllerNode.statistics = stats
|
|
|
|
if (stats.messagesRX > (oldStatistics?.messagesRX ?? 0)) {
|
|
// no need to emit `lastActive` event. That would cause useless traffic
|
|
controllerNode.lastActive = Date.now()
|
|
}
|
|
|
|
const bgRssi = stats.backgroundRSSI
|
|
|
|
if (bgRssi) {
|
|
if (!controllerNode.bgRSSIPoints) {
|
|
controllerNode.bgRSSIPoints = []
|
|
}
|
|
|
|
controllerNode.bgRSSIPoints.push(bgRssi)
|
|
|
|
if (controllerNode.bgRSSIPoints.length > 360) {
|
|
const firstPoint = controllerNode.bgRSSIPoints[0]
|
|
const lastPoint =
|
|
controllerNode.bgRSSIPoints[
|
|
controllerNode.bgRSSIPoints.length - 1
|
|
]
|
|
|
|
const maxTimeSpan = 3 * 60 * 60 * 1000 // 3 hours
|
|
|
|
if (
|
|
lastPoint.timestamp - firstPoint.timestamp >
|
|
maxTimeSpan
|
|
) {
|
|
controllerNode.bgRSSIPoints.shift()
|
|
}
|
|
}
|
|
}
|
|
|
|
this.emitStatistics(controllerNode, {
|
|
statistics: stats,
|
|
lastActive: controllerNode.lastActive,
|
|
bgRssi,
|
|
})
|
|
}
|
|
|
|
this.emit('event', EventSource.CONTROLLER, 'statistics updated', stats)
|
|
}
|
|
|
|
private _onControllerStatusChanged(status: ControllerStatus) {
|
|
let message = ''
|
|
|
|
if (status === ControllerStatus.Unresponsive) {
|
|
this._error = 'Controller is unresponsive'
|
|
message = this._error
|
|
} else if (status === ControllerStatus.Jammed) {
|
|
this._error = 'Controller is unable to transmit'
|
|
message = this._error
|
|
} else {
|
|
message = `Controller is ${getEnumMemberName(
|
|
ControllerStatus,
|
|
status,
|
|
)}`
|
|
this._error = undefined
|
|
}
|
|
|
|
this._updateControllerStatus(message)
|
|
this._onNodeEvent(
|
|
'status changed',
|
|
this.getNode(this.driver.controller.ownNodeId),
|
|
status,
|
|
)
|
|
this.emit('event', EventSource.CONTROLLER, 'status changed', status)
|
|
}
|
|
|
|
private _onBootLoaderReady() {
|
|
this._updateControllerStatus('Bootloader is READY')
|
|
|
|
this.status = ZwaveClientStatus.BOOTLOADER_READY
|
|
|
|
logger.info(`Bootloader is READY`)
|
|
|
|
this.emit('event', EventSource.DRIVER, 'bootloader ready')
|
|
}
|
|
|
|
private _onScanComplete() {
|
|
this._scanComplete = true
|
|
|
|
this._updateControllerStatus('Scan completed')
|
|
|
|
// all nodes are ready
|
|
this.status = ZwaveClientStatus.SCAN_DONE
|
|
|
|
logger.info(`Network scan complete. Found: ${this._nodes.size} nodes`)
|
|
|
|
this.emit('scanComplete')
|
|
|
|
this.emit('event', EventSource.DRIVER, 'all nodes ready')
|
|
}
|
|
|
|
// ---------- CONTROLLER EVENTS -------------------------------
|
|
|
|
private _updateControllerStatus(status: string) {
|
|
if (this._cntStatus !== status) {
|
|
logger.info(`Controller status: ${status}`)
|
|
this._cntStatus = status
|
|
this.sendToSocket(socketEvents.controller, {
|
|
status,
|
|
error: this._error,
|
|
inclusionState: this._inclusionState,
|
|
})
|
|
}
|
|
}
|
|
|
|
private _onInclusionStarted(strategy: InclusionStrategy) {
|
|
const secure = strategy !== InclusionStrategy.Insecure
|
|
const message = `${secure ? 'Secure' : 'Non-secure'} inclusion started`
|
|
this._updateControllerStatus(message)
|
|
this.emit('event', EventSource.CONTROLLER, 'inclusion started', secure)
|
|
}
|
|
|
|
private _onExclusionStarted() {
|
|
const message = 'Exclusion started'
|
|
this._updateControllerStatus(message)
|
|
this.emit('event', EventSource.CONTROLLER, 'exclusion started')
|
|
}
|
|
|
|
private _onInclusionStopped() {
|
|
const message = 'Inclusion stopped'
|
|
|
|
this._updateControllerStatus(message)
|
|
this.emit('event', EventSource.CONTROLLER, 'inclusion stopped')
|
|
}
|
|
|
|
private _onExclusionStopped() {
|
|
const message = 'Exclusion stopped'
|
|
this._updateControllerStatus(message)
|
|
this.emit('event', EventSource.CONTROLLER, 'exclusion stopped')
|
|
}
|
|
|
|
private _onInclusionStateChanged(state: InclusionState) {
|
|
if (state !== this._inclusionState) {
|
|
this._inclusionState = state
|
|
|
|
this.sendToSocket(socketEvents.controller, {
|
|
status: this._cntStatus,
|
|
error: this._error,
|
|
inclusionState: this._inclusionState,
|
|
})
|
|
}
|
|
}
|
|
|
|
private _onInclusionFailed() {
|
|
const message = 'Inclusion failed'
|
|
this.isReplacing = false
|
|
this.tmpNode = undefined
|
|
this._updateControllerStatus(message)
|
|
this.emit('event', EventSource.CONTROLLER, 'inclusion failed')
|
|
}
|
|
|
|
private _onExclusionFailed() {
|
|
const message = 'Exclusion failed'
|
|
this._updateControllerStatus(message)
|
|
this.emit('event', EventSource.CONTROLLER, 'exclusion failed')
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node is found, this is emitted when stick includes the node
|
|
* the only reliable info at this point is the node id
|
|
*/
|
|
private _onNodeFound(foundNode: FoundNode) {
|
|
let node: ZUINode
|
|
const nodeId = foundNode.id
|
|
// the driver is ready so this node has been added on fly
|
|
if (this.driverReady) {
|
|
node = this._createNode(nodeId)
|
|
this.sendToSocket(socketEvents.nodeFound, { node })
|
|
} else {
|
|
node = this._nodes.get(nodeId)
|
|
}
|
|
|
|
this.logNode(node, 'info', 'Found')
|
|
|
|
this.emitNodeUpdate(node)
|
|
|
|
this.emit('event', EventSource.CONTROLLER, 'node found', { id: nodeId })
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node is added. Emitted after zwave-js exchanges security key, adds lifeline, SUC route, etc.
|
|
*/
|
|
private async _onNodeAdded(zwaveNode: ZWaveNode, result: InclusionResult) {
|
|
let node: ZUINode
|
|
// the driver is ready so this node has been added on fly
|
|
if (this.driverReady) {
|
|
node = this._addNode(zwaveNode)
|
|
|
|
const security = zwaveNode.getHighestSecurityClass()
|
|
|
|
if (security) {
|
|
node.security = SecurityClass[security]
|
|
}
|
|
|
|
if (zwaveNode.dsk) {
|
|
const entry = this.driver.controller.getProvisioningEntry(
|
|
dskToString(zwaveNode.dsk),
|
|
)
|
|
|
|
if (entry) {
|
|
if (entry.name) {
|
|
await this.setNodeName(zwaveNode.id, entry.name)
|
|
}
|
|
|
|
if (entry.location) {
|
|
await this.setNodeLocation(zwaveNode.id, entry.location)
|
|
}
|
|
}
|
|
}
|
|
|
|
this.sendToSocket(socketEvents.nodeAdded, { node, result })
|
|
}
|
|
|
|
const security =
|
|
node?.security ||
|
|
(result.lowSecurity ? 'LOW SECURITY' : 'HIGH SECURITY')
|
|
|
|
this.logNode(node, 'info', `Added with security ${security}`)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.CONTROLLER,
|
|
'node added',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when node is removed
|
|
*
|
|
*/
|
|
private _onNodeRemoved(zwaveNode: ZWaveNode, reason: RemoveNodeReason) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
'Removed, reason: ' + getEnumMemberName(RemoveNodeReason, reason),
|
|
)
|
|
zwaveNode.removeAllListeners()
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.CONTROLLER,
|
|
'node removed',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
reason,
|
|
)
|
|
|
|
this._removeNode(zwaveNode.id)
|
|
}
|
|
|
|
/**
|
|
* Triggered on each progress of rebuild routes process
|
|
*/
|
|
private _onRebuildRoutesProgress(
|
|
progress: ReadonlyMap<number, RebuildRoutesStatus>,
|
|
) {
|
|
const toRebuild = [...progress.values()]
|
|
const rebuiltNodes = toRebuild.filter((v) => v !== 'pending')
|
|
const message = `Rebuild Routes process IN PROGRESS. Healed ${rebuiltNodes.length} nodes`
|
|
this._updateControllerStatus(message)
|
|
this.sendToSocket(socketEvents.rebuildRoutesProgress, [
|
|
...progress.entries(),
|
|
])
|
|
|
|
// update rebuildNodeRoutes progress status
|
|
for (const [nodeId, status] of progress) {
|
|
const node = this._nodes.get(nodeId)
|
|
if (node) {
|
|
node.rebuildRoutesProgress = status
|
|
}
|
|
}
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.CONTROLLER,
|
|
'rebuild routes progress',
|
|
progress,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered on each progress of health check processes
|
|
*/
|
|
private _onHealthCheckProgress(
|
|
request: { nodeId: number; targetNodeId: number },
|
|
round: number,
|
|
totalRounds: number,
|
|
lastRating: number,
|
|
lastResult: RouteHealthCheckResult | LifelineHealthCheckResult,
|
|
) {
|
|
const message = `Health check ${request.nodeId}-->${request.targetNodeId}: ${round}/${totalRounds} done, last rating ${lastRating}`
|
|
this._updateControllerStatus(message)
|
|
this.sendToSocket(socketEvents.healthCheckProgress, {
|
|
request,
|
|
round,
|
|
totalRounds,
|
|
lastRating,
|
|
lastResult,
|
|
})
|
|
}
|
|
|
|
private _onLinkReliabilityCheckProgress(
|
|
request: { nodeId: number },
|
|
...args: any[]
|
|
) {
|
|
// const message = `Link statistics ${request.nodeId}: ${args.join(', ')}`
|
|
// this._updateControllerStatus(message)
|
|
this.sendToSocket(socketEvents.linkReliability, {
|
|
request,
|
|
args,
|
|
})
|
|
}
|
|
|
|
private _onRebuildRoutesDone(result) {
|
|
const message = `Rebuild Routes process COMPLETED. Healed ${result.size} nodes`
|
|
this._updateControllerStatus(message)
|
|
}
|
|
|
|
private _onGrantSecurityClasses(
|
|
requested: InclusionGrant,
|
|
): Promise<InclusionGrant | false> {
|
|
logger.log('info', `Grant security classes: %o`, requested)
|
|
this.sendToSocket(socketEvents.grantSecurityClasses, requested)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.CONTROLLER,
|
|
'grant security classes',
|
|
requested,
|
|
)
|
|
|
|
return new Promise((resolve) => {
|
|
this._grantResolve = resolve
|
|
})
|
|
}
|
|
|
|
grantSecurityClasses(requested: InclusionGrant) {
|
|
if (this._grantResolve) {
|
|
this._grantResolve(requested)
|
|
this._grantResolve = null
|
|
} else {
|
|
logger.error('No inclusion process started')
|
|
}
|
|
}
|
|
|
|
private _onValidateDSK(dsk: string): Promise<string | false> {
|
|
logger.info(`DSK received ${dsk}`)
|
|
|
|
this.sendToSocket(socketEvents.validateDSK, dsk)
|
|
|
|
this.emit('event', EventSource.CONTROLLER, 'validate dsk', dsk)
|
|
|
|
return new Promise((resolve) => {
|
|
this._dskResolve = resolve
|
|
})
|
|
}
|
|
|
|
validateDSK(dsk: string) {
|
|
if (this._dskResolve) {
|
|
this._dskResolve(dsk)
|
|
this._dskResolve = null
|
|
} else {
|
|
logger.error('No inclusion process started')
|
|
}
|
|
}
|
|
|
|
abortInclusion() {
|
|
if (this._dskResolve) {
|
|
this._dskResolve(false)
|
|
this._dskResolve = null
|
|
}
|
|
|
|
if (this._grantResolve) {
|
|
this._grantResolve(false)
|
|
this._grantResolve = null
|
|
}
|
|
}
|
|
|
|
private _onAbortInclusion() {
|
|
this._dskResolve = null
|
|
this._grantResolve = null
|
|
this.sendToSocket(socketEvents.inclusionAborted, true)
|
|
|
|
this.emit('event', EventSource.CONTROLLER, 'inclusion aborted')
|
|
|
|
logger.warn('Inclusion aborted')
|
|
}
|
|
|
|
async backupNVMRaw(): Promise<{ data: Buffer; fileName: string }> {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
// it's set when the backup has been triggered by an event
|
|
const event = this.nvmEvent ? this.nvmEvent + '_' : ''
|
|
this.nvmEvent = null
|
|
|
|
const data = await this.driver.controller.backupNVMRaw(
|
|
this._onBackupNVMProgress.bind(this),
|
|
)
|
|
|
|
const fileName = `${NVM_BACKUP_PREFIX}${utils.fileDate()}${event}`
|
|
|
|
await mkdirp(nvmBackupsDir)
|
|
|
|
await writeFile(utils.joinPath(nvmBackupsDir, fileName + '.bin'), data)
|
|
|
|
return { data: Buffer.from(data.buffer), fileName }
|
|
}
|
|
|
|
private _onBackupNVMProgress(bytesRead: number, totalBytes: number) {
|
|
const progress = Math.round((bytesRead / totalBytes) * 100)
|
|
this._updateControllerStatus(`Backup NVM progress: ${progress}%`)
|
|
}
|
|
|
|
async restoreNVM(data: Buffer, useRaw = false) {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
if (useRaw) {
|
|
await this.driver.controller.restoreNVMRaw(
|
|
data,
|
|
this._onRestoreNVMProgress.bind(this),
|
|
)
|
|
} else {
|
|
await this.driver.controller.restoreNVM(
|
|
data,
|
|
this._onConvertNVMProgress.bind(this),
|
|
this._onRestoreNVMProgress.bind(this),
|
|
)
|
|
}
|
|
}
|
|
|
|
private _onConvertNVMProgress(bytesRead: number, totalBytes: number) {
|
|
const progress = Math.round((bytesRead / totalBytes) * 100)
|
|
|
|
this._updateControllerStatus(`Convert NVM progress: ${progress}%`)
|
|
}
|
|
|
|
private _onRestoreNVMProgress(bytesRead: number, totalBytes: number) {
|
|
const progress = Math.round((bytesRead / totalBytes) * 100)
|
|
|
|
this._updateControllerStatus(`Restore NVM progress: ${progress}%`)
|
|
}
|
|
|
|
async getProvisioningEntries(): Promise<SmartStartProvisioningEntry[]> {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
const result = this.driver.controller.getProvisioningEntries()
|
|
|
|
for (const entry of result) {
|
|
const node = this.nodes.get(entry.nodeId)
|
|
if (node) {
|
|
if (node.deviceConfig) {
|
|
entry.manufacturer = node.deviceConfig.manufacturer
|
|
entry.label = node.deviceConfig.label
|
|
entry.description = node.deviceConfig.description
|
|
}
|
|
entry.protocol = node.protocol
|
|
} else if (
|
|
typeof entry.manufacturerId === 'number' &&
|
|
typeof entry.productType === 'number' &&
|
|
typeof entry.productId === 'number' &&
|
|
typeof entry.applicationVersion === 'string'
|
|
) {
|
|
const device = await this.driver.configManager.lookupDevice(
|
|
entry.manufacturerId,
|
|
entry.productType,
|
|
entry.productId,
|
|
entry.applicationVersion,
|
|
)
|
|
if (device) {
|
|
entry.manufacturer = device.manufacturer
|
|
entry.label = device.label
|
|
entry.description = device.description
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
getProvisioningEntry(dsk: string): SmartStartProvisioningEntry | undefined {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
return this.driver.controller.getProvisioningEntry(dsk)
|
|
}
|
|
|
|
unprovisionSmartStartNode(dskOrNodeId: string | number) {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
this.driver.controller.unprovisionSmartStartNode(dskOrNodeId)
|
|
}
|
|
|
|
parseQRCodeString(qrString: string): {
|
|
parsed?: QRProvisioningInformation
|
|
nodeId?: number
|
|
exists: boolean
|
|
} {
|
|
const parsed = parseQRCodeString(qrString)
|
|
let node: ZWaveNode | undefined
|
|
let exists = false
|
|
|
|
if (parsed?.dsk) {
|
|
node = this.driver.controller.getNodeByDSK(parsed.dsk)
|
|
|
|
if (!node) {
|
|
exists = !!this.getProvisioningEntry(parsed.dsk)
|
|
}
|
|
}
|
|
|
|
return {
|
|
parsed,
|
|
nodeId: node?.id,
|
|
exists,
|
|
}
|
|
}
|
|
|
|
provisionSmartStartNode(entry: PlannedProvisioningEntry | string) {
|
|
if (!this.driverReady) {
|
|
throw new DriverNotReadyError()
|
|
}
|
|
|
|
if (typeof entry === 'string') {
|
|
// it's a qrcode
|
|
entry = parseQRCodeString(entry)
|
|
}
|
|
|
|
if (!entry.dsk) {
|
|
throw Error('DSK is required')
|
|
}
|
|
|
|
const isNew = !this.driver.controller.getProvisioningEntry(entry.dsk)
|
|
|
|
// disable it so user can choose the protocol to use
|
|
if (
|
|
isNew &&
|
|
entry.supportedProtocols?.includes(Protocols.ZWaveLongRange)
|
|
) {
|
|
entry.status = ProvisioningEntryStatus.Inactive
|
|
}
|
|
|
|
this.driver.controller.provisionSmartStartNode(entry)
|
|
|
|
return entry
|
|
}
|
|
|
|
// ---------- NODE EVENTS -------------------------------------
|
|
|
|
/**
|
|
* Update current node status and interviewState
|
|
*
|
|
*/
|
|
private _onNodeStatus(zwaveNode: ZWaveNode, updateStatusOnly = false) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
|
|
if (node) {
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/node/Types.ts#L127
|
|
node.status = NodeStatus[
|
|
zwaveNode.status
|
|
] as keyof typeof NodeStatus
|
|
node.available = zwaveNode.status !== NodeStatus.Dead
|
|
node.interviewStage = InterviewStage[
|
|
zwaveNode.interviewStage
|
|
] as keyof typeof InterviewStage
|
|
|
|
if (zwaveNode.interviewStage === InterviewStage.Complete) {
|
|
node.hasDeviceConfigChanged = zwaveNode.hasDeviceConfigChanged()
|
|
}
|
|
|
|
let changedProps: utils.DeepPartial<ZUINode>
|
|
|
|
if (updateStatusOnly) {
|
|
changedProps = {
|
|
status: node.status,
|
|
available: node.available,
|
|
interviewStage: node.interviewStage,
|
|
}
|
|
}
|
|
|
|
this.emitNodeUpdate(node, changedProps)
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'error',
|
|
`Received status update but node doesn't exists`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggered every time a node event is received
|
|
*
|
|
*/
|
|
private _onNodeEvent(
|
|
eventName: ZwaveNodeEvents | 'status changed',
|
|
zwaveNode: ZWaveNode,
|
|
...eventArgs: any[]
|
|
) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
|
|
if (node) {
|
|
const event: NodeEvent = {
|
|
time: new Date(),
|
|
event: eventName,
|
|
args: eventArgs,
|
|
}
|
|
node.eventsQueue.push(event)
|
|
|
|
this.sendToSocket(socketEvents.nodeEvent, {
|
|
nodeId: node?.id,
|
|
event,
|
|
})
|
|
|
|
while (node.eventsQueue.length > this.maxNodeEventsQueueSize) {
|
|
node.eventsQueue.shift()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node is ready. All values are added and all node info are received
|
|
*
|
|
*/
|
|
private _onNodeReady(zwaveNode: ZWaveNode) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
|
|
if (!node) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'error',
|
|
`Ready event called but node doesn't exists`,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// keep track of existing values (if any)
|
|
const existingValues = node.values
|
|
|
|
// node can trigger the ready event multiple times. Set it to false to prevent discovery
|
|
node.ready = false
|
|
node.values = {}
|
|
|
|
this._dumpNode(zwaveNode)
|
|
|
|
const values = zwaveNode.getDefinedValueIDs()
|
|
const delayedUpdates = []
|
|
|
|
for (const zwaveValue of values) {
|
|
const res = this._addValue(
|
|
zwaveNode,
|
|
zwaveValue,
|
|
existingValues,
|
|
true,
|
|
)
|
|
|
|
if (!res) continue
|
|
|
|
const { valueId, updated } = res
|
|
|
|
// in case of writeable values whe always need to emit a
|
|
// value change event in order to subscribe mqtt topics
|
|
if (updated || valueId.writeable) {
|
|
delayedUpdates.push(
|
|
this.emitValueChanged.bind(this, valueId, node, true),
|
|
)
|
|
}
|
|
|
|
// setup value observer (if any)
|
|
this.subscribeObservers(node, valueId)
|
|
}
|
|
|
|
// emit value updated events when all values are added
|
|
// this prevents to have undefined target values when using mqtt
|
|
delayedUpdates.forEach((fn) => fn())
|
|
|
|
// add it to know devices types (if not already present)
|
|
if (!this._devices[node.deviceId]) {
|
|
this._devices[node.deviceId] = {
|
|
name: `[${node.deviceId}] ${node.productDescription} (${node.manufacturer})`,
|
|
values: utils.copy(node.values),
|
|
}
|
|
|
|
const deviceValues = this._devices[node.deviceId].values
|
|
|
|
delete this._devices[node.deviceId].hassDevices
|
|
|
|
// remove node specific info from values
|
|
for (const id in deviceValues) {
|
|
delete deviceValues[id].nodeId
|
|
|
|
// remove the node part
|
|
deviceValues[id].id = id
|
|
}
|
|
}
|
|
|
|
// node is ready when all its info are parsed and all values added
|
|
// don't set the node as ready before all values are added, to prevent discovery
|
|
node.ready = true
|
|
|
|
if (node.isControllerNode) {
|
|
node.supportsLongRange = this.driver.controller.supportsLongRange
|
|
this.updateControllerNodeProps(node).catch((error) => {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'error',
|
|
`Failed to get controller node ${node.id} properties: ${error.message}`,
|
|
)
|
|
})
|
|
}
|
|
|
|
// check if this node can call the Sync time
|
|
node.supportsTime =
|
|
zwaveNode.supportsCC(CommandClasses.Time) ||
|
|
zwaveNode.supportsCC(CommandClasses['Time Parameters']) ||
|
|
zwaveNode.supportsCC(CommandClasses['Clock']) ||
|
|
zwaveNode.supportsCC(CommandClasses['Schedule Entry Lock'])
|
|
|
|
this.getGroups(zwaveNode.id, true)
|
|
|
|
this._onNodeStatus(zwaveNode)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node ready',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Ready: ${node.manufacturer} - ${node.productLabel} (${
|
|
node.productDescription || 'Unknown'
|
|
})`,
|
|
)
|
|
|
|
if (zwaveNode.commandClasses['Schedule Entry Lock'].isSupported()) {
|
|
this.logNode(zwaveNode, 'info', `Schedule Entry Lock is supported`)
|
|
|
|
this.getSchedules(zwaveNode.id, { fromCache: true }).catch(
|
|
(error) => {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'error',
|
|
`Failed to get schedules for node ${node.id}: ${error.message}`,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
// Long range nodes use a star topology, so they don't have return/priority routes
|
|
if (
|
|
!zwaveNode.isControllerNode &&
|
|
zwaveNode.protocol !== Protocols.ZWaveLongRange
|
|
) {
|
|
this.getPriorityRoute(zwaveNode.id).catch((error) => {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'error',
|
|
`Failed to get priority route for node ${node.id}: ${error.message}`,
|
|
)
|
|
})
|
|
|
|
this.getCustomSUCReturnRoute(zwaveNode.id)
|
|
this.getPrioritySUCReturnRoute(zwaveNode.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node interview starts for the first time or when the node is manually re-interviewed
|
|
*
|
|
*/
|
|
private _onNodeInterviewStarted(zwaveNode: ZWaveNode) {
|
|
this.logNode(zwaveNode, 'info', 'Interview started')
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node interview started',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when an interview stage complete
|
|
*
|
|
*/
|
|
private _onNodeInterviewStageCompleted(
|
|
zwaveNode: ZWaveNode,
|
|
stageName: string,
|
|
) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Interview stage ${stageName.toUpperCase()} completed`,
|
|
)
|
|
|
|
this._onNodeStatus(zwaveNode, true)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node interview stage completed',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node finish its interview. When this event is triggered all node values and metadata are updated
|
|
* Starting from zwave-js v7 this event is only triggered when the node is added the first time or manually re-interviewed
|
|
*
|
|
*/
|
|
private _onNodeInterviewCompleted(zwaveNode: ZWaveNode) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
|
|
if (node.manufacturerId === undefined) {
|
|
this._dumpNode(zwaveNode)
|
|
}
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
'Interview COMPLETED, all values are updated',
|
|
)
|
|
|
|
this._onNodeStatus(zwaveNode, true)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node interview completed',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node interview fails.
|
|
*
|
|
*/
|
|
private _onNodeInterviewFailed(
|
|
zwaveNode: ZWaveNode,
|
|
args: NodeInterviewFailedEventArgs,
|
|
) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'error',
|
|
`Interview FAILED: ${args.errorMessage}`,
|
|
)
|
|
|
|
this._onNodeStatus(zwaveNode, true)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node interview failed',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node wake ups
|
|
*
|
|
*/
|
|
private _onNodeWakeUp(zwaveNode: ZWaveNode, oldStatus: NodeStatus) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Is ${oldStatus === NodeStatus.Unknown ? '' : 'now '}awake`,
|
|
)
|
|
|
|
this._onNodeStatus(zwaveNode, true)
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node wakeup',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node is sleeping
|
|
*
|
|
*/
|
|
private _onNodeSleep(zwaveNode: ZWaveNode, oldStatus: NodeStatus) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Is ${oldStatus === NodeStatus.Unknown ? '' : 'now '}asleep`,
|
|
)
|
|
|
|
this._onNodeStatus(zwaveNode, true)
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node sleep',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node is alive
|
|
*
|
|
*/
|
|
private _onNodeAlive(zwaveNode: ZWaveNode, oldStatus: NodeStatus) {
|
|
this._onNodeStatus(zwaveNode, true)
|
|
if (oldStatus === NodeStatus.Dead) {
|
|
this.logNode(zwaveNode, 'info', 'Has returned from the dead')
|
|
} else {
|
|
this.logNode(zwaveNode, 'info', 'Is alive')
|
|
}
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node alive',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node is dead
|
|
*
|
|
*/
|
|
private _onNodeDead(zwaveNode: ZWaveNode, oldStatus: NodeStatus) {
|
|
this._onNodeStatus(zwaveNode, true)
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Is ${oldStatus === NodeStatus.Unknown ? '' : 'now '}dead`,
|
|
)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node dead',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node value is added
|
|
*
|
|
*/
|
|
private _onNodeValueAdded(
|
|
zwaveNode: ZWaveNode,
|
|
args: ZWaveNodeValueAddedArgs,
|
|
) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Value added: ${this._getValueID(
|
|
args as unknown as ZUIValueId,
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
)} => ${args.newValue}`,
|
|
)
|
|
|
|
// handle node values added 'on fly'
|
|
if (zwaveNode.ready) {
|
|
const res = this._addValue(zwaveNode, args)
|
|
|
|
if (res?.valueId) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
this.subscribeObservers(node, res.valueId)
|
|
}
|
|
}
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node value added',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
args,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Emitted when we receive a `value notification` event
|
|
*
|
|
*/
|
|
private _onNodeValueNotification(
|
|
zwaveNode: ZWaveNode,
|
|
args: ZWaveNodeValueNotificationArgs & {
|
|
newValue?: any
|
|
stateless: boolean
|
|
},
|
|
) {
|
|
// notification hasn't `newValue`
|
|
args.newValue = args.value
|
|
// specify that this is stateless
|
|
args.stateless = true
|
|
|
|
this._onNodeValueUpdated(zwaveNode, args)
|
|
}
|
|
|
|
/**
|
|
* Emitted when we receive a `value updated` event
|
|
*
|
|
*/
|
|
private _onNodeValueUpdated(
|
|
zwaveNode: ZWaveNode,
|
|
args: (ZWaveNodeValueUpdatedArgs | ZWaveNodeValueNotificationArgs) & {
|
|
prevValue?: any
|
|
newValue?: any
|
|
stateless: boolean
|
|
},
|
|
) {
|
|
this._updateValue(zwaveNode, args)
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Value ${
|
|
args.stateless ? 'notification' : 'updated'
|
|
}: ${this._getValueID(args)} ${
|
|
args.stateless
|
|
? args.newValue
|
|
: `${args.prevValue} => ${args.newValue}`
|
|
}`,
|
|
)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node value updated',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
args,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Emitted when we receive a `value removed` event
|
|
*
|
|
*/
|
|
private _onNodeValueRemoved(
|
|
zwaveNode: ZWaveNode,
|
|
args: ZWaveNodeValueRemovedArgs,
|
|
) {
|
|
this._removeValue(zwaveNode, args)
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Value removed: ${this._getValueID(args)}`,
|
|
)
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node value removed',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
args,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Emitted when we receive a `metadata updated` event
|
|
*
|
|
*/
|
|
private _onNodeMetadataUpdated(
|
|
zwaveNode: ZWaveNode,
|
|
args: ZWaveNodeMetadataUpdatedArgs,
|
|
) {
|
|
const value = this._parseValue(zwaveNode, args, args.metadata)
|
|
|
|
this.sendToSocket(socketEvents.metadataUpdated, value)
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Metadata updated: ${this._getValueID(
|
|
args as unknown as ZUIValueId,
|
|
)}`,
|
|
)
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node metadata updated',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
args,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Emitted when we receive a node `notification` event
|
|
*
|
|
*/
|
|
private _onNodeNotification: ZWaveNotificationCallback = (...parms) => {
|
|
const [endpoint, ccId, args] = parms
|
|
|
|
const zwaveNode = endpoint.tryGetNode()
|
|
|
|
if (!zwaveNode) {
|
|
this.logNode(
|
|
endpoint.nodeId,
|
|
'error',
|
|
`Notification received but node doesn't exist`,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
const valueId: Partial<ZUIValueId> = {
|
|
id: null,
|
|
nodeId: zwaveNode.id,
|
|
commandClass: ccId,
|
|
commandClassName: CommandClasses[ccId],
|
|
property: null,
|
|
}
|
|
|
|
let data = null
|
|
|
|
if (ccId === CommandClasses.Notification) {
|
|
valueId.property = args.label
|
|
valueId.propertyKey = args.eventLabel
|
|
|
|
data = this._parseNotification(args.parameters)
|
|
} else if (ccId === CommandClasses['Entry Control']) {
|
|
valueId.property = args.eventType.toString()
|
|
valueId.propertyKey = args.dataType
|
|
data =
|
|
args.eventData instanceof Buffer
|
|
? utils.buffer2hex(args.eventData)
|
|
: args.eventData
|
|
} else if (ccId === CommandClasses['Multilevel Switch']) {
|
|
valueId.property = getEnumMemberName(
|
|
MultilevelSwitchCommand,
|
|
args.eventType as number,
|
|
)
|
|
data = args.direction
|
|
} else if (ccId === CommandClasses.Powerlevel) {
|
|
// ignore, this should be handled in zwave-js
|
|
return
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'error',
|
|
'Unknown notification received CC %s: %o',
|
|
valueId.commandClassName,
|
|
args,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
valueId.id = this._getValueID(valueId, true)
|
|
valueId.propertyName = valueId.property // must be defined in named topics
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`CC %s notification %o`,
|
|
valueId.commandClassName,
|
|
args,
|
|
)
|
|
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
|
|
this.emit('notification', node, valueId as ZUIValueId, data)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node notification',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
ccId,
|
|
args,
|
|
)
|
|
}
|
|
|
|
private _onNodeStatisticsUpdated(
|
|
zwaveNode: ZWaveNode,
|
|
stats: NodeStatistics,
|
|
) {
|
|
const node = this.nodes.get(zwaveNode.id)
|
|
|
|
if (node) {
|
|
node.statistics = { ...stats } // stats is readonly, we need to be able to edit it in getPriorityRoute
|
|
|
|
// update stats only when node is doing something
|
|
if (stats.lastSeen) {
|
|
node.lastActive = stats.lastSeen?.getTime()
|
|
this.emit('nodeLastActive', node)
|
|
}
|
|
|
|
this.emitStatistics(node, {
|
|
statistics: stats,
|
|
lastActive: node.lastActive,
|
|
applicationRoute: node.applicationRoute || null,
|
|
})
|
|
}
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'statistics updated',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
stats,
|
|
)
|
|
}
|
|
|
|
private _onNodeInfoReceived(zwaveNode: ZWaveNode) {
|
|
this.logNode(zwaveNode, 'info', `Node info (NIF) received`)
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node info received',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Emitted when we receive a node `firmware update progress` event
|
|
*
|
|
*/
|
|
private _onNodeFirmwareUpdateProgress: ZWaveNodeFirmwareUpdateProgressCallback =
|
|
function _onNodeFirmwareUpdateProgress(
|
|
this: ZwaveClient,
|
|
zwaveNode: ZWaveNode,
|
|
progress: FirmwareUpdateProgress,
|
|
) {
|
|
const node = this.nodes.get(zwaveNode.id)
|
|
if (node) {
|
|
node.firmwareUpdate = progress
|
|
// send at most 4msg per second
|
|
this.throttle(
|
|
this._onNodeFirmwareUpdateProgress.name + '_' + node.id,
|
|
this.emitNodeUpdate.bind(this, node, {
|
|
firmwareUpdate: progress,
|
|
} as utils.DeepPartial<ZUINode>),
|
|
250,
|
|
)
|
|
}
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node firmware update progress',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
progress,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Triggered we receive a node `firmware update finished` event
|
|
*
|
|
*/
|
|
private _onNodeFirmwareUpdateFinished: ZWaveNodeFirmwareUpdateFinishedCallback =
|
|
function _onNodeFirmwareUpdateFinished(
|
|
this: ZwaveClient,
|
|
zwaveNode: ZWaveNode,
|
|
result: FirmwareUpdateResult,
|
|
) {
|
|
const node = this.nodes.get(zwaveNode.id)
|
|
if (node) {
|
|
node.firmwareUpdate = undefined
|
|
|
|
this.emitNodeUpdate(node, {
|
|
firmwareUpdate: false,
|
|
} as any)
|
|
}
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Firmware update finished ${
|
|
result.success ? 'successfully' : 'with error'
|
|
}.\n Status: ${getEnumMemberName(
|
|
FirmwareUpdateStatus,
|
|
result.status,
|
|
)}.\n Wait before interacting: ${
|
|
result.waitTime !== undefined ? `${result.waitTime}s` : 'No'
|
|
}.\n Result: ${JSON.stringify(result)}.`,
|
|
)
|
|
|
|
if (result.reInterview) {
|
|
this.logNode(zwaveNode, 'info', 'Will be re-interviewed')
|
|
}
|
|
|
|
this.emit(
|
|
'event',
|
|
EventSource.NODE,
|
|
'node firmware update finished',
|
|
this.zwaveNodeToJSON(zwaveNode),
|
|
result,
|
|
)
|
|
}
|
|
|
|
// ------- NODE METHODS -------------
|
|
|
|
/**
|
|
* Bind to ZwaveNode events
|
|
*
|
|
*/
|
|
private _bindNodeEvents(zwaveNode: ZWaveNode) {
|
|
this.logNode(zwaveNode, 'debug', 'Binding to node events')
|
|
|
|
// https://zwave-js.github.io/node-zwave-js/#/api/node?id=zwavenode-events
|
|
zwaveNode
|
|
.on('ready', this._onNodeReady.bind(this))
|
|
.on('interview started', this._onNodeInterviewStarted.bind(this))
|
|
.on(
|
|
'interview stage completed',
|
|
this._onNodeInterviewStageCompleted.bind(this),
|
|
)
|
|
.on(
|
|
'interview completed',
|
|
this._onNodeInterviewCompleted.bind(this),
|
|
)
|
|
.on('interview failed', this._onNodeInterviewFailed.bind(this))
|
|
.on('wake up', this._onNodeWakeUp.bind(this))
|
|
.on('sleep', this._onNodeSleep.bind(this))
|
|
.on('alive', this._onNodeAlive.bind(this))
|
|
.on('dead', this._onNodeDead.bind(this))
|
|
.on('value added', this._onNodeValueAdded.bind(this))
|
|
.on('value updated', this._onNodeValueUpdated.bind(this))
|
|
.on('value notification', this._onNodeValueNotification.bind(this))
|
|
.on('value removed', this._onNodeValueRemoved.bind(this))
|
|
.on('metadata updated', this._onNodeMetadataUpdated.bind(this))
|
|
.on('notification', this._onNodeNotification.bind(this))
|
|
.on(
|
|
'firmware update progress',
|
|
this._onNodeFirmwareUpdateProgress.bind(this),
|
|
)
|
|
.on(
|
|
'firmware update finished',
|
|
this._onNodeFirmwareUpdateFinished.bind(this),
|
|
)
|
|
.on('statistics updated', this._onNodeStatisticsUpdated.bind(this))
|
|
.on('node info received', this._onNodeInfoReceived.bind(this))
|
|
|
|
const events: ZwaveNodeEvents[] = [
|
|
'ready',
|
|
'interview started',
|
|
'interview stage completed',
|
|
'interview completed',
|
|
'interview failed',
|
|
'wake up',
|
|
'sleep',
|
|
'alive',
|
|
'dead',
|
|
'value added',
|
|
'value updated',
|
|
'value notification',
|
|
'value removed',
|
|
'notification',
|
|
'firmware update progress',
|
|
'firmware update finished',
|
|
]
|
|
|
|
for (const event of events) {
|
|
zwaveNode.on(event, this._onNodeEvent.bind(this, event))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a node from internal nodes array
|
|
*
|
|
*/
|
|
private _removeNode(nodeid: number) {
|
|
// don't use splice here, nodeid equals to the index in the array
|
|
const node = this._nodes.get(nodeid)
|
|
if (node) {
|
|
this._nodes.delete(nodeid)
|
|
|
|
this.emit('nodeRemoved', {
|
|
id: node.id,
|
|
name: node.name,
|
|
loc: node.loc,
|
|
})
|
|
this.sendToSocket(socketEvents.nodeRemoved, node)
|
|
}
|
|
|
|
if (!this.isReplacing && this.storeNodes[nodeid]) {
|
|
delete this.storeNodes[nodeid]
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
this.updateStoreNodes(false)
|
|
}
|
|
}
|
|
|
|
private _createNode(nodeId: number) {
|
|
// set node name and location sent with beginInclusion call
|
|
if (this.tmpNode) {
|
|
if (this.storeNodes[nodeId]) {
|
|
this.storeNodes[nodeId].name = this.tmpNode.name
|
|
this.storeNodes[nodeId].loc = this.tmpNode.loc
|
|
} else {
|
|
this.storeNodes[nodeId] = {
|
|
name: this.tmpNode.name,
|
|
loc: this.tmpNode.loc,
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
this.updateStoreNodes(false)
|
|
|
|
this.tmpNode = undefined
|
|
}
|
|
|
|
const node: ZUINode = {
|
|
id: nodeId,
|
|
name: this.storeNodes[nodeId]?.name || '',
|
|
loc: this.storeNodes[nodeId]?.loc || '',
|
|
values: {},
|
|
groups: [],
|
|
neighbors: [],
|
|
ready: false,
|
|
available: false,
|
|
hassDevices: {},
|
|
failed: false,
|
|
inited: false,
|
|
eventsQueue: [],
|
|
status: 'Unknown',
|
|
interviewStage: 'None',
|
|
priorityReturnRoute: {},
|
|
customReturnRoute: {},
|
|
prioritySUCReturnRoute:
|
|
this._driver.controller.getPrioritySUCReturnRouteCached(nodeId),
|
|
customSUCReturnRoutes:
|
|
this._driver.controller.getCustomSUCReturnRoutesCached(nodeId),
|
|
applicationRoute: null,
|
|
}
|
|
|
|
this._nodes.set(nodeId, node)
|
|
|
|
return node
|
|
}
|
|
|
|
/**
|
|
* Add a new node to our nodes array. No informations are available yet, the node needs to be ready
|
|
*
|
|
*/
|
|
private _addNode(zwaveNode: ZWaveNode): ZUINode {
|
|
const nodeId = zwaveNode.id
|
|
|
|
const existingNode = this._nodes.get(nodeId)
|
|
|
|
// this shouldn't happen
|
|
if (existingNode && existingNode.ready) {
|
|
logger.error(
|
|
'Error while adding node ' + nodeId,
|
|
Error('node has been added twice'),
|
|
)
|
|
return existingNode
|
|
}
|
|
|
|
this._bindNodeEvents(zwaveNode)
|
|
this._dumpNode(zwaveNode)
|
|
this._onNodeStatus(zwaveNode)
|
|
|
|
this.logNode(zwaveNode, 'debug', `Has been added to nodes array`)
|
|
|
|
return existingNode
|
|
}
|
|
|
|
/**
|
|
* Initialize a node with all its info
|
|
*
|
|
*/
|
|
private _dumpNode(zwaveNode: ZWaveNode) {
|
|
const nodeId = zwaveNode.id
|
|
|
|
const node = this._nodes.get(nodeId)
|
|
|
|
if (!node) return
|
|
|
|
const hexIds = [
|
|
utils.num2hex(zwaveNode.manufacturerId),
|
|
utils.num2hex(zwaveNode.productId),
|
|
utils.num2hex(zwaveNode.productType),
|
|
]
|
|
node.hexId = `${hexIds[0]} ${hexIds[2]}-${hexIds[1]}`
|
|
node.dbLink = `https://devices.zwave-js.io/?jumpTo=${hexIds[0]}:${
|
|
hexIds[2]
|
|
}:${hexIds[1]}:${node.firmwareVersion || '0.0'}`
|
|
|
|
const deviceConfig = zwaveNode.deviceConfig || {
|
|
label: `Unknown product ${hexIds[1]}`,
|
|
description: hexIds[2],
|
|
manufacturer:
|
|
this.driver.configManager.lookupManufacturer(
|
|
zwaveNode.manufacturerId,
|
|
) || `Unknown manufacturer ${hexIds[0]}`,
|
|
}
|
|
|
|
// https://zwave-js.github.io/node-zwave-js/#/api/node?id=zwavenode-properties
|
|
node.manufacturerId = zwaveNode.manufacturerId
|
|
node.productId = zwaveNode.productId
|
|
node.productType = zwaveNode.productType
|
|
node.deviceConfig = zwaveNode.deviceConfig
|
|
|
|
node.productLabel = deviceConfig.label
|
|
node.productDescription = deviceConfig.description
|
|
node.manufacturer = deviceConfig.manufacturer
|
|
node.firmwareVersion = zwaveNode.firmwareVersion
|
|
node.sdkVersion = zwaveNode.sdkVersion
|
|
node.protocolVersion = zwaveNode.protocolVersion
|
|
node.zwavePlusVersion = zwaveNode.zwavePlusVersion
|
|
node.zwavePlusNodeType = zwaveNode.zwavePlusNodeType
|
|
node.zwavePlusRoleType = zwaveNode.zwavePlusRoleType
|
|
node.nodeType = zwaveNode.nodeType
|
|
node.endpointsCount = zwaveNode.getEndpointCount()
|
|
node.endpoints = zwaveNode.getAllEndpoints().map((e) => {
|
|
const defaultLabel =
|
|
e.index === 0 ? 'Root Endpoint' : `Endpoint ${e.index}`
|
|
return {
|
|
index: e.index,
|
|
label: e.endpointLabel || defaultLabel,
|
|
deviceClass: {
|
|
basic: e.deviceClass?.basic,
|
|
generic: e.deviceClass?.generic.key,
|
|
specific: e.deviceClass?.specific.key,
|
|
},
|
|
}
|
|
})
|
|
node.isSecure = zwaveNode.isSecure
|
|
node.security = SecurityClass[zwaveNode.getHighestSecurityClass()]
|
|
node.supportsSecurity = zwaveNode.supportsSecurity
|
|
node.supportsBeaming = zwaveNode.supportsBeaming
|
|
node.isControllerNode = zwaveNode.isControllerNode
|
|
node.isListening = zwaveNode.isListening
|
|
node.isFrequentListening = zwaveNode.isFrequentListening
|
|
node.isRouting = zwaveNode.isRouting
|
|
node.keepAwake = zwaveNode.keepAwake
|
|
node.maxDataRate = zwaveNode.maxDataRate
|
|
node.deviceClass = {
|
|
basic: zwaveNode.deviceClass?.basic,
|
|
generic: zwaveNode.deviceClass?.generic.key,
|
|
specific: zwaveNode.deviceClass?.specific.key,
|
|
}
|
|
|
|
node.lastActive = zwaveNode.lastSeen?.getTime() || null
|
|
node.defaultTransitionDuration = zwaveNode.defaultTransitionDuration
|
|
node.defaultVolume = zwaveNode.defaultVolume
|
|
node.firmwareCapabilities =
|
|
zwaveNode.getFirmwareUpdateCapabilitiesCached()
|
|
|
|
node.protocol = zwaveNode.protocol
|
|
const storedNode = this.storeNodes[nodeId]
|
|
|
|
if (storedNode) {
|
|
node.loc = storedNode.loc || ''
|
|
node.name = storedNode.name || ''
|
|
|
|
if (storedNode.hassDevices) {
|
|
node.hassDevices = utils.copy(storedNode.hassDevices)
|
|
}
|
|
|
|
// keep zwaveNode and node name and location synced
|
|
if (node.name && node.name !== zwaveNode.name) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'debug',
|
|
`Setting node name to '${node.name}'`,
|
|
)
|
|
zwaveNode.name = node.name
|
|
}
|
|
if (node.loc && node.loc !== zwaveNode.location) {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'debug',
|
|
`Setting node location to '${node.loc}'`,
|
|
)
|
|
zwaveNode.location = node.loc
|
|
}
|
|
} else {
|
|
this.storeNodes[nodeId] = {}
|
|
}
|
|
|
|
node.deviceId = this._getDeviceID(node)
|
|
node.hasDeviceConfigChanged = zwaveNode.hasDeviceConfigChanged()
|
|
|
|
if (node.isControllerNode) {
|
|
node.rfRegions =
|
|
this.driver.controller
|
|
.getSupportedRFRegions()
|
|
?.map((region) => ({
|
|
value: region,
|
|
text: getEnumMemberName(RFRegion, region),
|
|
}))
|
|
.sort((a, b) => a.text.localeCompare(b.text)) ?? []
|
|
}
|
|
}
|
|
|
|
async updateControllerNodeProps(
|
|
node?: ZUINode,
|
|
props: Array<'powerlevel' | 'RFRegion'> = ['powerlevel', 'RFRegion'],
|
|
) {
|
|
node = node || this.nodes.get(this._driver.controller.ownNodeId)
|
|
if (props.includes('powerlevel')) {
|
|
if (
|
|
this._driver.controller.isSerialAPISetupCommandSupported(
|
|
SerialAPISetupCommand.GetPowerlevel,
|
|
)
|
|
) {
|
|
const { powerlevel, measured0dBm } =
|
|
await this._driver.controller.getPowerlevel()
|
|
node.powerlevel = powerlevel
|
|
node.measured0dBm = measured0dBm
|
|
} else {
|
|
logger.info('Powerlevel is not supported by controller')
|
|
}
|
|
}
|
|
|
|
if (props.includes('RFRegion')) {
|
|
if (
|
|
this._driver.controller.isSerialAPISetupCommandSupported(
|
|
SerialAPISetupCommand.GetRFRegion,
|
|
)
|
|
) {
|
|
node.RFRegion = await this._driver.controller.getRFRegion()
|
|
} else {
|
|
logger.info('RF region is not supported by controller')
|
|
}
|
|
|
|
// when RF region changes, check if long range is supported
|
|
if (
|
|
this.driver.controller.supportsLongRange !==
|
|
node.supportsLongRange
|
|
) {
|
|
node.supportsLongRange =
|
|
this.driver.controller.supportsLongRange
|
|
}
|
|
}
|
|
|
|
this.emitNodeUpdate(node, {
|
|
powerlevel: node.powerlevel,
|
|
measured0dBm: node.measured0dBm,
|
|
RFRegion: node.RFRegion,
|
|
supportsLongRange: node.supportsLongRange,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Set value metadata to the internal valueId
|
|
*
|
|
*/
|
|
private _updateValueMetadata(
|
|
zwaveNode: ZWaveNode,
|
|
zwaveValue: TranslatedValueID & { [x: string]: any },
|
|
zwaveValueMeta: ValueMetadata,
|
|
): ZUIValueId {
|
|
zwaveValue.nodeId = zwaveNode.id
|
|
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
const vID = this._getValueID(zwaveValue)
|
|
|
|
const valueId: ZUIValueId = {
|
|
...(node.values[vID] || {}), // extend existing valueId
|
|
id: this._getValueID(zwaveValue, true), // the valueId unique in the entire network, it also has the nodeId
|
|
nodeId: zwaveNode.id,
|
|
toUpdate: false,
|
|
commandClass: zwaveValue.commandClass,
|
|
commandClassName: zwaveValue.commandClassName,
|
|
endpoint: zwaveValue.endpoint,
|
|
property: zwaveValue.property,
|
|
propertyName: zwaveValue.propertyName,
|
|
propertyKey: zwaveValue.propertyKey,
|
|
propertyKeyName: zwaveValue.propertyKeyName,
|
|
type: zwaveValueMeta.type, // https://github.com/zwave-js/node-zwave-js/blob/cb35157da5e95f970447a67cbb2792e364b9d1e1/packages/core/src/values/Metadata.ts#L28
|
|
readable: zwaveValueMeta.readable,
|
|
writeable: zwaveValueMeta.writeable,
|
|
description: zwaveValueMeta.description,
|
|
label:
|
|
zwaveValueMeta.label || zwaveValue.propertyName + ' (property)', // when label is missing, re use propertyName. Useful for webinterface
|
|
default: zwaveValueMeta.default,
|
|
ccSpecific: zwaveValueMeta.ccSpecific,
|
|
stateless: zwaveValue.stateless || false, // used for notifications to specify that this should not be persisted (retained)
|
|
}
|
|
|
|
if (zwaveNode.ready) {
|
|
const endpoint = zwaveNode.getEndpoint(zwaveValue.endpoint)
|
|
|
|
valueId.commandClassVersion = (endpoint ?? zwaveNode).getCCVersion(
|
|
zwaveValue.commandClass,
|
|
)
|
|
}
|
|
|
|
// Value types: https://github.com/zwave-js/node-zwave-js/blob/cb35157da5e95f970447a67cbb2792e364b9d1e1/packages/core/src/values/Metadata.ts#L28
|
|
if (zwaveValueMeta.type === 'number') {
|
|
valueId.min = (zwaveValueMeta as ValueMetadataNumeric).min
|
|
valueId.max = (zwaveValueMeta as ValueMetadataNumeric).max
|
|
valueId.step = (zwaveValueMeta as ValueMetadataNumeric).steps
|
|
valueId.unit = (zwaveValueMeta as ValueMetadataNumeric).unit
|
|
} else if (zwaveValueMeta.type === 'string') {
|
|
valueId.minLength = (
|
|
zwaveValueMeta as ValueMetadataString
|
|
).minLength
|
|
valueId.maxLength = (
|
|
zwaveValueMeta as ValueMetadataString
|
|
).maxLength
|
|
}
|
|
|
|
if (
|
|
(zwaveValueMeta as ValueMetadataNumeric).states &&
|
|
Object.keys((zwaveValueMeta as ValueMetadataNumeric).states)
|
|
.length > 0
|
|
) {
|
|
valueId.list = true
|
|
valueId.allowManualEntry = (
|
|
zwaveValueMeta as ConfigurationMetadata
|
|
).allowManualEntry
|
|
valueId.states = []
|
|
for (const k in (zwaveValueMeta as ValueMetadataNumeric).states) {
|
|
valueId.states.push({
|
|
text: (zwaveValueMeta as ValueMetadataNumeric).states[k],
|
|
value:
|
|
zwaveValueMeta.type === 'number'
|
|
? parseInt(k)
|
|
: zwaveValueMeta.type === 'boolean'
|
|
? k === 'true'
|
|
: k,
|
|
})
|
|
}
|
|
} else {
|
|
valueId.list = false
|
|
}
|
|
|
|
return valueId
|
|
}
|
|
|
|
/**
|
|
* Add a node value to our node values
|
|
*
|
|
*/
|
|
private _addValue(
|
|
zwaveNode: ZWaveNode,
|
|
zwaveValue: TranslatedValueID,
|
|
oldValues?: {
|
|
[key: string]: ZUIValueId
|
|
},
|
|
skipUpdate = false,
|
|
) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
|
|
if (!node) {
|
|
logger.info(`ValueAdded: no such node: ${zwaveNode.id} error`)
|
|
} else {
|
|
if (
|
|
zwaveValue.commandClass ===
|
|
CommandClasses['Node Naming and Location']
|
|
) {
|
|
this.onNodeNameLocationChanged(
|
|
node,
|
|
zwaveValue as ZUIValueId,
|
|
zwaveNode.getValue(zwaveValue),
|
|
)
|
|
|
|
return null
|
|
}
|
|
|
|
const zwaveValueMeta = zwaveNode.getValueMetadata(zwaveValue)
|
|
|
|
const valueId = this._parseValue(
|
|
zwaveNode,
|
|
zwaveValue,
|
|
zwaveValueMeta,
|
|
)
|
|
|
|
const vID = this._getValueID(valueId)
|
|
|
|
// a valueId is updated when it doesn't exist or its value is updated
|
|
const updated =
|
|
!oldValues ||
|
|
!oldValues[vID] ||
|
|
oldValues[vID].value !== valueId.value
|
|
|
|
this.logNode(
|
|
zwaveNode,
|
|
'info',
|
|
`Value added ${valueId.id} => ${valueId.value}`,
|
|
)
|
|
|
|
if (!skipUpdate && updated) {
|
|
this.emitValueChanged(valueId, node, true)
|
|
}
|
|
|
|
return {
|
|
updated,
|
|
valueId,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Parse a zwave value into a valueID
|
|
*
|
|
*/
|
|
private _parseValue(
|
|
zwaveNode: ZWaveNode,
|
|
zwaveValue: TranslatedValueID & { [x: string]: any },
|
|
zwaveValueMeta: ValueMetadata,
|
|
) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
const valueId = this._updateValueMetadata(
|
|
zwaveNode,
|
|
zwaveValue,
|
|
zwaveValueMeta,
|
|
)
|
|
|
|
const vID = this._getValueID(valueId)
|
|
|
|
valueId.value = zwaveNode.getValue(zwaveValue)
|
|
|
|
if (valueId.value === undefined) {
|
|
const prevValue = node.values[vID]
|
|
? node.values[vID].value
|
|
: undefined
|
|
valueId.value =
|
|
zwaveValue.newValue !== undefined
|
|
? zwaveValue.newValue
|
|
: prevValue
|
|
}
|
|
|
|
// ensure duration is never undefined
|
|
if (valueId.type === 'duration' && valueId.value === undefined) {
|
|
valueId.value = new Duration(undefined, 'seconds')
|
|
}
|
|
|
|
if (this._isCurrentValue(valueId)) {
|
|
valueId.isCurrentValue = true
|
|
const targetValue = this._findTargetValue(
|
|
valueId,
|
|
zwaveNode.getDefinedValueIDs(),
|
|
)
|
|
if (targetValue) {
|
|
valueId.targetValue = this._getValueID(targetValue)
|
|
}
|
|
}
|
|
|
|
node.values[vID] = valueId
|
|
|
|
return valueId
|
|
}
|
|
|
|
/**
|
|
* Triggered when a node is ready and a value changes
|
|
*
|
|
*/
|
|
private _updateValue(
|
|
zwaveNode: ZWaveNode,
|
|
args: TranslatedValueID & { [x: string]: any },
|
|
) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
|
|
if (!node) {
|
|
logger.info(`valueChanged: no such node: ${zwaveNode.id} error`)
|
|
} else {
|
|
let skipUpdate = false
|
|
|
|
const vID = this._getValueID(args as unknown as ZUIValueId)
|
|
|
|
// notifications events are not defined as values, manually create them once we get the first update
|
|
if (!node.values[vID]) {
|
|
this._addValue(zwaveNode, args)
|
|
// addValue call already trigger valueChanged event
|
|
skipUpdate = true
|
|
}
|
|
|
|
const valueId = node.values[vID]
|
|
|
|
if (!valueId) {
|
|
// node name and location emit a value update but
|
|
// there could be no defined valueId as not all nodes
|
|
// support that CC but zwave-js does, also we ignore it
|
|
// on `_addvalue`. Ref: (https://github.com/zwave-js/zwave-js-ui/issues/3591)
|
|
if (
|
|
args.commandClass ===
|
|
CommandClasses['Node Naming and Location']
|
|
) {
|
|
this.onNodeNameLocationChanged(
|
|
node,
|
|
args as ZUIValueId,
|
|
args.newValue,
|
|
)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// this is set when the updates comes from a write request
|
|
if (valueId.toUpdate) {
|
|
valueId.toUpdate = false
|
|
}
|
|
|
|
let newValue = args.newValue
|
|
if (isUint8Array(newValue)) {
|
|
// encode Buffers as HEX strings
|
|
newValue = utils.buffer2hex(newValue)
|
|
}
|
|
|
|
let prevValue = args.prevValue
|
|
if (isUint8Array(prevValue)) {
|
|
// encode Buffers as HEX strings
|
|
prevValue = utils.buffer2hex(prevValue)
|
|
}
|
|
|
|
valueId.value = newValue
|
|
valueId.stateless = !!args.stateless
|
|
|
|
if (this.valuesObservers[valueId.id]) {
|
|
this.valuesObservers[valueId.id].call(this, node, valueId)
|
|
}
|
|
|
|
// ensure duration is never undefined
|
|
if (valueId.type === 'duration' && valueId.value === undefined) {
|
|
valueId.value = new Duration(undefined, 'seconds')
|
|
}
|
|
|
|
if (!skipUpdate) {
|
|
this.emitValueChanged(valueId, node, prevValue !== newValue)
|
|
}
|
|
|
|
// if valueId is stateless, automatically reset the value after 1 sec
|
|
if (valueId.stateless) {
|
|
if (this.statelessTimeouts[valueId.id]) {
|
|
clearTimeout(this.statelessTimeouts[valueId.id])
|
|
}
|
|
|
|
this.statelessTimeouts[valueId.id] = setTimeout(() => {
|
|
valueId.value = undefined
|
|
this.emitValueChanged(valueId, node, false)
|
|
}, 1000)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a value from internal node values
|
|
*
|
|
*/
|
|
private _removeValue(
|
|
zwaveNode: ZWaveNode,
|
|
args: ZWaveNodeValueRemovedArgs,
|
|
) {
|
|
const node = this._nodes.get(zwaveNode.id)
|
|
const vID = this._getValueID(args)
|
|
const toRemove = node ? node.values[vID] : null
|
|
|
|
if (toRemove) {
|
|
delete node.values[vID]
|
|
this.sendToSocket(socketEvents.valueRemoved, toRemove)
|
|
this.logNode(zwaveNode, 'info', `ValueId ${vID} removed`)
|
|
} else {
|
|
this.logNode(
|
|
zwaveNode,
|
|
'warn',
|
|
`ValueId ${vID} removed: no such node`,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ------- Utils ------------------------
|
|
|
|
private _parseNotification(parameters) {
|
|
if (isUint8Array(parameters)) {
|
|
return Buffer.from(parameters.buffer).toString('hex')
|
|
} else if (parameters instanceof Duration) {
|
|
return parameters.toMilliseconds()
|
|
} else {
|
|
return parameters
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the device id of a specific node
|
|
*
|
|
*/
|
|
private _getDeviceID(node: ZUINode): string {
|
|
if (!node) return ''
|
|
|
|
return `${node.manufacturerId}-${node.productId}-${node.productType}`
|
|
}
|
|
|
|
/**
|
|
* Check if a valueID is a current value
|
|
*/
|
|
private _isCurrentValue(valueId: TranslatedValueID | ZUIValueId) {
|
|
return valueId.propertyName && /current/i.test(valueId.propertyName)
|
|
}
|
|
|
|
/**
|
|
* Find the target valueId of a current valueId
|
|
*/
|
|
private _findTargetValue(
|
|
zwaveValue: TranslatedValueID,
|
|
definedValueIds: TranslatedValueID[],
|
|
) {
|
|
return definedValueIds.find(
|
|
(v) =>
|
|
v.commandClass === zwaveValue.commandClass &&
|
|
v.endpoint === zwaveValue.endpoint &&
|
|
v.propertyKey === zwaveValue.propertyKey &&
|
|
/target/i.test(v.property.toString()),
|
|
)
|
|
}
|
|
|
|
private zwaveNodeToJSON(
|
|
node: ZWaveNode,
|
|
): Partial<
|
|
ZWaveNode &
|
|
Pick<
|
|
ZUINode,
|
|
| 'inited'
|
|
| 'manufacturer'
|
|
| 'productDescription'
|
|
| 'productLabel'
|
|
| 'supportsLongRange'
|
|
>
|
|
> {
|
|
const zuiNode = this.nodes.get(node.id)
|
|
|
|
return {
|
|
id: node.id,
|
|
inited: zuiNode?.inited,
|
|
name: node.name,
|
|
location: node.location,
|
|
status: node.status,
|
|
isControllerNode: node.isControllerNode,
|
|
interviewStage: node.interviewStage,
|
|
deviceClass: node.deviceClass,
|
|
zwavePlusVersion: node.zwavePlusVersion,
|
|
ready: node.ready,
|
|
zwavePlusRoleType: node.zwavePlusRoleType,
|
|
isListening: node.isListening,
|
|
isFrequentListening: node.isFrequentListening,
|
|
canSleep: node.canSleep,
|
|
isRouting: node.isRouting,
|
|
supportedDataRates: node.supportedDataRates,
|
|
maxDataRate: node.maxDataRate,
|
|
supportsSecurity: node.supportsSecurity,
|
|
isSecure: node.isSecure,
|
|
supportsBeaming: node.supportsBeaming,
|
|
protocolVersion: node.protocolVersion,
|
|
sdkVersion: node.sdkVersion,
|
|
firmwareVersion: node.firmwareVersion,
|
|
manufacturerId: node.manufacturerId,
|
|
manufacturer: zuiNode?.manufacturer,
|
|
productId: node.productId,
|
|
productDescription: zuiNode?.productDescription,
|
|
productType: node.productType,
|
|
productLabel: zuiNode?.productLabel,
|
|
deviceDatabaseUrl: node.deviceDatabaseUrl,
|
|
keepAwake: node.keepAwake,
|
|
protocol: node.protocol,
|
|
supportsLongRange: zuiNode?.supportsLongRange,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a valueId from a valueId object
|
|
*/
|
|
private _getValueID(v: Partial<ZUIValueId>, withNode = false) {
|
|
return `${withNode ? v.nodeId + '-' : ''}${v.commandClass}-${
|
|
v.endpoint || 0
|
|
}-${v.property}${
|
|
v.propertyKey !== undefined ? '-' + v.propertyKey : ''
|
|
}`
|
|
}
|
|
|
|
/**
|
|
* Internal function to check for config updates automatically once a day
|
|
*
|
|
*/
|
|
private async _scheduledConfigCheck() {
|
|
try {
|
|
await this.checkForConfigUpdates()
|
|
} catch (error) {
|
|
logger.warn(`Scheduled update check has failed: ${error.message}`)
|
|
}
|
|
|
|
const nextUpdate = new Date()
|
|
nextUpdate.setHours(24, 0, 0, 0) // next midnight
|
|
|
|
const waitMillis = nextUpdate.getTime() - Date.now()
|
|
|
|
logger.info(`Next update scheduled for: ${nextUpdate}`)
|
|
|
|
this.updatesCheckTimeout = setTimeout(
|
|
this._scheduledConfigCheck.bind(this),
|
|
waitMillis > 0 ? waitMillis : 1000,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Try to poll a value, don't throw. Used in the setTimeout
|
|
*
|
|
*/
|
|
private async _tryPoll(valueId: ZUIValueId, interval: number) {
|
|
try {
|
|
await this.pollValue(valueId)
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error while polling value ${this._getValueID(
|
|
valueId,
|
|
true,
|
|
)}: ${error.message}`,
|
|
)
|
|
}
|
|
|
|
this.setPollInterval(valueId, interval)
|
|
}
|
|
|
|
/** Loads fake nodes exported from UI */
|
|
private async loadFakeNodes() {
|
|
const filePath = utils.joinPath(true, 'fakeNodes.json')
|
|
// load fake nodes from `fakeNodes.json` for testing
|
|
if (await exists(filePath)) {
|
|
const fakeNodes = JSON.parse(await readFile(filePath, 'utf-8'))
|
|
for (const node of fakeNodes) {
|
|
// convert valueIds array to map
|
|
const values = {}
|
|
for (const value of node.values) {
|
|
values[this._getValueID(value)] = value
|
|
}
|
|
node.values = values
|
|
this._nodes.set(node.id, node)
|
|
this.emitNodeUpdate(node)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Used for testing purposes */
|
|
private emulateFwUpdate(
|
|
nodeId: number,
|
|
totalFiles = 3,
|
|
fragmentsPerFile = 100,
|
|
) {
|
|
const interval = setInterval(() => {
|
|
const totalFilesFragments = totalFiles * fragmentsPerFile
|
|
const progress = this.nodes.get(nodeId)?.firmwareUpdate || {
|
|
totalFiles,
|
|
currentFile: 1,
|
|
sentFragments: 0,
|
|
totalFragments: fragmentsPerFile,
|
|
progress: 0,
|
|
}
|
|
|
|
// random increment from 0 to 5
|
|
progress.sentFragments += Math.round(Math.random() * 5)
|
|
if (progress.sentFragments >= progress.totalFragments) {
|
|
progress.currentFile += 1
|
|
progress.sentFragments = 0
|
|
}
|
|
|
|
if (progress.currentFile > totalFiles) {
|
|
let api: 'firmwareUpdateOTW' | 'firmwareUpdateOTA'
|
|
if (this.nodes.get(nodeId).isControllerNode) {
|
|
api = 'firmwareUpdateOTW'
|
|
this._onControllerFirmwareUpdateFinished({
|
|
status: ControllerFirmwareUpdateStatus.OK,
|
|
success: true,
|
|
})
|
|
} else {
|
|
api = 'firmwareUpdateOTA'
|
|
this._onNodeFirmwareUpdateFinished(
|
|
this.driver.controller.nodes.get(nodeId),
|
|
{
|
|
reInterview: false,
|
|
status: FirmwareUpdateStatus.OK_NoRestart,
|
|
success: true,
|
|
waitTime: 1000,
|
|
},
|
|
)
|
|
}
|
|
|
|
const result = {
|
|
success: true,
|
|
message: 'Firmware update finished',
|
|
result: true,
|
|
api,
|
|
args: [],
|
|
}
|
|
|
|
this.socket.emit(socketEvents.api, result)
|
|
|
|
clearInterval(interval)
|
|
return
|
|
}
|
|
|
|
progress.progress = Math.round(
|
|
(100 *
|
|
(fragmentsPerFile * (progress.currentFile - 1) +
|
|
progress.sentFragments)) /
|
|
totalFilesFragments,
|
|
)
|
|
|
|
if (this.nodes.get(nodeId)?.isControllerNode) {
|
|
// emulate a ping to another node
|
|
Array.from(this.driver.controller.nodes.entries())[1][1]
|
|
.ping()
|
|
.catch(() => {
|
|
//noop
|
|
})
|
|
this._onControllerFirmwareUpdateProgress({
|
|
sentFragments: progress.sentFragments,
|
|
totalFragments: progress.totalFragments,
|
|
progress: progress.progress,
|
|
})
|
|
} else {
|
|
// emulate a ping to node
|
|
this.driver.controller.nodes
|
|
.get(nodeId)
|
|
.ping()
|
|
.catch(() => {
|
|
//noop
|
|
})
|
|
this._onNodeFirmwareUpdateProgress(
|
|
this.driver.controller.nodes.get(nodeId),
|
|
progress,
|
|
)
|
|
}
|
|
}, 1000)
|
|
}
|
|
}
|
|
|
|
export default ZwaveClient
|