import type {
	AllowedValue,
	ConfigurationMetadata,
	Firmware,
	Route,
	SupervisionResult,
	ValueMetadataNumeric,
	ValueMetadataString,
	ZWaveDataRate,
	FirmwareFileFormat,
} from '@zwave-js/core'
import {
	CommandClasses,
	dskToString,
	Duration,
	isUnsupervisedOrSucceeded,
	NODE_ID_BROADCAST,
	NODE_ID_BROADCAST_LR,
	RouteKind,
	SecurityClass,
	supervisedCommandFailed,
	SupervisionStatus,
	ZWaveErrorCodes,
	Protocols,
	tryUnzipFirmwareFile,
	extractFirmware,
} from '@zwave-js/core'
import { createDefaultTransportFormat } from '@zwave-js/core/bindings/log/node'
import { applyExternalDriverSettings } from './externalSettings.ts'
import { JSONTransport } from '@zwave-js/log-transport-json'
import type {
	AssociationAddress,
	AssociationGroup,
	OTWFirmwareUpdateProgress,
	OTWFirmwareUpdateResult,
	ControllerStatistics,
	DataRate,
	ExclusionOptions,
	FirmwareUpdateCapabilities,
	FirmwareUpdateProgress,
	FirmwareUpdateResult,
	FLiRS,
	FoundNode,
	GetFirmwareUpdatesOptions,
	RebuildRoutesOptions,
	RebuildRoutesStatus,
	InclusionGrant,
	InclusionOptions,
	InclusionResult,
	LifelineHealthCheckResult,
	LifelineHealthCheckSummary,
	NodeInterviewFailedEventArgs,
	NodeStatistics,
	NodeType,
	PlannedProvisioningEntry,
	ProtocolVersion,
	QRProvisioningInformation,
	RefreshInfoOptions,
	ReplaceNodeOptions,
	RouteHealthCheckResult,
	RouteHealthCheckSummary,
	ScheduleEntryLockDailyRepeatingSchedule,
	ScheduleEntryLockSlotId,
	ScheduleEntryLockWeekDaySchedule,
	ScheduleEntryLockYearDaySchedule,
	SetValueAPIOptions,
	SetValueResult,
	SmartStartProvisioningEntry,
	TranslatedValueID,
	ValueID,
	ValueMetadata,
	ValueType,
	ZWaveError,
	ZWaveNode,
	ZWaveNodeEvents,
	ZWaveNodeFirmwareUpdateFinishedCallback,
	ZWaveNodeFirmwareUpdateProgressCallback,
	InterviewProgress,
	ZWaveNodeMetadataUpdatedArgs,
	ZWaveNodeValueAddedArgs,
	ZWaveNodeValueNotificationArgs,
	ZWaveNodeValueRemovedArgs,
	ZWaveNodeValueUpdatedArgs,
	ZWaveNotificationCallback,
	ZWaveOptions,
	ZWavePlusNodeType,
	ZWavePlusRoleType,
	FirmwareUpdateInfo,
	PartialZWaveOptions,
	InclusionUserCallbacks,
	InclusionState,
	LinkReliabilityCheckResult,
	JoinNetworkOptions,
	JoinNetworkResult,
	VirtualNode,
	VirtualValueID,
} from 'zwave-js'
import {
	OTWFirmwareUpdateStatus,
	ControllerStatus,
	Driver,
	ExclusionStrategy,
	FirmwareUpdateStatus,
	guessFirmwareFileFormat,
	InclusionStrategy,
	InterviewStage,
	libVersion,
	MultilevelSwitchCommand,
	NodeStatus,
	QRCodeVersion,
	RemoveNodeReason,
	RFRegion,
	ScheduleEntryLockCC,
	ScheduleEntryLockScheduleKind,
	SerialAPISetupCommand,
	setValueFailed,
	SetValueStatus,
	setValueWasUnsupervisedOrSucceeded,
	UserCodeCC,
	UserIDStatus,
	ProvisioningEntryStatus,
	AssociationCheckResult,
	JoinNetworkStrategy,
	DriverMode,
	BatteryReplacementStatus,
	SetUserResult,
	SetCredentialResult,
	UserCredentialType,
	UserCredentialAdminCodeOperationResult,
} from 'zwave-js'
import type {
	UserCapabilities,
	CredentialCapabilities,
	UserData,
	CredentialData,
	SetUserOptions,
	DeleteCredentialsOptions,
	UserCredentialUserType,
	UserCredentialRule,
	AssignCredentialResult,
} from 'zwave-js'
import { getEnumMemberName, parseQRCodeString } from 'zwave-js/Utils'
import { configDbDir, logsDir, nvmBackupsDir, storeDir } from '../config/app.ts'
import type { Group } from '../config/store.ts'
import store from '../config/store.ts'
import jsonStore from './jsonStore.ts'
import * as LogManager from './logger.ts'
import * as utils from './utils.ts'

import { serverVersion, ZwavejsServer } from '@zwave-js/server'
import type { Server as SocketServer } from 'socket.io'
import { TypedEventEmitter } from './EventEmitter.ts'
import type { GatewayValue } from './Gateway.ts'

import type { DeviceConfig } from '@zwave-js/config'
import { ConfigManager } from '@zwave-js/config'
import { createHash } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import backupManager, { NVM_BACKUP_PREFIX } from './BackupManager.ts'
import { eventToChannel, socketEvents } from './SocketEvents.ts'
import { isUint8Array } from 'node:util/types'
import {
	coerce as semverCoerce,
	gte as semverGte,
	lte as semverLte,
} from 'semver'
import { PkgFsBindings } from './PkgFsBindings.ts'
import { regionSupportsAutoPowerlevel } from './shared.ts'
import { deviceConfigPriorityDir } from './Constants.ts'
import { createRequire } from 'node:module'

export const configManager = new ConfigManager({
	deviceConfigPriorityDir,
})

const logger = LogManager.module('Z-Wave')

const NEIGHBORS_LOCK_REFRESH = 60 * 1000

/** Maximum length of a multicast group name (avoids bloating MQTT topics). */
const GROUP_NAME_MAX_LENGTH = 64

/**
 * Lower bound for auto-assigned multicast-group virtual-node IDs. Chosen above
 * the LR address space so they never collide with physical or broadcast nodes.
 */
const GROUP_ID_MIN = 0x1000
// Ordered from least verbose to most verbose (matching winston/npm log levels)
const LOG_LEVEL_ORDER = ['error', 'warn', 'info', 'verbose', 'debug', 'silly']

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',
	'_createGroup',
	'_updateGroup',
	'_deleteGroup',
	'_getGroups',
	'refreshNeighbors',
	'getNodeNeighbors',
	'discoverNodeNeighbors',
	'getAssociations',
	'checkAssociation',
	'addAssociations',
	'removeAssociations',
	'removeAllAssociations',
	'removeNodeFromAllAssociations',
	'getNodes',
	'getInfo',
	'refreshValues',
	'refreshCCValues',
	'pollValue',
	'setPowerlevel',
	'setRFRegion',
	'setMaxLRPowerLevel',
	'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',
	'getAllAvailableFirmwareUpdates',
	'checkAllNodesFirmwareUpdates',
	'dismissFirmwareUpdate',
	'getNodeFirmwareUpdates',
	'firmwareUpdateOTA',
	'sendCommand',
	'writeValue',
	'writeBroadcast',
	'writeMulticast',
	'driverFunction',
	'checkForConfigUpdates',
	'installConfigUpdate',
	'shutdownZwaveAPI',
	'startLearnMode',
	'stopLearnMode',
	'pingNode',
	'restart',
	'grantSecurityClasses',
	'validateDSK',
	'abortInclusion',
	'backupNVMRaw',
	'restoreNVM',
	'getProvisioningEntries',
	'getProvisioningEntry',
	'unprovisionSmartStartNode',
	'provisionSmartStartNode',
	'parseQRCodeString',
	'checkLifelineHealth',
	'abortHealthCheck',
	'checkRouteHealth',
	'checkLinkReliability',
	'abortLinkReliabilityCheck',
	'syncNodeDateAndTime',
	'manuallyIdleNotificationValue',
	'getSchedules',
	'cancelGetSchedule',
	'setSchedule',
	'setEnabledSchedule',
	'getConfigurationTemplates',
	'createConfigurationTemplate',
	'updateConfigurationTemplate',
	'deleteConfigurationTemplate',
	'applyConfigurationTemplate',
	'importConfigurationTemplates',
	'getDeviceConfigurationParams',
	'accessControlGetState',
	'accessControlRefreshUsers',
	'accessControlRefreshCredentials',
	'accessControlSetUser',
	'accessControlAddUser',
	'accessControlDeleteUser',
	'accessControlDeleteAllUsers',
	'accessControlSetCredential',
	'accessControlDeleteCredential',
	'accessControlDeleteCredentials',
	'accessControlAssignCredential',
	'accessControlStartCredentialLearn',
	'accessControlCancelCredentialLearn',
	'accessControlGetAdminCode',
	'accessControlSetAdminCode',
] 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]

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
	allowed?: readonly AllowedValue[]
	unit?: string
	minLength?: number
	maxLength?: number
	states?: ZUIValueIdState[]
	list?: boolean
	lastUpdate?: number
	value?: any
	targetValue?: string
	isCurrentValue?: boolean
	conf?: GatewayValue
	allowManualEntry?: boolean
	destructive?: boolean
	commandClassVersion?: number
} & TranslatedValueID

export type ZUIValueIdScene = ZUIValueId & {
	timeout: number
}

export type ZUIScene = {
	sceneid: number
	label: string
	values: ZUIValueIdScene[]
}

export type ZUIConfigurationTemplateValue = {
	property: number
	propertyKey?: number | null
	endpoint: number
	value: unknown
	label?: string
	description?: string
}

export type ZUIConfigurationTemplate = {
	id: string
	name: string
	deviceId: string
	manufacturerId?: number
	productId?: number
	productType?: number
	manufacturer?: string
	productLabel?: string
	firmwareRange?: { min?: string; max?: string }
	values: ZUIConfigurationTemplateValue[]
	autoApply: boolean
	contentHash: string
	createdAt: string
	updatedAt: string
}

export type ZUIDeviceClass = {
	basic: number
	generic: number
	specific: number
}

export type ZUINodeGroups = {
	title: 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: Uint8Array<ArrayBuffer>
	target?: number
}

export interface ZUIEndpoint {
	index: number
	label?: string
	deviceClass: {
		basic: number
		generic: number
		specific: number
	}
}

export const ZUIScheduleEntryLockMode = {
	DAILY: 'daily',
	WEEKLY: 'weekly',
	YEARLY: 'yearly',
} as const
export type ZUIScheduleEntryLockMode =
	(typeof ZUIScheduleEntryLockMode)[keyof typeof ZUIScheduleEntryLockMode]

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 ZUIAccessControlUser = UserData

export type ZUIAccessControlCredential = {
	userId: number
	type: UserCredentialType
	slot: number
	/** Hex-encoded bytes for binary credentials, raw string for PIN/password */
	data?: string
	/** True when data was a Uint8Array (binary credential) */
	isBinary?: boolean
}

export type ZUIAccessControlCapabilityType = {
	type: UserCredentialType
	numberOfCredentialSlots: number
	minCredentialLength: number
	maxCredentialLength: number
	maxCredentialHashLength: number
	supportsCredentialLearn: boolean
	credentialLearnRecommendedTimeout?: number
	credentialLearnNumberOfSteps?: number
}

export type ZUIAccessControlCapabilities = {
	maxUsers: number
	supportedUserTypes: UserCredentialUserType[]
	maxUserNameLength?: number
	supportedCredentialRules: UserCredentialRule[]
	supportedCredentialTypes: ZUIAccessControlCapabilityType[]
	supportsAdminCode: boolean
	supportsAdminCodeDeactivation: boolean
	supportsCredentialAssignment: boolean
	/** Legacy User Code CC — creating a user requires at least one credential */
	requiresCredentialAtUserCreation: boolean
}

export type ZUIAccessControlEndpoint = {
	endpointIndex: number
	capabilities: ZUIAccessControlCapabilities
	users: ZUIAccessControlUser[]
	credentials: ZUIAccessControlCredential[]
	/** Whether an admin code is configured, without exposing its value */
	adminCodeSet?: boolean
}

export type ZUIAccessControl = {
	supported: boolean
	endpoints: ZUIAccessControlEndpoint[]
	/** Index of the primary endpoint to render — root if it supports, else first that does */
	primaryEndpoint?: number
}

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
	maxLongRangePowerlevel?: number
	RFRegion?: RFRegion
	rfRegions?: { title: 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
	appliedTemplateContentHashes?: string[]
	hexId?: string
	values?: { [key: string]: ZUIValueId }
	groups?: ZUINodeGroups[]
	ready: boolean
	available: boolean
	failed: boolean
	lastActive?: number
	lastAwake?: number
	dbLink?: string
	maxDataRate?: DataRate
	interviewStage?: keyof typeof InterviewStage
	interviewProgress?: number
	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[]
	}
	accessControl?: ZUIAccessControl
	defaultTransitionDuration?: string
	defaultVolume?: number
	protocol?: Protocols
	supportsLongRange?: boolean
	virtual?: boolean
	/**
	 * Virtual-node discriminator. Undefined for physical nodes; set when
	 * `virtual === true` to identify the kind of virtual node so the UI can
	 * branch on this instead of magic-numeric-ID checks.
	 */
	kind?: 'physical' | 'broadcast' | 'broadcastLR' | 'multicast'
	availableFirmwareUpdates?: FirmwareUpdateInfo[]
	firmwareUpdatesDismissed?: { [version: string]: boolean }
	lastFirmwareUpdateCheck?: number
}

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
	disableOptimisticValueUpdate?: boolean
	disclaimerVersion?: number
	options?: ZWaveOptions
	// healNetwork?: boolean
	healHour?: number
	logToFile?: boolean
	nodeFilter?: string[]
	scales?: SensorTypeScale[]
	serverServiceDiscoveryDisabled?: boolean
	maxNodeEventsQueueSize?: number
	higherReportsTimeout?: boolean
	disableControllerRecovery?: boolean
	disableAutomaticFirmwareUpdateChecks?: boolean
	rf?: {
		region?: RFRegion
		maxLongRangePowerlevel?: number | 'auto'
		autoPowerlevels?: boolean
		txPower?: {
			powerlevel: number | 'auto'
			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 const ZwaveClientStatus = {
	CONNECTED: 'connected',
	BOOTLOADER_READY: 'bootloader ready',
	DRIVER_READY: 'driver ready',
	SCAN_DONE: 'scan done',
	DRIVER_FAILED: 'driver failed',
	CLOSED: 'closed',
} as const
export type ZwaveClientStatus =
	(typeof ZwaveClientStatus)[keyof typeof ZwaveClientStatus]

export const EventSource = {
	DRIVER: 'driver',
	CONTROLLER: 'controller',
	NODE: 'node',
} as const
export type EventSource = (typeof EventSource)[keyof typeof EventSource]

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 groups: Group[]
	private _configTemplates: ZUIConfigurationTemplate[]
	private _nodes: Map<number, ZUINode>
	/**
	 * Holds the live driver-side virtual-node instances (multicast groups +
	 * standard/LR broadcast). Keyed by virtual nodeId.
	 */
	private _virtualNodes: Map<number, VirtualNode>
	/**
	 * Index of physical nodeId → groupIds containing it. Rebuilt on group
	 * create/update/delete so per-value-change lookups are O(1) instead of
	 * O(groups).
	 */
	private _nodeToGroups: Map<number, Set<number>>
	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
	// node ids surfaced to the UI via `node found` that have not yet hit
	// `node added` — if inclusion fails before the interview completes
	// they're orphans stuck at ProtocolInfo and must be cleared (see #4639)
	private _pendingInclusionNodeIds: Set<number> = new Set()

	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 firmwareUpdateCheckTimeout: 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[] = []

	private _extraLogTransports: Array<{ transport: any; level?: string }> = []

	// 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

	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.groups = jsonStore.get(store.groups)
		this._configTemplates = jsonStore.get(store.configurationTemplates)

		this._nodes = new Map()
		this._virtualNodes = new Map()
		this._nodeToGroups = new Map()
		this._rebuildNodeToGroupsIndex()

		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()
	}

	/**
	 * Register an extra log transport that persists across driver restarts.
	 * If the driver is already running, the transport is applied immediately.
	 */
	addExtraLogTransport(transport: any, level?: string): void {
		this._extraLogTransports.push({ transport, level })
		if (this._driver && this._driverReady) {
			const config: any = {
				transports: [transport],
			}
			if (level) {
				config.level = level
			}
			this._driver.updateLogConfig(config)
		}
	}

	/**
	 * Remove a previously registered extra log transport.
	 * If the driver is running, the transport is detached immediately.
	 */
	removeExtraLogTransport(transport: any): void {
		const idx = this._extraLogTransports.findIndex(
			(e) => e.transport === transport,
		)
		if (idx !== -1) {
			this._extraLogTransports.splice(idx, 1)
		}
		if (this._driver && this._driverReady) {
			this._driver.updateLogConfig({
				transports: [],
			})
		}
	}

	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) {
							oldEntry.lastUpdate = Date.now()
							// clear the timeout so later calls can schedule a
							// new trailing emit
							oldEntry.timeout = null
							// run the most recently queued function, not the one
							// captured when this timeout was scheduled, so the
							// final value of a burst is never dropped
							oldEntry.fn()
						}
					},
					entry.lastUpdate + wait - now,
				)
			}
			// discard the old function and store the new one
			entry.fn = fn
		}
	}

	private clearThrottle(key: string) {
		const entry = this.throttledFunctions.get(key)
		if (entry) {
			if (entry.timeout) {
				clearTimeout(entry.timeout)
			}
			this.throttledFunctions.delete(key)
		}
	}

	/**
	 * Check if a nodeId belongs to a virtual node (broadcast or multicast group)
	 */
	isVirtualNode(nodeId: number): boolean {
		return this._virtualNodes.has(nodeId)
	}

	/**
	 * Returns the driver ZWaveNode object for physical nodes
	 */
	getNode(nodeId: number): ZWaveNode {
		return this._driver.controller.nodes.get(nodeId)
	}

	/**
	 * Returns the virtual node instance (multicast group or broadcast node)
	 */
	getVirtualNode(nodeId: number): VirtualNode | null {
		return this._virtualNodes.get(nodeId) ?? null
	}

	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.firmwareUpdateCheckTimeout) {
			clearTimeout(this.firmwareUpdateCheckTimeout)
			this.firmwareUpdateCheckTimeout = 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
		}

		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,
		}
	}

	// ---------------- Access Control (User Credential CC / User Code CC) ----------------

	private _supportedAccessControlEndpointIndices(
		zwaveNode: ZWaveNode,
	): number[] {
		const out: number[] = []
		for (const ep of zwaveNode.getAllEndpoints()) {
			if (
				ep.supportsCC(CommandClasses['User Credential']) ||
				ep.supportsCC(CommandClasses['User Code'])
			) {
				out.push(ep.index)
			}
		}
		return out
	}

	private _resolveAccessControlEndpoint(
		zwaveNode: ZWaveNode,
		endpointIndex?: number,
	) {
		const indices = this._supportedAccessControlEndpointIndices(zwaveNode)
		const idx =
			endpointIndex !== undefined
				? endpointIndex
				: indices.includes(0)
					? 0
					: indices[0]
		if (idx === undefined) {
			throw new Error(
				`Node ${zwaveNode.id} does not support access control`,
			)
		}
		const ep = zwaveNode.getEndpoint(idx)
		if (!ep || !ep.accessControl) {
			throw new Error(
				`Node ${zwaveNode.id} endpoint ${idx} does not support access control`,
			)
		}
		return { endpoint: ep, accessControl: ep.accessControl, index: idx }
	}

	private _assertDirectEntryCredentialType(
		type: UserCredentialType,
	): asserts type is
		| UserCredentialType.PINCode
		| UserCredentialType.Password {
		if (
			type !== UserCredentialType.PINCode &&
			type !== UserCredentialType.Password
		) {
			throw new Error(
				'Only PIN and password credentials can be entered manually',
			)
		}
	}

	private _findAccessControlEndpointEntry(
		node: ZUINode,
		endpointIndex?: number,
	): ZUIAccessControlEndpoint | undefined {
		const targetEndpoint =
			endpointIndex ?? node.accessControl?.primaryEndpoint
		return node.accessControl?.endpoints.find(
			(endpoint) => endpoint.endpointIndex === targetEndpoint,
		)
	}

	private _encodeCredentialData(data: string | Uint8Array | undefined): {
		data?: string
		isBinary?: boolean
	} {
		if (data === undefined) return {}
		if (typeof data === 'string') return { data, isBinary: false }
		const hex = Buffer.from(data).toString('hex')
		return { data: hex, isBinary: true }
	}

	private _mapCredentialData(c: CredentialData): ZUIAccessControlCredential {
		const encoded = this._encodeCredentialData(c.data)
		return {
			userId: c.userId,
			type: c.type,
			slot: c.slot,
			...encoded,
		}
	}

	private _buildAccessControlCapabilities(
		zwaveNode: ZWaveNode,
		endpointIndex: number,
	): ZUIAccessControlCapabilities {
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		const userCaps: UserCapabilities =
			accessControl.getUserCapabilitiesCached()
		const credCaps: CredentialCapabilities =
			accessControl.getCredentialCapabilitiesCached()

		const supportedCredentialTypes: ZUIAccessControlCapabilityType[] = []
		for (const [type, c] of credCaps.supportedCredentialTypes.entries()) {
			supportedCredentialTypes.push({
				type,
				numberOfCredentialSlots: c.numberOfCredentialSlots,
				minCredentialLength: c.minCredentialLength,
				maxCredentialLength: c.maxCredentialLength,
				maxCredentialHashLength: c.maxCredentialHashLength,
				supportsCredentialLearn: c.supportsCredentialLearn,
				credentialLearnRecommendedTimeout:
					c.credentialLearnRecommendedTimeout,
				credentialLearnNumberOfSteps: c.credentialLearnNumberOfSteps,
			})
		}

		const requiresCredentialAtUserCreation =
			!userCaps.supportsUsersWithoutCredentials

		return {
			maxUsers: userCaps.maxUsers,
			supportedUserTypes: [...userCaps.supportedUserTypes],
			maxUserNameLength: userCaps.maxUserNameLength,
			supportedCredentialRules: [...userCaps.supportedCredentialRules],
			supportedCredentialTypes,
			supportsAdminCode: credCaps.supportsAdminCode,
			supportsAdminCodeDeactivation:
				credCaps.supportsAdminCodeDeactivation,
			supportsCredentialAssignment: credCaps.supportsCredentialAssignment,
			requiresCredentialAtUserCreation,
		}
	}

	/** Read access-control state for a node from cache (no network I/O). */
	private _readAccessControlState(zwaveNode: ZWaveNode): ZUIAccessControl {
		const indices = this._supportedAccessControlEndpointIndices(zwaveNode)
		if (indices.length === 0) {
			return { supported: false, endpoints: [] }
		}
		const primary = indices.includes(0) ? 0 : indices[0]
		const prev = this._nodes.get(zwaveNode.id)?.accessControl
		const endpoints: ZUIAccessControlEndpoint[] = []
		for (const idx of indices) {
			try {
				const { accessControl } = this._resolveAccessControlEndpoint(
					zwaveNode,
					idx,
				)
				const capabilities = this._buildAccessControlCapabilities(
					zwaveNode,
					idx,
				)
				const users = accessControl.getUsersCached()
				const credentials = accessControl
					.getAllCredentialsCached()
					.map((c) => this._mapCredentialData(c))
				const endpoint: ZUIAccessControlEndpoint = {
					endpointIndex: idx,
					capabilities,
					users,
					credentials,
				}
				// Carry over the previously-known admin-code state — there is no
				// cheap synchronous accessor for it, so a refresh would
				// otherwise wipe the value the UI just learned via Get/Set.
				const prevEp = prev?.endpoints.find(
					(e) => e.endpointIndex === idx,
				)
				if (prevEp && 'adminCodeSet' in prevEp) {
					endpoint.adminCodeSet = prevEp.adminCodeSet
				}
				endpoints.push(endpoint)
			} catch (e) {
				logger.warn(
					`Failed to read access control state for node ${zwaveNode.id} endpoint ${idx}: ${(e as Error).message}`,
				)
			}
		}
		return { supported: true, endpoints, primaryEndpoint: primary }
	}

	/** Re-read access control state into node and broadcast a node update. */
	private _refreshAccessControlState(zwaveNode: ZWaveNode) {
		const node = this._nodes.get(zwaveNode.id)
		if (!node) return
		const ac = this._readAccessControlState(zwaveNode)
		if (!ac.supported) {
			if (node.accessControl) {
				delete node.accessControl
				this.emitNodeUpdate(node, { accessControl: undefined })
			}
			return
		}
		node.accessControl = ac
		this.emitNodeUpdate(node, { accessControl: ac })
	}

	/** Returns the cached access-control state — call this from the UI as a primer. */
	accessControlGetState(nodeId: number): ZUIAccessControl {
		const zwaveNode = this._requireNode(nodeId)
		const state = this._readAccessControlState(zwaveNode)
		const node = this._nodes.get(nodeId)
		if (node) {
			node.accessControl = state
		}
		return state
	}

	async accessControlRefreshUsers(
		nodeId: number,
		endpointIndex?: number,
	): Promise<ZUIAccessControlUser[]> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		const users = await accessControl.getUsers()
		this._refreshAccessControlState(zwaveNode)
		return users
	}

	async accessControlRefreshCredentials(
		nodeId: number,
		endpointIndex?: number,
	): Promise<ZUIAccessControlCredential[]> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		const credentials = await accessControl.getAllCredentials()
		this._refreshAccessControlState(zwaveNode)
		return credentials.map((c) => this._mapCredentialData(c))
	}

	async accessControlSetUser(
		nodeId: number,
		endpointIndex: number | undefined,
		userId: number,
		options: SetUserOptions,
	): Promise<SetUserResult> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		return accessControl.setUser(userId, options)
	}

	async accessControlAddUser(
		nodeId: number,
		endpointIndex: number | undefined,
		userId: number,
		options: SetUserOptions,
		credential?: {
			type: UserCredentialType
			slot: number
			data: string
		},
	): Promise<{
		userResult: SetUserResult
		credentialResult?: SetCredentialResult
	}> {
		const zwaveNode = this._requireNode(nodeId)
		const { endpoint, accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		let initialCredential = credential
		if (credential) {
			this._assertDirectEntryCredentialType(credential.type)
			if (!endpoint.supportsCC(CommandClasses['User Credential'])) {
				initialCredential = { ...credential, slot: userId }
			}
		}
		const result = await accessControl.addUser(
			userId,
			options,
			initialCredential,
		)
		return {
			userResult: result.user,
			credentialResult: result.credential,
		}
	}

	async accessControlDeleteUser(
		nodeId: number,
		endpointIndex: number | undefined,
		userId: number,
	): Promise<SetUserResult> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		return accessControl.deleteUser(userId)
	}

	async accessControlDeleteAllUsers(
		nodeId: number,
		endpointIndex?: number,
	): Promise<SetUserResult> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		// zwave-js purges its cache silently here without emitting per-user
		// `user deleted` / `credential deleted` events, so push a refresh so
		// the UI catches up.
		const result = await accessControl.deleteAllUsers()
		if (result === SetUserResult.OK) {
			this._refreshAccessControlState(zwaveNode)
		}
		return result
	}

	async accessControlSetCredential(
		nodeId: number,
		endpointIndex: number | undefined,
		userId: number,
		type: UserCredentialType,
		slot: number,
		data: string,
	): Promise<SetCredentialResult> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		this._assertDirectEntryCredentialType(type)
		return accessControl.setCredential(userId, type, slot, data)
	}

	async accessControlDeleteCredential(
		nodeId: number,
		endpointIndex: number | undefined,
		userId: number,
		type: UserCredentialType,
		slot: number,
	): Promise<SetCredentialResult> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		return accessControl.deleteCredential(userId, type, slot)
	}

	async accessControlDeleteCredentials(
		nodeId: number,
		endpointIndex: number | undefined,
		options: DeleteCredentialsOptions = {},
	): Promise<SetCredentialResult> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		// U3C path emits no per-credential `credential deleted` events for the
		// wildcard delete, so push a refresh so the UI catches up.
		const result = await accessControl.deleteCredentials(options)
		if (result === SetCredentialResult.OK) {
			this._refreshAccessControlState(zwaveNode)
		}
		return result
	}

	async accessControlAssignCredential(
		nodeId: number,
		endpointIndex: number | undefined,
		type: UserCredentialType,
		slot: number,
		destinationUserId: number,
	): Promise<AssignCredentialResult> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		return accessControl.assignCredential(type, slot, destinationUserId)
	}

	async accessControlStartCredentialLearn(
		nodeId: number,
		endpointIndex: number | undefined,
		userId: number,
		type: UserCredentialType,
		slot: number,
		timeout?: number,
	): Promise<void> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		const result = await accessControl.startCredentialLearn(
			userId,
			type,
			slot,
			timeout,
		)
		// Handle supervision failure before waiting for further events that might not come
		if (supervisedCommandFailed(result)) {
			throw new Error(
				`Node ${nodeId} refused to start credential learning (${getEnumMemberName(
					SupervisionStatus,
					result.status,
				)})`,
			)
		}
	}

	async accessControlCancelCredentialLearn(
		nodeId: number,
		endpointIndex?: number,
	): Promise<void> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		const result = await accessControl.cancelCredentialLearn()
		if (supervisedCommandFailed(result)) {
			throw new Error(
				`Node ${nodeId} refused to cancel credential learning (${getEnumMemberName(
					SupervisionStatus,
					result.status,
				)})`,
			)
		}
	}

	async accessControlGetAdminCode(
		nodeId: number,
		endpointIndex?: number,
	): Promise<{ isSet: boolean }> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		const code = await accessControl.getAdminCode()
		const isSet = code !== undefined && code !== ''
		const node = this._nodes.get(nodeId)
		if (node?.accessControl) {
			const epEntry = this._findAccessControlEndpointEntry(
				node,
				endpointIndex,
			)
			if (epEntry) {
				epEntry.adminCodeSet = isSet
				this.emitNodeUpdate(node, { accessControl: node.accessControl })
			}
		}
		return { isSet }
	}

	async accessControlSetAdminCode(
		nodeId: number,
		endpointIndex: number | undefined,
		code: string,
	): Promise<{ result: UserCredentialAdminCodeOperationResult }> {
		const zwaveNode = this._requireNode(nodeId)
		const { accessControl } = this._resolveAccessControlEndpoint(
			zwaveNode,
			endpointIndex,
		)
		const supervisionResult = await accessControl.setAdminCode(code)
		const result: UserCredentialAdminCodeOperationResult =
			isUnsupervisedOrSucceeded(supervisionResult)
				? UserCredentialAdminCodeOperationResult.Modified
				: supervisionResult.status === SupervisionStatus.NoSupport
					? UserCredentialAdminCodeOperationResult.ErrorNotSupported
					: UserCredentialAdminCodeOperationResult.UnspecifiedNodeError
		if (isUnsupervisedOrSucceeded(supervisionResult)) {
			// Reflect the new state immediately so the UI doesn't have to wait
			// for a follow-up Get to round-trip the lock.
			const node = this._nodes.get(nodeId)
			if (node?.accessControl) {
				const epEntry = this._findAccessControlEndpointEntry(
					node,
					endpointIndex,
				)
				if (epEntry) {
					epEntry.adminCodeSet = code !== ''
					this.emitNodeUpdate(node, {
						accessControl: node.accessControl,
					})
				}
			}
		}
		return { result }
	}

	private _requireNode(nodeId: number): ZWaveNode {
		const zwaveNode = this.getNode(nodeId)
		if (!zwaveNode) {
			throw new Error(`Node ${nodeId} not found`)
		}
		return zwaveNode
	}

	/**
	 * 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
							? (getCached(
									ScheduleEntryLockScheduleKind.WeekDay,
									s,
								) as ScheduleEntryLockWeekDaySchedule)
							: 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
							? (getCached(
									ScheduleEntryLockScheduleKind.YearDay,
									s,
								) as ScheduleEntryLockYearDaySchedule)
							: 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
							? (getCached(
									ScheduleEntryLockScheduleKind.DailyRepeating,
									s,
								) as ScheduleEntryLockDailyRepeatingSchedule)
							: 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({
						title: 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[],
		options?: { force?: boolean },
	) {
		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[] = []
		const force = options?.force ?? false

		for (const a of associations) {
			const checkResult = this._driver.controller.checkAssociation(
				source,
				groupId,
				a,
			)

			result.push(checkResult)

			if (checkResult === AssociationCheckResult.OK || force) {
				const isForcedAdd =
					force && checkResult !== AssociationCheckResult.OK
				const logLevel = isForcedAdd ? 'warn' : 'info'
				const action = isForcedAdd ? 'Force adding' : 'Adding'
				const bypassInfo = isForcedAdd
					? ` (bypassing check: ${getEnumMemberName(AssociationCheckResult, checkResult)})`
					: ''

				this.logNode(
					zwaveNode,
					logLevel,
					`${action} Node ${a.nodeId} to Group ${groupId} of ${sourceMsg}${bypassInfo}`,
				)

				await this._driver.controller.addAssociations(
					source,
					groupId,
					[a],
					{ force },
				)
			} 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(
			async function () {},
		).constructor

		const fn = new AsyncFunction('driver', code)
		const require = createRequire(import.meta.url)

		return fn.call({ zwaveClient: this, require, logger }, this._driver)
	}

	/**
	 * Method used to start Z-Wave connection using configuration `port`
	 */
	async connect() {
		// When ZWAVE_PORT env var is set, force enable and override port
		if (process.env.ZWAVE_PORT) {
			this.cfg.enabled = true
			this.cfg.port = process.env.ZWAVE_PORT
		}

		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
		}

		let shouldUpdateSettings = false

		// extend options with hidden `options`
		const zwaveOptions: PartialZWaveOptions = {
			bootloaderMode: this.cfg.allowBootloaderOnly ? 'allow' : 'recover',
			storage: {
				cacheDir: storeDir,
				deviceConfigPriorityDir:
					this.cfg.deviceConfigPriorityDir || deviceConfigPriorityDir,
			},
			// https://zwave-js.github.io/node-zwave-js/#/api/driver?id=logconfig
			logConfig: utils.buildLogConfig(this.cfg, logsDir),
			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,
			},
			disableOptimisticValueUpdate: this.cfg.disableOptimisticValueUpdate,
		}

		// when no env is specified copy config db to store dir
		// fixes issues with pkg (and no more need to set this env on docker)
		if (!process.env.ZWAVEJS_EXTERNAL_CONFIG) {
			zwaveOptions.storage.deviceConfigExternalDir = configDbDir
		}

		if (this.cfg.rf) {
			const { region, txPower, maxLongRangePowerlevel } = this.cfg.rf

			let { autoPowerlevels } = this.cfg.rf
			zwaveOptions.rf = {}

			if (typeof region === 'number') {
				zwaveOptions.rf.region = region
			}

			if (
				autoPowerlevels === undefined &&
				typeof maxLongRangePowerlevel !== 'number' &&
				typeof txPower?.powerlevel !== 'number'
			) {
				// if autoPowerlevels is undefined and maxLongRangePowerlevel is not a number (likely '' or undefined), assume autoPowerlevels is true
				autoPowerlevels = true
				this.cfg.rf.autoPowerlevels = true
				shouldUpdateSettings = true
			}

			if (autoPowerlevels) {
				zwaveOptions.rf.maxLongRangePowerlevel = 'auto'
				zwaveOptions.rf.txPower ??= {}
				zwaveOptions.rf.txPower.powerlevel = 'auto'
			}

			if (
				!autoPowerlevels &&
				(maxLongRangePowerlevel === 'auto' ||
					typeof maxLongRangePowerlevel === 'number')
			) {
				zwaveOptions.rf.maxLongRangePowerlevel = maxLongRangePowerlevel
			}

			if (txPower) {
				if (
					!autoPowerlevels &&
					(txPower.powerlevel === 'auto' ||
						typeof txPower.powerlevel === 'number')
				) {
					zwaveOptions.rf.txPower ??= {}
					zwaveOptions.rf.txPower.powerlevel = txPower.powerlevel
				}

				if (typeof txPower.measured0dBm === 'number') {
					zwaveOptions.rf.txPower ??= {}
					zwaveOptions.rf.txPower.measured0dBm = txPower.measured0dBm
				}
			}
		}

		// @ts-expect-error this is defined when running in a pkg bundle
		if (process.pkg) {
			// Ensure Z-Wave JS is looking for the configuration files in the right place
			// when running inside a pkg bundle
			zwaveOptions.host ??= {}
			zwaveOptions.host.fs = new PkgFsBindings()
		}

		// ensure deviceConfigPriorityDir exists to prevent warnings #2374
		// lgtm [js/path-injection]
		await utils.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 preferences = utils.buildPreferences(this.cfg)
			if (preferences) {
				zwaveOptions.preferences = preferences
			}
		}

		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
			shouldUpdateSettings = true
		}

		utils.parseSecurityKeys(this.cfg, zwaveOptions)

		// Apply driver-only external settings (storage, presets, logFilename, forceConsole).
		// These are not in ZwaveConfig/settings.json, so they must be applied directly to driver options.
		applyExternalDriverSettings(zwaveOptions)

		const logTransport = new JSONTransport()
		logTransport.format = createDefaultTransportFormat(true, false)

		zwaveOptions.logConfig.transports = [
			logTransport,
			...this._extraLogTransports.map((e) => e.transport),
		]

		// If any extra transport requires a more verbose log level, apply it.
		// Use the most verbose (highest priority) level among all extra transports.
		const extraLevel = this._extraLogTransports
			.map((e) => e.level)
			.filter(Boolean)
			.reduce<string | undefined>((best, level) => {
				if (!best) return level
				return LOG_LEVEL_ORDER.indexOf(level) >
					LOG_LEVEL_ORDER.indexOf(best)
					? level
					: best
			}, undefined)
		if (extraLevel) {
			const currentLevel = zwaveOptions.logConfig.level
			const currentIdx =
				typeof currentLevel === 'string'
					? LOG_LEVEL_ORDER.indexOf(currentLevel)
					: -1
			const extraIdx = LOG_LEVEL_ORDER.indexOf(extraLevel)
			if (extraIdx > currentIdx) {
				zwaveOptions.logConfig.level = extraLevel
			}
		}

		logTransport.stream.on('data', (data) => {
			this.socket
				.to('debug')
				.emit(socketEvents.debug, data.message.toString())
		})

		try {
			if (shouldUpdateSettings) {
				const settings = jsonStore.get(store.settings)
				settings.zwave = this.cfg
				await jsonStore.put(store.settings, settings)
			}
			// 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._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),
			)
			this._driver.on(
				'firmware update progress',
				this._onOTWFirmwareUpdateProgress.bind(this),
			)
			this._driver.on(
				'firmware update finished',
				this._onOTWFirmwareUpdateFinished.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(() => {
				const channel = eventToChannel[evtName]
				if (channel) {
					this.socket.to(channel).emit(evtName, data, ...args)
				} else {
					logger.warn(
						`No channel mapping for event ${evtName}, broadcasting to all clients`,
					)
					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()

		// Skip the socket broadcast when the value didn't actually change.
		// Chatty meshes can produce ~50% no-op value updates (see #4639) and
		// each one triggers an unnecessary re-render of the nodes table.
		// MQTT/HASS still get every event via the local 'valueChanged' below.
		if (changed) {
			this.sendToSocket(socketEvents.valueUpdated, valueId)
		}

		this.emit('valueChanged', valueId, node, changed)

		// Update virtual nodes that contain this node
		this._updateVirtualNodesForNode(valueId.nodeId)
	}

	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 getStoreNodes() {
		if (!this.homeHex) {
			throw new Error('HomeHex not set')
		}

		let nodes = jsonStore.get(store.nodes)

		// back compatibility fixes

		// convert store nodes from array to object
		if (Array.isArray(nodes)) {
			const storeNodes = {}

			for (let i = 0; i < nodes.length; i++) {
				if (nodes[i]) {
					storeNodes[i] = nodes[i]
				}
			}

			nodes = storeNodes
		}

		const keys = Object.keys(nodes)

		// ensure store nodes are stored using homeHex
		if (keys.length > 0 && !keys[0].startsWith('0x')) {
			this.storeNodes = nodes
			await jsonStore.put(store.nodes, {
				[this.homeHex]: nodes,
			})
		} else {
			this.storeNodes = nodes[this.homeHex] || {}
		}
	}

	async updateStoreNodes(throwError = true) {
		try {
			if (!this.homeHex) {
				logger.warn('HomeHex not set, skipping storeDevices')
				return
			}

			const nodes = jsonStore.get(store.nodes)

			// remove empty objects keys
			nodes[this.homeHex] = Object.keys(this.storeNodes).reduce(
				(acc, k) => {
					if (Object.keys(this.storeNodes[k]).length > 0) {
						acc[k] = this.storeNodes[k]
					}
					return acc
				},
				{},
			)

			logger.debug('Updating store nodes.json')
			await jsonStore.put(store.nodes, nodes)
		} 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
	}

	// === GROUPS MANAGEMENT ===

	/**
	 * Validate and normalize a group name.
	 */
	private _validateGroupName(name: string): string {
		const trimmed = name?.trim()
		if (!trimmed) {
			throw new Error('Group name is required')
		}
		if (trimmed.length > GROUP_NAME_MAX_LENGTH) {
			throw new Error(
				`Group name must be at most ${GROUP_NAME_MAX_LENGTH} characters`,
			)
		}
		return trimmed
	}

	/**
	 * Filter and validate node IDs for group creation/update.
	 * Removes duplicates, controller node, broadcast IDs, virtual-group IDs,
	 * and any IDs not present in the controller's physical nodes map.
	 */
	private _filterGroupNodeIds(nodeIds: number[]): number[] {
		const ownNodeId = this._driver?.controller?.ownNodeId
		const knownPhysicalIds = this._driver?.controller?.nodes
		const filtered = [...new Set(nodeIds)].filter(
			(id) =>
				typeof id === 'number' &&
				Number.isInteger(id) &&
				id > 0 &&
				id !== ownNodeId &&
				id !== NODE_ID_BROADCAST &&
				id !== NODE_ID_BROADCAST_LR &&
				id < GROUP_ID_MIN &&
				(!knownPhysicalIds || knownPhysicalIds.has(id)),
		)

		if (filtered.length < 2) {
			throw new Error('At least 2 valid nodes are required for a group')
		}

		return filtered
	}

	/**
	 * Get next available group ID (>= GROUP_ID_MIN).
	 */
	private _getNextGroupId(): number {
		const existingIds = new Set(this.groups.map((g) => g.id))
		let nextId = GROUP_ID_MIN
		while (existingIds.has(nextId)) {
			nextId++
		}
		return nextId
	}

	/**
	 * Rebuild the nodeId → groupIds index. Called whenever the groups list
	 * changes so per-value-change lookups stay O(1).
	 */
	private _rebuildNodeToGroupsIndex(): void {
		this._nodeToGroups.clear()
		for (const group of this.groups) {
			for (const nodeId of group.nodeIds) {
				let set = this._nodeToGroups.get(nodeId)
				if (!set) {
					set = new Set()
					this._nodeToGroups.set(nodeId, set)
				}
				set.add(group.id)
			}
		}
	}

	/**
	 * Create a new group. Builds the multicast instance before persisting so a
	 * failure leaves no phantom group in `groups.json`.
	 */
	async _createGroup(name: string, nodeIds: number[]): Promise<Group> {
		const trimmedName = this._validateGroupName(name)
		const validNodeIds = this._filterGroupNodeIds(nodeIds)

		const id = this._getNextGroupId()
		const group: Group = { id, name: trimmedName, nodeIds: validNodeIds }

		// Build the live multicast instance up-front: if zwave-js rejects the
		// node set, throw before we touch persistent state.
		if (this.driverReady) {
			const multicastGroup =
				this._driver.controller.getMulticastGroup(validNodeIds)
			this._virtualNodes.set(id, multicastGroup)
		}

		this.groups.push(group)
		try {
			await jsonStore.put(store.groups, this.groups)
		} catch (error) {
			// Rollback in-memory state on persistence failure
			this.groups.pop()
			this._virtualNodes.delete(id)
			throw error
		}

		this._rebuildNodeToGroupsIndex()
		this._createVirtualNode(group)

		return group
	}

	/**
	 * Update an existing group.
	 */
	async _updateGroup(
		id: number,
		name: string,
		nodeIds: number[],
	): Promise<Group | null> {
		const trimmedName = this._validateGroupName(name)
		const validNodeIds = this._filterGroupNodeIds(nodeIds)

		const groupIndex = this.groups.findIndex((g) => g.id === id)
		if (groupIndex === -1) {
			return null
		}

		const previous = { ...this.groups[groupIndex] }
		this.groups[groupIndex].name = trimmedName
		this.groups[groupIndex].nodeIds = validNodeIds

		// Refresh the multicast instance with the validated node list before
		// persisting — if zwave-js rejects, restore previous state and throw.
		if (this.driverReady) {
			try {
				const multicastGroup =
					this._driver.controller.getMulticastGroup(validNodeIds)
				this._virtualNodes.set(id, multicastGroup)
			} catch (error) {
				this.groups[groupIndex] = previous
				throw error
			}
		}

		try {
			await jsonStore.put(store.groups, this.groups)
		} catch (error) {
			this.groups[groupIndex] = previous
			throw error
		}

		this._rebuildNodeToGroupsIndex()
		this._updateVirtualNode(this.groups[groupIndex])

		return this.groups[groupIndex]
	}

	/**
	 * Delete a group.
	 */
	async _deleteGroup(id: number): Promise<boolean> {
		const groupIndex = this.groups.findIndex((g) => g.id === id)
		if (groupIndex === -1) {
			return false
		}

		this._nodes.delete(id)
		this._virtualNodes.delete(id)

		this.groups.splice(groupIndex, 1)
		await jsonStore.put(store.groups, this.groups)

		this._rebuildNodeToGroupsIndex()

		this.sendToSocket(socketEvents.nodeRemoved, { id })

		return true
	}

	/**
	 * Get all groups
	 */
	_getGroups(): Group[] {
		return this.groups
	}

	// ------------ CONFIGURATION TEMPLATES MANAGEMENT -----------------------------------

	/**
	 * Get all configuration templates
	 */
	getConfigurationTemplates(): ZUIConfigurationTemplate[] {
		return this._configTemplates
	}

	/**
	 * Get configuration parameter definitions from the zwave-js config DB
	 * for a device identified by its deviceId (manufacturerId-productId-productType)
	 */
	async getDeviceConfigurationParams(
		deviceId: string,
	): Promise<Partial<ZUIValueId>[]> {
		const parts = deviceId.split('-')
		if (parts.length !== 3) {
			throw new Error(
				'Invalid deviceId format, expected manufacturerId-productId-productType',
			)
		}

		const manufacturerId = parseInt(parts[0], 10)
		const productId = parseInt(parts[1], 10)
		const productType = parseInt(parts[2], 10)

		if (isNaN(manufacturerId) || isNaN(productId) || isNaN(productType)) {
			throw new Error('Invalid deviceId: non-numeric components')
		}

		await configManager.loadDeviceIndex()

		const device = await configManager.lookupDevice(
			manufacturerId,
			productType,
			productId,
		)

		if (!device || !device.paramInformation) {
			return []
		}

		const result: Partial<ZUIValueId>[] = []

		for (const [key, param] of device.paramInformation.entries()) {
			const propertyKey = key.valueBitMask
			const id = `0-112-0-${key.parameter}${propertyKey != null ? '-' + propertyKey : ''}`

			result.push({
				id,
				commandClass: 112,
				property: key.parameter,
				propertyKey: propertyKey,
				endpoint: 0,
				type: 'number',
				readable: true,
				writeable: !param.readOnly,
				label: param.label,
				description: param.description,
				min: param.minValue,
				max: param.maxValue,
				default: param.defaultValue,
				unit: param.unit,
				list: param.options?.length > 0,
				allowManualEntry: param.allowManualEntry,
				states: param.options?.map((o) => ({
					text: o.label,
					value: o.value,
				})),
				newValue: param.defaultValue,
			} as any)
		}

		return result
	}

	/**
	 * Create a configuration template from a node's CC 112 values
	 */
	async createConfigurationTemplate(
		nodeId: number,
		name: string,
		autoApply = false,
		values?: ZUIConfigurationTemplateValue[],
		firmwareRange?: { min?: string; max?: string },
	): Promise<ZUIConfigurationTemplate> {
		const node = this._nodes.get(nodeId)

		if (!node) {
			throw Error(`Node ${nodeId} not found`)
		}

		if (!node.ready) {
			throw Error(`Node ${nodeId} is not ready`)
		}

		let configValues: ZUIConfigurationTemplateValue[]

		if (values && values.length > 0) {
			// Use custom values provided by the wizard
			configValues = values
		} else {
			// Extract CC 112 (Configuration) writeable values
			configValues = []

			for (const id in node.values) {
				const v = node.values[id]
				if (
					v.commandClass === CommandClasses.Configuration &&
					v.writeable
				) {
					configValues.push({
						property: v.property as number,
						propertyKey:
							v.propertyKey != null
								? (v.propertyKey as number)
								: null,
						endpoint: v.endpoint || 0,
						value: v.value,
						label: v.label,
						description: v.description,
					})
				}
			}
		}

		if (configValues.length === 0) {
			throw Error(
				`Node ${nodeId} has no writeable Configuration CC values`,
			)
		}

		const id = utils.generateId()

		const now = new Date().toISOString()

		const template: ZUIConfigurationTemplate = {
			id,
			name,
			deviceId: node.deviceId,
			manufacturerId: node.manufacturerId,
			productId: node.productId,
			productType: node.productType,
			manufacturer: node.manufacturer,
			productLabel: node.productLabel,
			firmwareRange:
				firmwareRange?.min || firmwareRange?.max
					? firmwareRange
					: undefined,
			values: configValues,
			autoApply,
			contentHash: this._generateTemplateContentHash(
				configValues,
				firmwareRange,
			),
			createdAt: now,
			updatedAt: now,
		}

		this._configTemplates.push(template)
		await jsonStore.put(store.configurationTemplates, this._configTemplates)

		if (autoApply) {
			this._autoApplyTemplateToNodes(template)
		}

		return template
	}

	/**
	 * Update an existing configuration template
	 */
	async updateConfigurationTemplate(
		id: string,
		updates: {
			name?: string
			autoApply?: boolean
			firmwareRange?: { min?: string; max?: string }
			values?: ZUIConfigurationTemplateValue[]
		},
	): Promise<ZUIConfigurationTemplate> {
		const template = this._configTemplates.find((t) => t.id === id)

		if (!template) {
			throw Error(`Template ${id} not found`)
		}

		if (updates.name !== undefined) template.name = updates.name
		if (updates.autoApply !== undefined)
			template.autoApply = updates.autoApply

		const contentChanged =
			updates.firmwareRange !== undefined || updates.values !== undefined

		if (updates.firmwareRange !== undefined)
			template.firmwareRange = updates.firmwareRange
		if (updates.values !== undefined) template.values = updates.values

		if (contentChanged) {
			template.contentHash = this._generateTemplateContentHash(
				template.values,
				template.firmwareRange,
			)
		}

		template.updatedAt = new Date().toISOString()

		await jsonStore.put(store.configurationTemplates, this._configTemplates)

		if (template.autoApply && contentChanged) {
			this._autoApplyTemplateToNodes(template)
		}

		return template
	}

	/**
	 * Delete a configuration template
	 */
	async deleteConfigurationTemplate(id: string): Promise<boolean> {
		const index = this._configTemplates.findIndex((t) => t.id === id)

		if (index < 0) {
			throw Error(`Template ${id} not found`)
		}

		const deletedHash = this._configTemplates[index].contentHash

		this._configTemplates.splice(index, 1)
		await jsonStore.put(store.configurationTemplates, this._configTemplates)

		// Cleanup the deleted template's hash from all nodes
		if (deletedHash) {
			for (const [, node] of this._nodes) {
				const hashes = node.appliedTemplateContentHashes
				if (hashes && hashes.includes(deletedHash)) {
					// _cleanupAppliedTemplateHashes removes any hash not matching an existing template
					await this._cleanupAppliedTemplateHashes(node)
				}
			}
		}

		return true
	}

	/**
	 * Build a fresh ZUINode shell for a virtual node (broadcast/multicast).
	 */
	private _newVirtualZUINode(
		id: number,
		name: string,
		kind: NonNullable<ZUINode['kind']>,
	): ZUINode {
		return {
			id,
			name,
			virtual: true,
			kind,
			ready: true,
			available: true,
			failed: false,
			inited: true,
			values: {},
			eventsQueue: [],
		}
	}

	/**
	 * Materialize (or re-materialize) the ZUINode shell + initial values for a
	 * multicast group.
	 *
	 * Despite the name, this runs in three scenarios:
	 *   1. **Driver startup / restore** — `init()` iterates persisted groups
	 *      and calls this to (re)build their live `VirtualNode` instance and
	 *      ZUI shell. The live instance is *not* yet in `_virtualNodes`, so it
	 *      is created here.
	 *   2. **After `_createGroup` succeeds** — the live `VirtualNode` was
	 *      already eagerly registered in `_virtualNodes` (so a driver-side
	 *      rejection happens before we mutate persistent state); this call
	 *      only needs to build the ZUI shell.
	 *   3. **As a fallback inside `_updateVirtualNode`** when the ZUI shell is
	 *      missing for an existing live instance.
	 *
	 * The `_virtualNodes.has(group.id)` guard short-circuits case (2)/(3) and
	 * lazily creates the live instance for case (1).
	 */
	private _createVirtualNode(group: Group): void {
		if (!this.driverReady) return

		try {
			if (!this._virtualNodes.has(group.id)) {
				const multicastGroup =
					this._driver.controller.getMulticastGroup(group.nodeIds)
				this._virtualNodes.set(group.id, multicastGroup)
			}

			const virtualNode = this._newVirtualZUINode(
				group.id,
				group.name,
				'multicast',
			)

			this._nodes.set(group.id, virtualNode)

			// Emit node update (not nodeAdded, which expects {node, result}
			// and shows a confirmation dialog)
			this.sendToSocket(socketEvents.nodeUpdated, virtualNode)

			this._updateVirtualNodeValues(group)
		} catch (error) {
			logger.error(
				`Error creating virtual node for group ${group.id}: ${error.message}`,
			)
		}
	}

	/**
	 * Apply a configuration template to a node
	 */
	async applyConfigurationTemplate(
		templateId: string,
		nodeId: number,
		force = false,
	): Promise<{
		success: number
		failed: number
		errors: string[]
		reason?: string
	}> {
		const template = this._configTemplates.find((t) => t.id === templateId)

		if (!template) {
			throw Error(`Template ${templateId} not found`)
		}

		const node = this._nodes.get(nodeId)

		if (!node) {
			throw Error(`Node ${nodeId} not found`)
		}

		if (!node.ready) {
			throw Error(`Node ${nodeId} is not ready`)
		}

		if (
			!force &&
			template.deviceId &&
			node.deviceId &&
			template.deviceId !== node.deviceId
		) {
			throw Error(
				`Template device type "${template.deviceId}" does not match node device type "${node.deviceId}". Use force to override.`,
			)
		}

		const results: {
			success: number
			failed: number
			errors: string[]
			reason?: string
		} = { success: 0, failed: 0, errors: [] }

		for (const tv of template.values) {
			try {
				// skip writing `undefined` configuration values to prevent errors
				if (tv.value === undefined) {
					results.success++
					continue
				}

				const result = await this.writeValue(
					{
						nodeId,
						commandClass: CommandClasses.Configuration,
						endpoint: tv.endpoint,
						property: tv.property,
						propertyKey: tv.propertyKey,
					} as ZUIValueId,
					tv.value,
				)

				if (setValueFailed(result)) {
					results.failed++
					results.errors.push(
						`Parameter ${tv.property}: ${result.message || getEnumMemberName(SetValueStatus, result.status)}`,
					)

					// Fail + node is dead means no point continuing
					if (
						result.status === SetValueStatus.Fail &&
						node.status === 'Dead'
					) {
						const remaining =
							template.values.length -
							results.success -
							results.failed
						if (remaining > 0) {
							results.failed += remaining
						}
						results.reason = 'Node is dead'
						break
					}
				} else {
					results.success++
				}
			} catch (error) {
				results.failed++
				results.errors.push(
					`Parameter ${tv.property}: ${error.message}`,
				)
			}
		}

		logger.info(
			`Applied template "${template.name}" to node ${nodeId}: ${results.success} OK, ${results.failed} failed`,
		)

		// Record content hash if any values were applied successfully
		if (results.success > 0 && template.contentHash) {
			if (!node.appliedTemplateContentHashes) {
				node.appliedTemplateContentHashes = []
			}

			if (
				!node.appliedTemplateContentHashes.includes(
					template.contentHash,
				)
			) {
				node.appliedTemplateContentHashes.push(template.contentHash)

				if (!this.storeNodes[nodeId]) {
					this.storeNodes[nodeId] = {} as any
				}
				this.storeNodes[nodeId].appliedTemplateContentHashes =
					node.appliedTemplateContentHashes

				this.throttle(
					'applyTemplate_storeNodes',
					() => {
						// eslint-disable-next-line @typescript-eslint/no-floating-promises
						this.updateStoreNodes(false)
					},
					1000,
				)
			}
		}

		return results
	}

	/**
	 * Import configuration templates (extends existing templates)
	 */
	async importConfigurationTemplates(
		templates: ZUIConfigurationTemplate[],
	): Promise<ZUIConfigurationTemplate[]> {
		// Migrate legacy minFirmwareVersion to firmwareRange
		for (const t of templates) {
			if ((t as any).minFirmwareVersion && !t.firmwareRange) {
				t.firmwareRange = { min: (t as any).minFirmwareVersion }
				delete (t as any).minFirmwareVersion
			}
			t.id = utils.generateId()
			if (!t.contentHash) {
				t.contentHash = this._generateTemplateContentHash(
					t.values,
					t.firmwareRange,
				)
			}
			this._configTemplates.push(t)
		}

		await jsonStore.put(store.configurationTemplates, this._configTemplates)

		return this._configTemplates
	}

	/**
	 * Generate a short content hash for a template based on its applied values
	 */
	private _generateTemplateContentHash(
		values: ZUIConfigurationTemplateValue[],
		firmwareRange?: { min?: string; max?: string },
	): string {
		// Normalize values to ensure deterministic key ordering across
		// different code paths (create vs import)
		const normalized = values.map((v) => ({
			property: v.property,
			propertyKey: v.propertyKey ?? null,
			endpoint: v.endpoint,
			value: v.value,
		}))
		return createHash('sha256')
			.update(JSON.stringify({ values: normalized, firmwareRange }))
			.digest('hex')
			.slice(0, 12)
	}

	/**
	 * Remove applied template hashes that no longer match any existing template
	 */
	private async _cleanupAppliedTemplateHashes(node: ZUINode) {
		const hashes = node.appliedTemplateContentHashes
		if (!hashes || hashes.length === 0) return

		const validHashes = new Set(
			this._configTemplates.map((t) => t.contentHash),
		)
		const cleaned = hashes.filter((h) => validHashes.has(h))

		if (cleaned.length !== hashes.length) {
			node.appliedTemplateContentHashes = cleaned

			if (!this.storeNodes[node.id]) {
				this.storeNodes[node.id] = {} as any
			}
			this.storeNodes[node.id].appliedTemplateContentHashes = cleaned
			await this.updateStoreNodes(false)
		}
	}

	/**
	 * Update virtual node for multicast group
	 */
	private _updateVirtualNode(group: Group): void {
		const virtualNode = this._nodes.get(group.id)
		if (!virtualNode) {
			// Create if doesn't exist
			this._createVirtualNode(group)
			return
		}

		virtualNode.name = group.name

		// Update virtual node values based on member nodes
		this._updateVirtualNodeValues(group)

		// Emit node updated event
		this.emitNodeUpdate(virtualNode, { name: virtualNode.name })
	}

	/**
	 * Build a ZUIValueId for a virtual node (broadcast/multicast) from a value
	 * ID returned by VirtualNode.getDefinedValueIDs().
	 *
	 * zwave-js returns value IDs with inline `.metadata` and `.ccVersion`
	 * already resolved from the physical nodes. We mirror the structure that
	 * `_updateValueMetadata()` produces for physical nodes so the frontend
	 * renders the same controls (select, number input, boolean toggle, etc.).
	 */
	private _buildVirtualValueId(
		nodeId: number,
		zwaveValue: VirtualValueID,
		value?: unknown,
	): ZUIValueId | null {
		const meta = zwaveValue.metadata
		if (!meta) return null

		// VirtualValueID always carries ccVersion, but be defensive in case
		// older zwave-js builds omit it for synthesized Basic CC entries.
		const ccVersion =
			typeof zwaveValue.ccVersion === 'number' && zwaveValue.ccVersion > 0
				? zwaveValue.ccVersion
				: 1

		// Preserve any existing fields on the previous valueId (e.g. user-set
		// poll config) so rebuilds don't drop them — mirrors the spread used
		// in `_updateValueMetadata` for physical nodes.
		const existing =
			this._nodes.get(nodeId)?.values?.[
				this._getValueID(zwaveValue as unknown as ZUIValueId)
			]

		const valueId: ZUIValueId = {
			...(existing || {}),
			id: this._getValueID(
				{ ...zwaveValue, nodeId } as unknown as ZUIValueId,
				true,
			),
			nodeId,
			toUpdate: false,
			commandClass: zwaveValue.commandClass,
			commandClassName: zwaveValue.commandClassName,
			endpoint: zwaveValue.endpoint,
			property: zwaveValue.property,
			propertyName: zwaveValue.propertyName,
			propertyKey: zwaveValue.propertyKey,
			propertyKeyName: zwaveValue.propertyKeyName,
			type: meta.type,
			readable: meta.readable ?? false,
			writeable: meta.writeable ?? true,
			description: meta.description,
			label: meta.label || zwaveValue.propertyName + ' (property)',
			default: meta.default,
			ccSpecific: meta.ccSpecific,
			stateless: false,
			commandClassVersion: ccVersion,
			value,
			lastUpdate: Date.now(),
		}

		this._applyValueMetadataFields(valueId, meta)

		return valueId
	}

	/**
	 * Populate values for a multicast group virtual node.
	 *
	 * zwave-js `VirtualNode.getDefinedValueIDs()` returns the **union** of all
	 * writeable actuator value IDs across every physical member node, plus
	 * Basic CC (always added so heterogeneous groups can be controlled
	 * together). This means a group may expose CCs that only some members
	 * support — nodes that don't will simply ignore the command.
	 *
	 * For each value we aggregate the current state of all member nodes:
	 * if every member has the same value it is shown, otherwise `undefined`.
	 */
	private _updateVirtualNodeValues(group: Group): void {
		const virtualNode = this._nodes.get(group.id)
		if (!virtualNode) return

		const multicastGroup = this._virtualNodes.get(group.id)
		if (!multicastGroup) return

		try {
			const definedValueIDs = multicastGroup.getDefinedValueIDs()

			virtualNode.values = {}

			for (const zwaveValue of definedValueIDs) {
				const vId = this._getValueID(
					zwaveValue as unknown as ZUIValueId,
				)

				// Aggregate values from member nodes that support this CC.
				// Show the value if all supporting members agree (using deep
				// equality so object-valued CCs like Color compare correctly),
				// otherwise undefined.
				const memberValues = group.nodeIds
					.map((id) => this._nodes.get(id)?.values?.[vId]?.value)
					.filter((v) => v !== undefined && v !== null)
				const allSame =
					memberValues.length > 0 &&
					memberValues.every((v) =>
						utils.deepEqual(v, memberValues[0]),
					)

				const valueId = this._buildVirtualValueId(
					group.id,
					zwaveValue,
					allSame ? memberValues[0] : undefined,
				)
				if (!valueId) continue

				virtualNode.values[vId] = valueId
			}

			// Emit valueChanged for each value so the MQTT gateway
			// publishes them and registers topics for write-back
			for (const vId in virtualNode.values) {
				this.emit(
					'valueChanged',
					virtualNode.values[vId],
					virtualNode,
					true,
				)
			}

			this.emitNodeUpdate(virtualNode, { values: virtualNode.values })
		} catch (error) {
			logger.error(
				`Error updating virtual node values for group ${group.id}: ${error.message}`,
			)
		}
	}

	/**
	 * Populate values for broadcast virtual nodes (standard + LR).
	 *
	 * Like multicast groups, broadcast nodes expose the union of all writeable
	 * actuator value IDs across the entire network (or LR network). All values
	 * are write-only (`value: undefined`) since broadcast commands cannot be
	 * read back.
	 *
	 * Uses leading-edge throttling: the first call after a quiet period runs
	 * synchronously (so users see broadcast controls immediately on startup),
	 * while bursts of node-ready / node-added / node-removed events during a
	 * controller interview are coalesced into a single trailing rebuild.
	 */
	private _updateBroadcastNodeValues(): void {
		if (!this.driverReady) return

		// Keep the cached instances in sync with the driver *synchronously*, so
		// the write-back path (`getVirtualNode` → `setValue`) never targets a
		// removed node even before the throttled value rebuild below runs.
		this._refreshBroadcastInstances()

		this.throttle(
			'broadcast_values_rebuild',
			() => this._doUpdateBroadcastNodeValues(),
			1000,
		)
	}

	/**
	 * Re-fetch the cached broadcast VirtualNode instances from the controller.
	 *
	 * The driver snapshots the physical node set when a broadcast node is
	 * constructed, so a long-lived instance keeps referencing nodes that have
	 * since been removed — using one then throws "Node X was not found" (#4677),
	 * both when enumerating values and when sending a write. Re-fetching is
	 * cheap (it just wraps the controller's current node set), so we do it
	 * eagerly on every node-set change. Only instances that currently exist are
	 * refreshed; the LR broadcast node exists solely while the network has LR
	 * nodes (see `_refreshBroadcastLRNode`).
	 */
	private _refreshBroadcastInstances(): void {
		if (!this.driverReady) return

		const controller = this._driver.controller

		if (this._virtualNodes.has(NODE_ID_BROADCAST)) {
			this._virtualNodes.set(
				NODE_ID_BROADCAST,
				controller.getBroadcastNode(),
			)
		}

		if (this._virtualNodes.has(NODE_ID_BROADCAST_LR)) {
			this._virtualNodes.set(
				NODE_ID_BROADCAST_LR,
				controller.getBroadcastNodeLR(),
			)
		}
	}

	private _doUpdateBroadcastNodeValues(): void {
		if (!this.driverReady) return

		const broadcastNodeIds = [NODE_ID_BROADCAST, NODE_ID_BROADCAST_LR]

		for (const nodeId of broadcastNodeIds) {
			try {
				// The cached instances are kept current by
				// `_refreshBroadcastInstances`, so they already reflect the
				// live node set (no removed nodes) by the time we get here.
				const broadcastInstance = this._virtualNodes.get(nodeId)
				const virtualNode = this._nodes.get(nodeId)

				if (!broadcastInstance || !virtualNode) continue

				const definedValueIds = broadcastInstance.getDefinedValueIDs()

				virtualNode.values = {}

				for (const zwaveValue of definedValueIds) {
					const valueId = this._buildVirtualValueId(
						nodeId,
						zwaveValue,
						undefined, // broadcast values are write-only
					)
					if (!valueId) continue

					const vID = this._getValueID(valueId)
					virtualNode.values[vID] = valueId
				}

				// Emit valueChanged for each value so the MQTT gateway
				// publishes them and registers topics for write-back
				for (const vID in virtualNode.values) {
					this.emit(
						'valueChanged',
						virtualNode.values[vID],
						virtualNode,
						true,
					)
				}

				this.sendToSocket(socketEvents.nodeUpdated, virtualNode)
			} catch (error) {
				logger.error(
					`Error updating broadcast node ${nodeId} values: ${error.message}`,
				)
			}
		}
	}

	/**
	 * Create broadcast virtual nodes (standard + optional LR).
	 *
	 * Broadcast nodes send commands to every node in the network. The LR
	 * broadcast node is only created when the controller actually has at least
	 * one LR-capable physical node — exposing it on a controller that merely
	 * *supports* LR is misleading.
	 */
	private _createBroadcastNodes(): void {
		if (!this.driverReady) return

		try {
			// Standard broadcast — always available.
			const broadcastNode = this._driver.controller.getBroadcastNode()
			this._virtualNodes.set(NODE_ID_BROADCAST, broadcastNode)

			const broadcastVirtualNode = this._newVirtualZUINode(
				NODE_ID_BROADCAST,
				'Broadcast',
				'broadcast',
			)

			this._nodes.set(NODE_ID_BROADCAST, broadcastVirtualNode)
			this.sendToSocket(socketEvents.nodeUpdated, broadcastVirtualNode)

			this._refreshBroadcastLRNode()

			this._updateBroadcastNodeValues()
		} catch (error) {
			logger.error(`Error creating broadcast nodes: ${error.message}`)
		}
	}

	/**
	 * Add or remove the LR broadcast virtual node depending on whether any LR
	 * physical node is currently part of the network. Called whenever the
	 * physical node set changes.
	 */
	private _refreshBroadcastLRNode(): void {
		if (!this.driverReady) return

		const controller = this._driver.controller
		const hasLRNodes =
			controller.supportsLongRange &&
			[...controller.nodes.values()].some(
				(n) => n.protocol === Protocols.ZWaveLongRange,
			)

		const exists = this._virtualNodes.has(NODE_ID_BROADCAST_LR)

		if (hasLRNodes && !exists) {
			try {
				const broadcastNodeLR = controller.getBroadcastNodeLR()
				this._virtualNodes.set(NODE_ID_BROADCAST_LR, broadcastNodeLR)

				const lrNode = this._newVirtualZUINode(
					NODE_ID_BROADCAST_LR,
					'Broadcast LR',
					'broadcastLR',
				)
				this._nodes.set(NODE_ID_BROADCAST_LR, lrNode)
				this.sendToSocket(socketEvents.nodeUpdated, lrNode)
			} catch (error) {
				logger.warn(`LR broadcast node not available: ${error.message}`)
			}
		} else if (!hasLRNodes && exists) {
			this._virtualNodes.delete(NODE_ID_BROADCAST_LR)
			this._nodes.delete(NODE_ID_BROADCAST_LR)
			this.sendToSocket(socketEvents.nodeRemoved, {
				id: NODE_ID_BROADCAST_LR,
			})
		}
	}

	/**
	 * Update virtual nodes when a member node's value changes.
	 * Throttled to avoid excessive rebuilds on frequent value updates.
	 */
	private _updateVirtualNodesForNode(nodeId: number): void {
		const groupIds = this._nodeToGroups.get(nodeId)
		if (!groupIds || groupIds.size === 0) return

		for (const groupId of groupIds) {
			const group = this.groups.find((g) => g.id === groupId)
			if (!group) continue
			this.throttle(
				`virtual_node_update_${group.id}`,
				() => {
					this._updateVirtualNodeValues(group)
				},
				1000,
			)
		}
		// Note: broadcast node values are not updated here because they are
		// write-only and don't need to reflect individual node value changes.
		// They are rebuilt when nodes are added/removed or become ready.
	}

	/**
	 * Drop a removed physical node from any group containing it. Persists
	 * groups.json before touching live virtual instances, so a crash between
	 * the in-memory mutation and the disk write can never resurrect the
	 * removed node on restart. Refreshes the corresponding multicast instances
	 * and virtual ZUI nodes after the write succeeds.
	 */
	private async _removeNodeFromGroups(nodeId: number): Promise<void> {
		const groupIds = this._nodeToGroups.get(nodeId)
		if (!groupIds || groupIds.size === 0) return

		const affected: Group[] = []
		for (const groupId of groupIds) {
			const group = this.groups.find((g) => g.id === groupId)
			if (!group) continue
			group.nodeIds = group.nodeIds.filter((id) => id !== nodeId)
			affected.push(group)
		}

		if (affected.length === 0) return

		try {
			await jsonStore.put(store.groups, this.groups)
		} catch (error) {
			logger.error(
				`Failed to persist groups after removing node ${nodeId}: ${error.message}`,
			)
			return
		}

		this._rebuildNodeToGroupsIndex()

		for (const group of affected) {
			if (group.nodeIds.length >= 2 && this.driverReady) {
				try {
					const refreshed = this._driver.controller.getMulticastGroup(
						group.nodeIds,
					)
					this._virtualNodes.set(group.id, refreshed)
				} catch (error) {
					logger.error(
						`Failed to refresh multicast group ${group.id} after removing node ${nodeId}: ${error.message}`,
					)
				}
				this._updateVirtualNode(group)
			} else {
				// Group dropped below the 2-node minimum; tear down the live
				// virtual node but leave the persisted entry so the user can
				// fix it from the Groups page.
				this._virtualNodes.delete(group.id)
				this._nodes.delete(group.id)
				this.sendToSocket(socketEvents.nodeRemoved, { id: group.id })
			}
		}
	}

	/**
	 * Auto-apply a template to all matching ready nodes that haven't received it yet
	 */
	private _autoApplyTemplateToNodes(template: ZUIConfigurationTemplate) {
		if (!this._driver?.controller) return

		for (const [, node] of this._nodes) {
			if (!node.ready || !node.deviceId) continue

			const matching = this._getMatchingTemplates(node)
			if (!matching.some((t) => t.id === template.id)) continue

			const hashes = node.appliedTemplateContentHashes || []
			if (hashes.includes(template.contentHash)) continue

			const zwaveNode = this._driver.controller.nodes.get(node.id)

			if (zwaveNode) {
				this.logNode(
					zwaveNode,
					'info',
					`Auto-applying configuration template "${template.name}"`,
				)
			}

			this.applyConfigurationTemplate(template.id, node.id, true)
				.then((result) => {
					if (result.failed > 0 && zwaveNode) {
						this.logNode(
							zwaveNode,
							'warn',
							`Template "${template.name}" partially applied: ${result.success} OK, ${result.failed} failed`,
						)
					}
				})
				.catch((error) => {
					if (zwaveNode) {
						this.logNode(
							zwaveNode,
							'error',
							`Failed to auto-apply template "${template.name}": ${error.message}`,
						)
					}
				})
		}
	}

	/**
	 * Get templates matching a node's device type and firmware version
	 */
	private _getMatchingTemplates(node: ZUINode): ZUIConfigurationTemplate[] {
		if (!node.deviceId) return []

		return this._configTemplates.filter((t) => {
			if (t.deviceId !== node.deviceId) return false

			// Check firmware version range if specified
			if (t.firmwareRange?.min || t.firmwareRange?.max) {
				if (!node.firmwareVersion) {
					return false
				}

				const nodeFw = semverCoerce(node.firmwareVersion)
				if (!nodeFw) return false

				if (t.firmwareRange.min) {
					const minFw = semverCoerce(t.firmwareRange.min)
					if (!minFw || !semverGte(nodeFw, minFw)) {
						return false
					}
				}

				if (t.firmwareRange.max) {
					const maxFw = semverCoerce(t.firmwareRange.max)
					if (!maxFw || !semverLte(nodeFw, maxFw)) {
						return false
					}
				}
			}

			return true
		})
	}

	/**
	 * Check and auto-apply matching configuration templates for a node
	 */
	private _checkConfigurationTemplates(node: ZUINode, zwaveNode: ZWaveNode) {
		// Cleanup stale applied hashes on node ready
		// eslint-disable-next-line @typescript-eslint/no-floating-promises
		this._cleanupAppliedTemplateHashes(node)

		const matching = this._getMatchingTemplates(node)

		if (matching.length === 0) {
			return
		}

		const appliedHashes = node.appliedTemplateContentHashes || []

		// Filter auto-apply templates that haven't been applied yet
		const autoApplyTemplates = matching.filter(
			(t) =>
				t.autoApply &&
				t.contentHash &&
				!appliedHashes.includes(t.contentHash),
		)

		for (const template of autoApplyTemplates) {
			this.logNode(
				zwaveNode,
				'info',
				`Auto-applying configuration template "${template.name}"`,
			)
			this.applyConfigurationTemplate(template.id, node.id, true)
				.then((result) => {
					if (result.failed > 0) {
						this.logNode(
							zwaveNode,
							'warn',
							`Template "${template.name}" partially applied: ${result.success} OK, ${result.failed} failed`,
						)
					}
				})
				.catch((error) => {
					this.logNode(
						zwaveNode,
						'error',
						`Failed to auto-apply template "${template.name}": ${error.message}`,
					)
				})
		}
	}

	/**
	 * 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()
		}
	}

	/**
	 * Stops learn mode
	 */
	stopLearnMode(): Promise<boolean> {
		if (this.driverReady) {
			if (this.commandsTimeout) {
				clearTimeout(this.commandsTimeout)
				this.commandsTimeout = null
			}
			return this._driver.controller.stopJoiningNetwork()
		}

		throw new DriverNotReadyError()
	}

	/**
	 * Starts learn mode
	 */
	async startLearnMode(): Promise<JoinNetworkResult> {
		if (this.driverReady) {
			if (this.commandsTimeout) {
				clearTimeout(this.commandsTimeout)
				this.commandsTimeout = null
			}

			this.commandsTimeout = setTimeout(
				() => {
					this.stopLearnMode().catch(logger.error)
				},
				(this.cfg.commandsTimeout || 0) * 1000 || 30000,
			)

			const joinNetworkOptions: JoinNetworkOptions = {
				strategy: JoinNetworkStrategy.Default,
			}

			return this._driver.controller.beginJoiningNetwork(
				joinNetworkOptions,
			)
		}

		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 = await 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 getAllAvailableFirmwareUpdates(options?: GetFirmwareUpdatesOptions) {
		if (this.driverReady) {
			const result =
				await this._driver.controller.getAllAvailableFirmwareUpdates(
					options,
				)

			return result
		}

		throw new DriverNotReadyError()
	}

	/**
	 * Check firmware updates for all nodes and store results in nodes.json
	 */
	async checkAllNodesFirmwareUpdates(options?: GetFirmwareUpdatesOptions) {
		if (!this.driverReady) {
			throw new DriverNotReadyError()
		}

		if (this.cfg.disableAutomaticFirmwareUpdateChecks) {
			logger.info(
				'Firmware update checks are disabled. Skipping bulk firmware update check.',
			)
			return
		}

		logger.info('Starting bulk firmware update check for all nodes')

		try {
			const result =
				await this._driver.controller.getAllAvailableFirmwareUpdates(
					options,
				)

			if (result) {
				const now = Date.now()

				// Process results for each node
				for (const [nodeId, nodeUpdates] of result) {
					const filteredUpdates =
						this._filterFirmwareUpdates(nodeUpdates)

					this._updateNodeFirmwareInfo(nodeId, filteredUpdates, now)

					if (filteredUpdates && filteredUpdates.length > 0) {
						logger.info(
							`Found ${filteredUpdates.length} firmware update(s) for node ${nodeId}`,
						)
					}
				}

				// Save to nodes.json
				await this.updateStoreNodes()
			}

			return result
		} catch (error) {
			logger.error(
				'Error during bulk firmware update check:',
				error.message,
			)
			throw error
		}
	}

	/**
	 * Dismiss firmware update for a specific node and version
	 */
	async dismissFirmwareUpdate(nodeId: number, version: string) {
		// Ensure store entry exists
		if (!this.storeNodes[nodeId]) {
			this.storeNodes[nodeId] = {} as any
		}

		// Initialize dismissal tracking if not exists
		if (!this.storeNodes[nodeId].firmwareUpdatesDismissed) {
			this.storeNodes[nodeId].firmwareUpdatesDismissed = {}
		}

		// Mark version as dismissed
		this.storeNodes[nodeId].firmwareUpdatesDismissed[version] = true

		// Update in-memory node
		const node = this._nodes.get(nodeId)
		if (node) {
			if (!node.firmwareUpdatesDismissed) {
				node.firmwareUpdatesDismissed = {}
			}
			node.firmwareUpdatesDismissed[version] = true

			// Emit update to frontend
			this.emitNodeUpdate(node, {
				firmwareUpdatesDismissed: node.firmwareUpdatesDismissed,
			})
		}

		// Save to nodes.json
		await this.updateStoreNodes()
		logger.info(`Dismissed firmware update ${version} for node ${nodeId}`)

		return true
	}

	/**
	 * Filter firmware updates to remove downgrades
	 */
	private _filterFirmwareUpdates(
		updates: FirmwareUpdateInfo[] | null,
	): FirmwareUpdateInfo[] {
		return (updates || []).filter((update) => !update.downgrade)
	}

	/**
	 * Clean up dismissed updates map to only contain versions that exist in available updates
	 */
	private _cleanDismissedUpdates(
		filteredUpdates: FirmwareUpdateInfo[],
		existingDismissed: { [version: string]: boolean },
	): { [version: string]: boolean } {
		const cleanedDismissed: { [version: string]: boolean } = {}

		for (const update of filteredUpdates) {
			if (existingDismissed[update.version]) {
				cleanedDismissed[update.version] = true
			}
		}

		return cleanedDismissed
	}

	/**
	 * Update node firmware information in store and memory
	 */
	private _updateNodeFirmwareInfo(
		nodeId: number,
		filteredUpdates: FirmwareUpdateInfo[],
		timestamp: number,
	) {
		// Ensure store entry exists
		if (!this.storeNodes[nodeId]) {
			this.storeNodes[nodeId] = {} as any
		}

		// Update stored firmware update info
		this.storeNodes[nodeId].availableFirmwareUpdates = filteredUpdates
		this.storeNodes[nodeId].lastFirmwareUpdateCheck = timestamp

		// Clean up dismissed updates map
		const existingDismissed =
			this.storeNodes[nodeId].firmwareUpdatesDismissed || {}
		const cleanedDismissed = this._cleanDismissedUpdates(
			filteredUpdates,
			existingDismissed,
		)
		this.storeNodes[nodeId].firmwareUpdatesDismissed = cleanedDismissed

		// Update in-memory node
		const node = this._nodes.get(nodeId)
		if (node) {
			node.availableFirmwareUpdates = filteredUpdates
			node.lastFirmwareUpdateCheck = timestamp
			node.firmwareUpdatesDismissed = cleanedDismissed

			// Emit update to frontend
			this.emitNodeUpdate(node, {
				availableFirmwareUpdates: node.availableFirmwareUpdates,
				lastFirmwareUpdateCheck: node.lastFirmwareUpdateCheck,
				firmwareUpdatesDismissed: node.firmwareUpdatesDismissed,
			})
		}
	}

	/**
	 * Check for firmware updates on a specific node
	 * Called after firmware updates, node interviews, and node additions to refresh availableFirmwareUpdates
	 */
	private async _checkNodeFirmwareUpdates(nodeId: number) {
		if (!this.driverReady) {
			return
		}

		if (this.cfg.disableAutomaticFirmwareUpdateChecks) {
			logger.info(
				`Firmware update checks are disabled. Skipping check for node ${nodeId}.`,
			)
			return
		}

		try {
			// Get updated firmware information
			const updates =
				await this._driver.controller.getAvailableFirmwareUpdates(
					nodeId,
				)

			const filteredUpdates = this._filterFirmwareUpdates(updates)
			const timestamp = Date.now()

			this._updateNodeFirmwareInfo(nodeId, filteredUpdates, timestamp)

			// Save to nodes.json
			await this.updateStoreNodes()

			logger.info(
				`Checked firmware updates for node ${nodeId} after update completion. Found ${filteredUpdates.length} update(s)`,
			)
		} catch (error) {
			logger.error(
				`Failed to check firmware updates for node ${nodeId} after update: ${error.message}`,
			)
		}
	}

	/**
	 * Get available non-dismissed firmware updates for a node
	 */
	getNodeFirmwareUpdates(nodeId: number): FirmwareUpdateInfo[] {
		const node = this._nodes.get(nodeId)
		if (!node?.availableFirmwareUpdates) {
			return []
		}

		// Filter out dismissed updates
		return node.availableFirmwareUpdates.filter((update) => {
			const dismissed =
				node.firmwareUpdatesDismissed?.[update.version] || false
			return !dismissed
		})
	}

	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)

			// Determine which properties need updating
			const propsToUpdate: Array<
				'powerlevel' | 'RFRegion' | 'maxLongRangePowerlevel'
			> = ['RFRegion']

			const supportsAutoPowerlevel = regionSupportsAutoPowerlevel(region)

			// If powerlevels are in auto mode, refresh them after region change
			if (supportsAutoPowerlevel) {
				if (
					this.cfg.rf?.autoPowerlevels ||
					this.cfg.rf?.txPower?.powerlevel === 'auto'
				) {
					propsToUpdate.push('powerlevel')
				}
				if (
					this.cfg.rf?.autoPowerlevels ||
					this.cfg.rf?.maxLongRangePowerlevel === 'auto'
				) {
					propsToUpdate.push('maxLongRangePowerlevel')
				}
			}

			await this.updateControllerNodeProps(null, propsToUpdate)
			return result
		}

		throw new DriverNotReadyError()
	}

	async setMaxLRPowerLevel(powerlevel: number): Promise<boolean> {
		if (this.driverReady) {
			const result =
				await this._driver.controller.setMaxLongRangePowerlevel(
					powerlevel,
				)
			await this.updateControllerNodeProps(null, [
				'maxLongRangePowerlevel',
			])
			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 = await 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
							) {
								await 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],
			])

			// Refresh priority and custom SUC return routes after rebuild completes
			// The cache is cleared during rebuild, so we read from the cache to update UI
			this.getCustomSUCReturnRoute(nodeId)
			this.getPrioritySUCReturnRoute(nodeId)

			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) {
			if (this.isVirtualNode(nodeId)) {
				throw new Error(`Node ${nodeId} is a virtual node`)
			}
			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) {
			if (this.isVirtualNode(nodeId)) {
				throw new Error(`Node ${nodeId} is a virtual node`)
			}
			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) {
			if (this.isVirtualNode(nodeId)) {
				throw new Error(`Node ${nodeId} is a virtual node`)
			}
			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) {
			if (this.isVirtualNode(nodeId)) {
				throw new Error(`Node ${nodeId} is a virtual node`)
			}
			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, { updateStatusOnly: 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 | FirmwareUpdateInfo,
	): Promise<OTWFirmwareUpdateResult> {
		try {
			if (backupManager.backupOnEvent) {
				this.nvmEvent = 'before_controller_fw_update_otw'
				await backupManager.backupNvm()
			}
			let firmware: Firmware

			if (file['files']) {
				return await this.driver.firmwareUpdateOTW(
					file as FirmwareUpdateInfo,
				)
			}

			file = file as FwFile

			try {
				const format = guessFirmwareFileFormat(file.name, file.data)
				firmware = await extractFirmware(file.data, format)
			} catch (err) {
				throw Error(
					`Unable to extract firmware from file '${file.name}'`,
				)
			}
			const result = await this.driver.firmwareUpdateOTW(firmware.data)
			return result
		} catch (e) {
			throw Error(`Error while updating firmware: ${e.message}`)
		}
	}

	async 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 (isUint8Array(data)) {
					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 = await 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?.mode === DriverMode.Bootloader) {
			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)

			// Route writes for virtual nodes (broadcast/multicast) through
			// the virtual node instance's setValue
			if (this.isVirtualNode(valueId.nodeId)) {
				try {
					const virtualInstance = this.getVirtualNode(valueId.nodeId)
					if (!virtualInstance) {
						throw Error(`Virtual node ${valueId.nodeId} not found`)
					}

					// coerce string to numbers
					if (
						valueId.type === 'number' &&
						typeof value === 'string'
					) {
						value = Number(value)
					}

					result = await virtualInstance.setValue(
						valueId,
						value,
						options,
					)
				} catch (error) {
					logger.log(
						'error',
						`Error while writing %o on virtual node ${valueId.nodeId}-${vID}: ${error.message}`,
						value,
					)
				}

				if (setValueFailed(result)) {
					logger.log(
						'error',
						`Unable to write %o on virtual ${vID}: %s`,
						value,
						result.message ||
							getEnumMemberName(SetValueStatus, result.status),
					)
				}

				return result
			}

			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 */
			})

			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(
					'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

		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

		// needs home hex to be set
		await this.getStoreNodes()

		// Create broadcast nodes and recreate virtual nodes for groups
		this._createBroadcastNodes()

		// Recreate virtual nodes for existing groups
		for (const group of this.groups) {
			this._createVirtualNode(group)
		}

		for (const [, node] of this._driver.controller.nodes) {
			// node added will not be triggered if the node is in cache
			this._createNode(node.id)
			this._addNode(node)

			// Make sure we didn't miss the ready event
			if (node.ready) {
				this._onNodeReady(node)
			}
		}

		// Now that physical nodes are populated, decide whether the LR
		// broadcast virtual node should be exposed.
		this._refreshBroadcastLRNode()

		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 _onOTWFirmwareUpdateProgress(progress: OTWFirmwareUpdateProgress) {
		this.throttle(
			this._onOTWFirmwareUpdateProgress.name,
			this.sendToSocket.bind(this, socketEvents.otwFirmwareUpdate, {
				progress,
			}),
			250,
		)

		this.emit(
			'event',
			EventSource.DRIVER,
			'controller firmware update progress',
			progress,
		)
	}

	private _onOTWFirmwareUpdateFinished(result: OTWFirmwareUpdateResult) {
		// prevent progress event to come after finish
		this.clearThrottle(this._onOTWFirmwareUpdateProgress.name)

		this.sendToSocket(socketEvents.otwFirmwareUpdate, {
			result: {
				success: result.success,
				status: getEnumMemberName(
					OTWFirmwareUpdateStatus,
					result.status,
				),
			},
		})

		logger.info(
			`Controller firmware update OTW finished ${
				result.success ? 'successfully' : 'with error'
			}.\n   Status: ${getEnumMemberName(
				OTWFirmwareUpdateStatus,
				result.status,
			)}. Result: ${JSON.stringify(result)}.`,
		)

		this.emit(
			'event',
			EventSource.DRIVER,
			'controller firmware update finished',
			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)
		const controllerNode = this.getNode(this.driver.controller.ownNodeId)
		if (controllerNode) {
			this._onNodeEvent('status changed', controllerNode, 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')

		// Schedule periodic firmware update checks
		this._scheduledFirmwareUpdateCheck().catch(() => {
			/* ignore */
		})
	}

	// ---------- 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

		// Clear any ghost nodes the driver surfaced via `node found` but never
		// promoted to `node added`. zwave-js doesn't always emit `node removed`
		// for these, so they would stay stuck on ProtocolInfo in the UI forever.
		for (const nodeId of this._pendingInclusionNodeIds) {
			const node = this._nodes.get(nodeId)
			if (node && !node.ready) {
				this.logNode(
					nodeId,
					'info',
					'Removing ghost node after failed inclusion',
				)
				this._removeNode(nodeId)
			}
		}
		this._pendingInclusionNodeIds.clear()

		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 })
			// track until `node added` clears it; cleaned up on inclusion failure
			this._pendingInclusionNodeIds.add(nodeId)
		} 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
		// node made it past `node found` — no longer a ghost candidate
		this._pendingInclusionNodeIds.delete(zwaveNode.id)
		// 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 })

			// Update broadcast nodes with new capabilities
			this._refreshBroadcastLRNode()
			this._updateBroadcastNodeValues()
		}

		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 async _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)

		// Persist the group cleanup *before* refreshing live virtual nodes /
		// broadcast values, so the on-disk state reflects the removal.
		await this._removeNodeFromGroups(zwaveNode.id)

		// Update broadcast nodes after node removal
		this._refreshBroadcastLRNode()
		this._updateBroadcastNodeValues()
	}

	/**
	 * 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 allDone = toRebuild.every((v) => v !== 'pending')
		const status = allDone ? 'COMPLETED' : 'IN PROGRESS'
		const message = `Rebuild Routes process ${status}. 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 utils.ensureDir(nvmBackupsDir)

		await writeFile(utils.joinPath(nvmBackupsDir, fileName + '.bin'), data)

		this._updateControllerStatus('NVM backup completed successfully')

		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: Uint8Array<ArrayBuffer>, 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),
			)
		}

		this._updateControllerStatus('NVM restore completed successfully')
	}

	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)
	}

	async parseQRCodeString(qrString: string): Promise<{
		parsed?: QRProvisioningInformation
		nodeId?: number
		exists: boolean
	}> {
		const parsed = await 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,
		}
	}

	async provisionSmartStartNode(entry: PlannedProvisioningEntry | string) {
		if (!this.driverReady) {
			throw new DriverNotReadyError()
		}

		if (typeof entry === 'string') {
			// it's a qrcode
			entry = await 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,
		options?: {
			/** Only emit the status/availability change instead of the full node */
			updateStatusOnly?: boolean
			/**
			 * Seed the interview stage from the node (e.g. on node ready/added),
			 * instead of leaving it to the granular interview progress events
			 */
			updateInterviewStage?: boolean
		},
	) {
		const { updateStatusOnly = false, updateInterviewStage = false } =
			options ?? {}

		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

			// The interview stage is normally driven by the granular `interview
			// progress` events; only seed it from the node here when explicitly
			// requested (e.g. node ready / added), so status changes like
			// wake/sleep/alive/dead don't regress it during an interview.
			if (updateInterviewStage) {
				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,
				}
			}

			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[]
	) {
		// Some CC events (User Credential CC, User Code CC) emit
		// `(endpoint, payload)` — Endpoint has `nodeId` but not `id`.
		// Regular node events emit `(node, ...args)` where `id === nodeId`.
		const nodeId = zwaveNode?.nodeId ?? zwaveNode?.id
		const node = this._nodes.get(nodeId)

		if (node) {
			if (
				eventName === 'credential added' ||
				eventName === 'credential modified'
			) {
				// Strip credential payloads before events enter the shared socket queue
				eventArgs = eventArgs.map((arg) => {
					if (!arg || typeof arg !== 'object' || !('data' in arg)) {
						return arg
					}
					const redacted = { ...arg }
					delete redacted.data
					return redacted
				})
			}
			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,
				values,
			)

			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, { updateInterviewStage: true })

		// Check for matching configuration templates
		this._checkConfigurationTemplates(node, 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}`,
					)
				},
			)
		}

		if (this._supportedAccessControlEndpointIndices(zwaveNode).length > 0) {
			this.logNode(
				zwaveNode,
				'info',
				`Access control (User Credential / User Code) supported`,
			)
			try {
				this._refreshAccessControlState(zwaveNode)
			} catch (e) {
				this.logNode(
					zwaveNode,
					'warn',
					`Failed to read access control state: ${(e as 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)
		}

		// Update broadcast nodes when a node becomes ready
		this._updateBroadcastNodeValues()
	}

	/**
	 * Update a node's interview progress and notify the UI.
	 * When `throttle` is set the emit is rate-limited, otherwise it is emitted immediately.
	 */
	private _setInterviewProgress(
		zwaveNode: ZWaveNode,
		progress: number,
		stage?: keyof typeof InterviewStage,
		throttle = false,
	) {
		const node = this._nodes.get(zwaveNode.id)
		if (!node) return

		node.interviewProgress = progress
		const changedProps: utils.DeepPartial<ZUINode> = {
			interviewProgress: progress,
		}
		if (stage !== undefined) {
			node.interviewStage = stage
			changedProps.interviewStage = stage
		}

		if (throttle) {
			// send at most 4msg per second
			this.throttle(
				'_setInterviewProgress_' + node.id,
				this.emitNodeUpdate.bind(this, node, changedProps),
				250,
			)
		} else {
			this.emitNodeUpdate(node, changedProps)
		}
	}

	/**
	 * 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')

		// Z-Wave JS sends interview progress events - no need to change the progress here.

		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, { updateStatusOnly: 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, { updateStatusOnly: true })

		this._checkNodeFirmwareUpdates(zwaveNode.id).catch((error) => {
			this.logNode(
				zwaveNode,
				'error',
				`Failed to check firmware updates after interview: ${error.message}`,
			)
		})

		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}`,
		)

		// Reset the progress: the interview stopped before completing.
		this._setInterviewProgress(zwaveNode, 0)

		this._onNodeStatus(zwaveNode, { updateStatusOnly: 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, { updateStatusOnly: true })

		const node = this._nodes.get(zwaveNode.id)
		if (node) {
			node.lastAwake = Date.now()
			this.emitNodeUpdate(node, {
				lastAwake: node.lastAwake,
			} as utils.DeepPartial<ZUINode>)
		}

		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, { updateStatusOnly: 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, { updateStatusOnly: 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, { updateStatusOnly: 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

		switch (ccId) {
			case CommandClasses.Notification:
				valueId.property = args.label
				valueId.propertyKey = args.eventLabel
				data = this._parseNotification(args.parameters)
				break
			case CommandClasses['Entry Control']:
				valueId.property = args.eventType.toString()
				valueId.propertyKey = args.dataType
				data = isUint8Array(args.eventData)
					? utils.buffer2hex(args.eventData)
					: args.eventData
				break
			case CommandClasses['Multilevel Switch']:
				valueId.property = getEnumMemberName(
					MultilevelSwitchCommand,
					args.eventType as number,
				)
				data = args.direction
				break
			case CommandClasses.Powerlevel:
				// ignore, this should be handled in zwave-js
				return
			case CommandClasses['Battery']:
				valueId.property = args.eventType
				data = getEnumMemberName(BatteryReplacementStatus, args.urgency)
				break
			default:
				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,
			)
		}

	/**
	 * Emitted when we receive a node `interview progress` event
	 *
	 */
	private _onNodeInterviewProgress(
		zwaveNode: ZWaveNode,
		progress: InterviewProgress,
	) {
		this._setInterviewProgress(
			zwaveNode,
			Math.round(progress.progress),
			InterviewStage[progress.stage] as keyof typeof InterviewStage,
			true,
		)
	}

	/**
	 * 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.clearThrottle(
					this._onNodeFirmwareUpdateProgress.name + '_' + node.id,
				)

				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)}.`,
			)

			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('interview progress', this._onNodeInterviewProgress.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',
			'user added',
			'user modified',
			'user deleted',
			'credential added',
			'credential modified',
			'credential deleted',
			'credential learn progress',
			'credential learn completed',
		]

		for (const event of events) {
			zwaveNode.on(event, this._onNodeEvent.bind(this, event))
		}

		const refreshOnAccessControlChange = (event: ZwaveNodeEvents) => {
			zwaveNode.on(event as any, () => {
				this._refreshAccessControlState(zwaveNode)
			})
		}
		;(
			[
				'user added',
				'user modified',
				'user deleted',
				'credential added',
				'credential modified',
				'credential deleted',
			] as const
		).forEach((e) => refreshOnAccessControlChange(e))
	}

	/**
	 * 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)
		this._pendingInclusionNodeIds.delete(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,
			availableFirmwareUpdates:
				this.storeNodes[nodeId]?.availableFirmwareUpdates || [],
			firmwareUpdatesDismissed:
				this.storeNodes[nodeId]?.firmwareUpdatesDismissed || {},
			lastFirmwareUpdateCheck:
				this.storeNodes[nodeId]?.lastFirmwareUpdateCheck || 0,
			appliedTemplateContentHashes:
				this.storeNodes[nodeId]?.appliedTemplateContentHashes || [],
		}

		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, { updateInterviewStage: true })

		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,
						title: getEnumMemberName(RFRegion, region),
						disabled:
							region === RFRegion.Unknown ||
							region === RFRegion['Default (EU)'],
					}))
					.sort((a, b) => a.title.localeCompare(b.title)) ?? []
		}
	}

	async updateControllerNodeProps(
		node?: ZUINode,
		props: Array<'powerlevel' | 'RFRegion' | 'maxLongRangePowerlevel'> = [
			'powerlevel',
			'RFRegion',
			'maxLongRangePowerlevel',
		],
	) {
		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
			}
		}

		if (props.includes('maxLongRangePowerlevel')) {
			if (
				this._driver.controller.isSerialAPISetupCommandSupported(
					SerialAPISetupCommand.GetLongRangeMaximumTxPower,
				)
			) {
				const limit =
					await this._driver.controller.getMaxLongRangePowerlevel()
				node.maxLongRangePowerlevel = limit
			} else {
				logger.info('LR powerlevel is not supported by controller')
			}
		}

		this.emitNodeUpdate(node, {
			powerlevel: node.powerlevel,
			measured0dBm: node.measured0dBm,
			RFRegion: node.RFRegion,
			supportsLongRange: node.supportsLongRange,
			maxLongRangePowerlevel: node.maxLongRangePowerlevel,
		})
	}

	/**
	 * 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,
			)
		}

		this._applyValueMetadataFields(valueId, zwaveValueMeta)

		return valueId
	}

	/**
	 * Apply type-specific metadata fields (numeric, string, states, destructive)
	 * to a ZUIValueId. Shared between physical and virtual node value creation.
	 */
	private _applyValueMetadataFields(
		valueId: ZUIValueId,
		meta: ValueMetadata,
	): void {
		if (meta.type === 'number') {
			const numMeta = meta as ValueMetadataNumeric

			valueId.min = numMeta.min
			valueId.max = numMeta.max
			valueId.step = numMeta.steps
			valueId.allowed = numMeta.allowed
			valueId.unit = numMeta.unit

			if (numMeta.states && Object.keys(numMeta.states).length > 0) {
				valueId.list = true
				valueId.allowManualEntry = (
					numMeta as ConfigurationMetadata
				).allowManualEntry
				// If allowManualEntry is not explicitly set and the value has
				// allowed ranges (not just single values), default to allowing
				// manual entry. This supports the new value format in zwave-js
				// v15.21.0+ where values like Wake Up Interval have named states
				// (e.g., "Default", "Disabled") but also allow custom values
				// within a range.
				if (
					valueId.allowManualEntry === undefined &&
					numMeta.allowed?.some(
						(entry) => 'from' in entry && 'to' in entry,
					)
				) {
					valueId.allowManualEntry = true
				}
				valueId.states = []
				for (const k in numMeta.states) {
					valueId.states.push({
						text: numMeta.states[k],
						value: parseInt(k),
					})
				}
			} else {
				valueId.list = false
			}

			if ((numMeta as ConfigurationMetadata).destructive) {
				valueId.destructive = true
			}
		} else if (meta.type === 'string' || meta.type === 'color') {
			const strMeta = meta as ValueMetadataString
			valueId.minLength = strMeta.minLength
			valueId.maxLength = strMeta.maxLength
			valueId.list = false
		} else if (meta.type === 'boolean') {
			const boolStates = (meta as { states?: Record<string, string> })
				.states
			if (boolStates && Object.keys(boolStates).length > 0) {
				valueId.list = true
				valueId.states = []
				for (const k in boolStates) {
					valueId.states.push({
						text: boolStates[k],
						value: k === 'true',
					})
				}
			} else {
				valueId.list = false
			}
		} else {
			valueId.list = false
		}
	}

	/**
	 * Add a node value to our node values
	 *
	 */
	private _addValue(
		zwaveNode: ZWaveNode,
		zwaveValue: TranslatedValueID,
		oldValues?: {
			[key: string]: ZUIValueId
		},
		skipUpdate = false,
		definedValueIds?: TranslatedValueID[],
	) {
		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,
				definedValueIds,
			)

			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,
		definedValueIds?: TranslatedValueID[],
	) {
		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
			// Reuse the caller's list because getDefinedValueIDs() rebuilds it on every call
			const targetValue = this._findTargetValue(
				valueId,
				definedValueIds ?? 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
					// reset is itself a transition (X -> undefined), so the UI
					// must be told even after the no-op suppression in emitValueChanged
					this.emitValueChanged(valueId, node, true)
				}, 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,
		)
	}

	private async _scheduledFirmwareUpdateCheck() {
		// Check if automatic firmware update checks are disabled
		if (this.cfg.disableAutomaticFirmwareUpdateChecks) {
			logger.info('Automatic firmware update checks are disabled')
			return
		}

		try {
			await this.checkAllNodesFirmwareUpdates()
		} catch (error) {
			logger.warn(
				`Scheduled firmware update check has failed: ${error.message}`,
			)
		}

		// Schedule next check for a random delay between 23 and 25 hours to avoid thundering herd problem
		const minHours = 23
		const maxHours = 25
		const randomHours = minHours + Math.random() * (maxHours - minHours)
		const waitMillis = randomHours * 60 * 60 * 1000

		const nextCheckTime = new Date(Date.now() + waitMillis)
		logger.info(
			`Next firmware update check scheduled for: ${nextCheckTime}`,
		)

		this.firmwareUpdateCheckTimeout = setTimeout(
			this._scheduledFirmwareUpdateCheck.bind(this),
			waitMillis,
		)
	}

	/**
	 * 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 utils.pathExists(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
				node.inited = false // so nodeInited event is triggered
				node.hassDevices = {}
				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._onOTWFirmwareUpdateFinished({
						status: OTWFirmwareUpdateStatus.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._onOTWFirmwareUpdateProgress({
					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
