/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Supervision } from "#behavior/supervision/Supervision.js"; import { AesCipher, Bytes, type Cipher, Crypto, ImplementationError, MaybePromise, Seconds, Time, Timer, } from "@matter/general"; import { field, listOf, nonvolatile, octstr } from "@matter/model"; import { FabricIndex, NodeId, Status, StatusResponseError } from "@matter/types"; import { DoorLock } from "@matter/types/clusters/door-lock"; import { DoorLockBehavior } from "./DoorLockBehavior.js"; import { LockAuth } from "./LockAuth.js"; import { LockSchedule } from "./LockSchedule.js"; import AlarmCode = DoorLock.AlarmCode; import CredentialRule = DoorLock.CredentialRule; import CredentialType = DoorLock.CredentialType; import DataOperationType = DoorLock.DataOperationType; import DoorState = DoorLock.DoorState; import LockDataType = DoorLock.LockDataType; import LockOperationType = DoorLock.LockOperationType; import LockState = DoorLock.LockState; import OperationError = DoorLock.OperationError; import OperationSource = DoorLock.OperationSource; import UserStatus = DoorLock.UserStatus; import UserType = DoorLock.UserType; const DoorLockBaseServerClass = DoorLockBehavior.with( "PinCredential", "RfidCredential", "FingerCredentials", "FaceCredentials", "DoorPositionSensor", "CredentialOverTheAirAccess", "Unbolting", "User", "WeekDayAccessSchedules", "YearDayAccessSchedules", "HolidaySchedules", ); /** * Full DoorLock server implementation covering all features including User and schedules. * * The server stores users, credentials, and schedules in nonvolatile extension fields on the State class. Credential * data is encrypted at rest with AES-128-CCM by default. Override {@link cipher} for custom encryption (e.g. HSM) or * {@link auth} to replace the entire storage backend. */ export class DoorLockBaseServer extends DoorLockBaseServerClass { declare readonly state: DoorLockBaseServer.State; declare readonly internal: DoorLockBaseServer.Internal; override initialize(): MaybePromise { const state = this.state; // Default supportedOperatingModes if (!Object.values(state.supportedOperatingModes).some(v => v)) { state.supportedOperatingModes = { vacation: true, privacy: true, passage: true, alwaysSet: 2047 }; } else if (state.supportedOperatingModes.alwaysSet !== 2047) { throw new ImplementationError( `DoorLockServer: The "alwaysSet" bit-range in supportedOperatingModes must be set. Please check the` + ` specification about the meaning of this field because bits are inverted here!`, ); } // Initialize internal stores if empty if (!state.users) { state.users = []; } if (!state.credentials) { state.credentials = []; } if (!state.weekDaySchedules) { state.weekDaySchedules = []; } if (!state.yearDaySchedules) { state.yearDaySchedules = []; } if (!state.holidaySchedules) { state.holidaySchedules = []; } // Generate encryption key if not present; re-encrypt existing plaintext credentials if (!state.credentialKey) { state.credentialKey = Bytes.of(this.env.get(Crypto).randomBytes(16)); if (state.credentials.length > 0) { const auth = this.auth; state.credentials = state.credentials.map(c => ({ ...c, credentialData: auth.encrypt(c.credentialData), })); } } // Subscribe to doorState changes for DPS events this.reactTo(this.events.doorState$Changed, this.#handleDoorStateChange); } override [Symbol.asyncDispose](): MaybePromise { this.#stopAutoRelockTimer(); return super[Symbol.asyncDispose](); } // ── Core Lock Operations ───────────────────────────────────────────────── override lockDoor(request: DoorLock.LockDoorRequest): MaybePromise { this.#validatePinIfRequired(request.pinCode, LockOperationType.Lock); this.state.lockState = LockState.Locked; this.#stopAutoRelockTimer(); this.#emitLockOperation(LockOperationType.Lock, request.pinCode); } override unlockDoor(request: DoorLock.UnlockDoorRequest): MaybePromise { this.#validatePinIfRequired(request.pinCode, LockOperationType.Unlock); this.state.lockState = LockState.Unlocked; this.#emitLockOperation(LockOperationType.Unlock, request.pinCode); this.#scheduleAutoRelock(); } override unlockWithTimeout(request: DoorLock.UnlockWithTimeoutRequest): MaybePromise { this.#validatePinIfRequired(request.pinCode, LockOperationType.Unlock); this.state.lockState = LockState.Unlocked; this.#emitLockOperation(LockOperationType.Unlock, request.pinCode); this.#scheduleAutoRelock(request.timeout); } override unboltDoor(request: DoorLock.UnboltDoorRequest): MaybePromise { this.#validatePinIfRequired(request.pinCode, LockOperationType.Unlatch); this.state.lockState = LockState.Unlatched; this.#emitLockOperation(LockOperationType.Unlatch, request.pinCode); this.#scheduleAutoRelock(); } // ── Credential Storage (overridable) ───────────────────────────────────── // // The default implementation uses reversible encryption (AES-128-CCM) rather than hashing because the legacy // non-User commands (getPinCode, getRfidCode) must return plaintext credential data. Deployments where credential // secrecy is critical should override `cipher` (for HSM-backed encryption) or `auth` (for an external credential // store). /** * The cipher used for credential encryption. Override to integrate with an HSM or other secure storage. */ get cipher(): Cipher { return new AesCipher(this.env.get(Crypto), this.state.credentialKey!); } /** * The credential/user store with integrated encryption. Override to replace the entire storage backend. */ get auth(): LockAuth.Store { return new LockAuth.Store(this.state, this.cipher); } // ── User Database (USR) ────────────────────────────────────────────────── override setUser(request: DoorLock.SetUserRequest): MaybePromise { const { operationType, userIndex } = request; const maxUsers = this.state.numberOfTotalUsersSupported; const fabricIndex = this.#fabricIndex; if (userIndex < 1 || userIndex > maxUsers) { throw new StatusResponseError("Invalid user index", Status.InvalidCommand); } const auth = this.auth; const existing = auth.findUser(userIndex); if (operationType === DataOperationType.Add) { if (existing) { throw new DoorLock.OccupiedError("User slot is occupied"); } auth.addUser({ userIndex, userName: request.userName ?? "", userUniqueId: request.userUniqueId ?? null, userStatus: request.userStatus ?? UserStatus.OccupiedEnabled, userType: request.userType ?? UserType.UnrestrictedUser, credentialRule: request.credentialRule ?? CredentialRule.Single, credentials: [], creatorFabricIndex: fabricIndex, lastModifiedFabricIndex: fabricIndex, }); this.#emitLockUserChange(LockDataType.UserIndex, DataOperationType.Add, userIndex, fabricIndex, null); } else if (operationType === DataOperationType.Modify) { if (!existing) { throw new StatusResponseError("User slot is available", Status.InvalidCommand); } if (request.userName !== null && fabricIndex !== existing.creatorFabricIndex) { throw new StatusResponseError("Cannot modify userName from different fabric", Status.InvalidCommand); } if (request.userUniqueId !== null && fabricIndex !== existing.creatorFabricIndex) { throw new StatusResponseError( "Cannot modify userUniqueId from different fabric", Status.InvalidCommand, ); } auth.replaceUser(userIndex, { ...existing, userName: request.userName ?? existing.userName, userUniqueId: request.userUniqueId ?? existing.userUniqueId, userStatus: request.userStatus ?? existing.userStatus, userType: request.userType ?? existing.userType, credentialRule: request.credentialRule ?? existing.credentialRule, lastModifiedFabricIndex: fabricIndex, }); this.#emitLockUserChange(LockDataType.UserIndex, DataOperationType.Modify, userIndex, fabricIndex, null); } else { throw new StatusResponseError("Invalid operation type", Status.InvalidCommand); } } override getUser(request: DoorLock.GetUserRequest): DoorLock.GetUserResponse { const maxUsers = this.state.numberOfTotalUsersSupported; if (request.userIndex < 1 || request.userIndex > maxUsers) { throw new StatusResponseError("Invalid user index", Status.InvalidCommand); } const auth = this.auth; const user = auth.findUser(request.userIndex); const nextUserIndex = auth.findNextOccupiedUserIndex(request.userIndex); if (!user) { return { userIndex: request.userIndex, userName: null, userUniqueId: null, userStatus: null, userType: null, credentialRule: null, credentials: null, creatorFabricIndex: null, lastModifiedFabricIndex: null, nextUserIndex, }; } return { userIndex: user.userIndex, userName: user.userName, userUniqueId: user.userUniqueId, userStatus: user.userStatus, userType: user.userType, credentialRule: user.credentialRule, credentials: [...user.credentials], creatorFabricIndex: user.creatorFabricIndex, lastModifiedFabricIndex: user.lastModifiedFabricIndex, nextUserIndex, }; } override clearUser(request: DoorLock.ClearUserRequest): MaybePromise { const { userIndex } = request; const maxUsers = this.state.numberOfTotalUsersSupported; const fabricIndex = this.#fabricIndex; const auth = this.auth; if (userIndex === 0xfffe) { for (const user of auth.users) { this.#clearCredentialsForUser(auth, user.userIndex); this.#clearSchedulesForUser(user.userIndex); } auth.clearUsers(); this.#emitLockUserChange(LockDataType.UserIndex, DataOperationType.Clear, 0xfffe, fabricIndex, null); return; } if (userIndex < 1 || userIndex > maxUsers) { throw new StatusResponseError("Invalid user index", Status.InvalidCommand); } const user = auth.findUser(userIndex); if (!user) { // SDK clears nonexistent users silently — no error return; } this.#clearCredentialsForUser(auth, userIndex); this.#clearSchedulesForUser(userIndex); auth.removeUser(userIndex); this.#emitLockUserChange(LockDataType.UserIndex, DataOperationType.Clear, userIndex, fabricIndex, null); } // ── Credential Management (USR) ────────────────────────────────────────── override setCredential(request: DoorLock.SetCredentialRequest): DoorLock.SetCredentialResponse { const { operationType, credential, credentialData, userIndex, userStatus, userType } = request; const fabricIndex = this.#fabricIndex; const maxCredentials = this.#maxCredentialsForType(credential.credentialType); const auth = this.auth; if (credential.credentialIndex < 0 || credential.credentialIndex > maxCredentials) { return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex: null }; } if (!this.#validateCredentialDataLength(credential.credentialType, credentialData)) { return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex: null }; } if (auth.isDuplicateCredential(credential.credentialType, credentialData, credential.credentialIndex)) { return { status: Status.Failure, userIndex: null, nextCredentialIndex: null }; } const nextCredentialIndex = auth.findNextAvailableCredentialIndex( credential.credentialType, credential.credentialIndex, maxCredentials, ); if (operationType === DataOperationType.Add) { const existingCred = auth.findCredential(credential.credentialType, credential.credentialIndex); if (existingCred) { return { status: Status.Failure, userIndex: null, nextCredentialIndex }; } auth.addCredential(credential.credentialType, credential.credentialIndex, credentialData, fabricIndex); let createdUserIndex: number | null = null; if (userIndex === null) { const newUserIndex = auth.findAvailableUserIndex(this.state.numberOfTotalUsersSupported); if (newUserIndex === null) { auth.removeCredential(credential.credentialType, credential.credentialIndex); return { status: Status.ResourceExhausted, userIndex: null, nextCredentialIndex }; } auth.addUser({ userIndex: newUserIndex, userName: "", userUniqueId: 0xffffffff, userStatus: userStatus ?? UserStatus.OccupiedEnabled, userType: userType ?? UserType.UnrestrictedUser, credentialRule: CredentialRule.Single, credentials: [ { credentialType: credential.credentialType, credentialIndex: credential.credentialIndex }, ], creatorFabricIndex: fabricIndex, lastModifiedFabricIndex: fabricIndex, }); createdUserIndex = newUserIndex; this.#emitLockUserChange( LockDataType.UserIndex, DataOperationType.Add, newUserIndex, fabricIndex, credential.credentialIndex, ); } else { const user = auth.findUser(userIndex); if (!user) { auth.removeCredential(credential.credentialType, credential.credentialIndex); return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex }; } if (fabricIndex !== user.creatorFabricIndex) { auth.removeCredential(credential.credentialType, credential.credentialIndex); return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex }; } if (user.credentials.length >= this.state.numberOfCredentialsSupportedPerUser) { auth.removeCredential(credential.credentialType, credential.credentialIndex); return { status: Status.ResourceExhausted, userIndex: null, nextCredentialIndex }; } auth.replaceUser(userIndex, { ...user, credentials: [ ...user.credentials, { credentialType: credential.credentialType, credentialIndex: credential.credentialIndex }, ], lastModifiedFabricIndex: fabricIndex, }); } this.#emitLockUserChange( LockAuth.credentialTypeToLockDataType(credential.credentialType), DataOperationType.Add, userIndex ?? createdUserIndex, fabricIndex, credential.credentialIndex, ); return { status: Status.Success, userIndex: createdUserIndex, nextCredentialIndex }; } else if (operationType === DataOperationType.Modify) { const existingCred = auth.findCredential(credential.credentialType, credential.credentialIndex); if (!existingCred) { return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex }; } if (fabricIndex !== existingCred.creatorFabricIndex) { return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex }; } if (userIndex !== null) { const user = auth.findUser(userIndex); if (!user) { return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex }; } if (fabricIndex !== user.creatorFabricIndex) { return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex }; } const hasCredential = user.credentials.some( c => c.credentialType === credential.credentialType && c.credentialIndex === credential.credentialIndex, ); if (!hasCredential) { return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex }; } } auth.updateCredentialData( credential.credentialType, credential.credentialIndex, credentialData, fabricIndex, ); this.#emitLockUserChange( LockAuth.credentialTypeToLockDataType(credential.credentialType), DataOperationType.Modify, userIndex, fabricIndex, credential.credentialIndex, ); return { status: Status.Success, userIndex: null, nextCredentialIndex }; } return { status: Status.InvalidCommand, userIndex: null, nextCredentialIndex: null }; } override getCredentialStatus(request: DoorLock.GetCredentialStatusRequest): DoorLock.GetCredentialStatusResponse { const { credential } = request; const auth = this.auth; const cred = auth.findCredential(credential.credentialType, credential.credentialIndex); const nextCredentialIndex = auth.findNextOccupiedCredentialIndex( credential.credentialType, credential.credentialIndex, ); if (!cred) { return { credentialExists: false, userIndex: null, creatorFabricIndex: null, lastModifiedFabricIndex: null, nextCredentialIndex, }; } const userIndex = auth.findUserIndexForCredential(credential.credentialType, credential.credentialIndex); return { credentialExists: true, userIndex, creatorFabricIndex: cred.creatorFabricIndex, lastModifiedFabricIndex: cred.lastModifiedFabricIndex, nextCredentialIndex, }; } override clearCredential(request: DoorLock.ClearCredentialRequest): MaybePromise { const { credential } = request; const fabricIndex = this.#fabricIndex; const auth = this.auth; if (credential === null) { const typesToClear = [ CredentialType.Pin, CredentialType.Rfid, CredentialType.Fingerprint, CredentialType.FingerVein, CredentialType.Face, ]; for (const type of typesToClear) { this.#clearAllCredentialsOfType(auth, type, fabricIndex); } return; } if (credential.credentialType === CredentialType.ProgrammingPin) { throw new StatusResponseError("Cannot clear ProgrammingPIN", Status.InvalidCommand); } if (credential.credentialIndex === 0xfffe) { this.#clearAllCredentialsOfType(auth, credential.credentialType, fabricIndex); return; } const cred = auth.findCredential(credential.credentialType, credential.credentialIndex); if (!cred) { throw new StatusResponseError("Credential not found", Status.InvalidCommand); } this.#removeCredentialFromUsers(auth, credential.credentialType, credential.credentialIndex); auth.removeCredential(credential.credentialType, credential.credentialIndex); this.#emitLockUserChange( LockAuth.credentialTypeToLockDataType(credential.credentialType), DataOperationType.Clear, null, fabricIndex, credential.credentialIndex, ); } // ── Schedules (WDSCH, YDSCH, HDSCH) ───────────────────────────────────── override setWeekDaySchedule(request: DoorLock.SetWeekDayScheduleRequest): MaybePromise { const maxSchedules = this.state.numberOfWeekDaySchedulesSupportedPerUser; const { weekDayIndex, userIndex } = request; if (weekDayIndex < 1 || weekDayIndex > maxSchedules) { throw new StatusResponseError("Invalid schedule index", Status.InvalidCommand); } this.#requireValidUser(userIndex); if (request.endHour < request.startHour) { throw new StatusResponseError("End hour must be >= start hour", Status.InvalidCommand); } if (request.endHour === request.startHour && request.endMinute <= request.startMinute) { throw new StatusResponseError("End time must be after start time", Status.InvalidCommand); } const schedules = this.state.weekDaySchedules.filter( s => !(s.weekDayIndex === weekDayIndex && s.userIndex === userIndex), ); const schedule: LockSchedule.WeekDay = { weekDayIndex, userIndex, daysMask: request.daysMask, startHour: request.startHour, startMinute: request.startMinute, endHour: request.endHour, endMinute: request.endMinute, }; this.state.weekDaySchedules = [...schedules, schedule]; } override getWeekDaySchedule(request: DoorLock.GetWeekDayScheduleRequest): DoorLock.GetWeekDayScheduleResponse { const maxSchedules = this.state.numberOfWeekDaySchedulesSupportedPerUser; const { weekDayIndex, userIndex } = request; if ( weekDayIndex < 1 || weekDayIndex > maxSchedules || userIndex < 1 || userIndex > this.state.numberOfTotalUsersSupported ) { const response = { weekDayIndex, userIndex, status: Status.InvalidCommand }; Supervision(response, "weekDayIndex").constraint = false; Supervision(response, "userIndex").constraint = false; return response; } if (!this.auth.findUser(userIndex)) { return { weekDayIndex, userIndex, status: Status.NotFound }; } const schedule = this.state.weekDaySchedules.find( s => s.weekDayIndex === weekDayIndex && s.userIndex === userIndex, ); if (!schedule) { return { weekDayIndex, userIndex, status: Status.NotFound }; } return { weekDayIndex, userIndex, status: Status.Success, daysMask: schedule.daysMask, startHour: schedule.startHour, startMinute: schedule.startMinute, endHour: schedule.endHour, endMinute: schedule.endMinute, }; } override clearWeekDaySchedule(request: DoorLock.ClearWeekDayScheduleRequest): MaybePromise { const { weekDayIndex, userIndex } = request; this.#requireValidUser(userIndex); if (weekDayIndex === 0xfe) { this.state.weekDaySchedules = this.state.weekDaySchedules.filter(s => s.userIndex !== userIndex); } else { const maxSchedules = this.state.numberOfWeekDaySchedulesSupportedPerUser; if (weekDayIndex < 1 || weekDayIndex > maxSchedules) { throw new StatusResponseError("Invalid schedule index", Status.InvalidCommand); } this.state.weekDaySchedules = this.state.weekDaySchedules.filter( s => !(s.weekDayIndex === weekDayIndex && s.userIndex === userIndex), ); } } override setYearDaySchedule(request: DoorLock.SetYearDayScheduleRequest): MaybePromise { const maxSchedules = this.state.numberOfYearDaySchedulesSupportedPerUser; const { yearDayIndex, userIndex } = request; if (yearDayIndex < 1 || yearDayIndex > maxSchedules) { throw new StatusResponseError("Invalid schedule index", Status.InvalidCommand); } this.#requireValidUser(userIndex); if (request.localEndTime <= request.localStartTime) { throw new StatusResponseError("End time must be after start time", Status.InvalidCommand); } const schedules = this.state.yearDaySchedules.filter( s => !(s.yearDayIndex === yearDayIndex && s.userIndex === userIndex), ); const schedule: LockSchedule.YearDay = { yearDayIndex, userIndex, localStartTime: request.localStartTime, localEndTime: request.localEndTime, }; this.state.yearDaySchedules = [...schedules, schedule]; } override getYearDaySchedule(request: DoorLock.GetYearDayScheduleRequest): DoorLock.GetYearDayScheduleResponse { const maxSchedules = this.state.numberOfYearDaySchedulesSupportedPerUser; const { yearDayIndex, userIndex } = request; if ( yearDayIndex < 1 || yearDayIndex > maxSchedules || userIndex < 1 || userIndex > this.state.numberOfTotalUsersSupported ) { const response = { yearDayIndex, userIndex, status: Status.InvalidCommand }; Supervision(response, "yearDayIndex").constraint = false; Supervision(response, "userIndex").constraint = false; return response; } if (!this.auth.findUser(userIndex)) { return { yearDayIndex, userIndex, status: Status.NotFound }; } const schedule = this.state.yearDaySchedules.find( s => s.yearDayIndex === yearDayIndex && s.userIndex === userIndex, ); if (!schedule) { return { yearDayIndex, userIndex, status: Status.NotFound }; } return { yearDayIndex, userIndex, status: Status.Success, localStartTime: schedule.localStartTime, localEndTime: schedule.localEndTime, }; } override clearYearDaySchedule(request: DoorLock.ClearYearDayScheduleRequest): MaybePromise { const { yearDayIndex, userIndex } = request; this.#requireValidUser(userIndex); if (yearDayIndex === 0xfe) { this.state.yearDaySchedules = this.state.yearDaySchedules.filter(s => s.userIndex !== userIndex); } else { const maxSchedules = this.state.numberOfYearDaySchedulesSupportedPerUser; if (yearDayIndex < 1 || yearDayIndex > maxSchedules) { throw new StatusResponseError("Invalid schedule index", Status.InvalidCommand); } this.state.yearDaySchedules = this.state.yearDaySchedules.filter( s => !(s.yearDayIndex === yearDayIndex && s.userIndex === userIndex), ); } } override setHolidaySchedule(request: DoorLock.SetHolidayScheduleRequest): MaybePromise { const maxSchedules = this.state.numberOfHolidaySchedulesSupported; const { holidayIndex } = request; if (holidayIndex < 1 || holidayIndex > maxSchedules) { throw new StatusResponseError("Invalid schedule index", Status.InvalidCommand); } if (request.localEndTime <= request.localStartTime) { throw new StatusResponseError("End time must be after start time", Status.InvalidCommand); } if ( request.operatingMode !== DoorLock.OperatingMode.Normal && request.operatingMode !== DoorLock.OperatingMode.Vacation && request.operatingMode !== DoorLock.OperatingMode.Privacy && request.operatingMode !== DoorLock.OperatingMode.NoRemoteLockUnlock && request.operatingMode !== DoorLock.OperatingMode.Passage ) { throw new StatusResponseError("Invalid operating mode", Status.InvalidCommand); } const schedules = this.state.holidaySchedules.filter(s => s.holidayIndex !== holidayIndex); const schedule: LockSchedule.Holiday = { holidayIndex, localStartTime: request.localStartTime, localEndTime: request.localEndTime, operatingMode: request.operatingMode, }; this.state.holidaySchedules = [...schedules, schedule]; } override getHolidaySchedule(request: DoorLock.GetHolidayScheduleRequest): DoorLock.GetHolidayScheduleResponse { const maxSchedules = this.state.numberOfHolidaySchedulesSupported; const { holidayIndex } = request; if (holidayIndex < 1 || holidayIndex > maxSchedules) { const response = { holidayIndex, status: Status.InvalidCommand }; Supervision(response, "holidayIndex").constraint = false; return response; } const schedule = this.state.holidaySchedules.find(s => s.holidayIndex === holidayIndex); if (!schedule) { return { holidayIndex, status: Status.NotFound }; } return { holidayIndex, status: Status.Success, localStartTime: schedule.localStartTime, localEndTime: schedule.localEndTime, operatingMode: schedule.operatingMode, }; } override clearHolidaySchedule(request: DoorLock.ClearHolidayScheduleRequest): MaybePromise { const { holidayIndex } = request; if (holidayIndex === 0xfe) { this.state.holidaySchedules = []; } else { const maxSchedules = this.state.numberOfHolidaySchedulesSupported; if (holidayIndex < 1 || holidayIndex > maxSchedules) { throw new StatusResponseError("Invalid schedule index", Status.InvalidCommand); } this.state.holidaySchedules = this.state.holidaySchedules.filter(s => s.holidayIndex !== holidayIndex); } } // ── Private Helpers ──────────────────────────────────────────────────────── get #fabricIndex(): FabricIndex { const fabric = this.context.fabric; if (fabric === undefined) { throw new StatusResponseError("Fabric required", Status.UnsupportedAccess); } return fabric; } get #sourceNode(): NodeId | null { const context = this.context; if ("subject" in context && context.subject?.kind === "node") { return context.subject.id; } return null; } #stopAutoRelockTimer() { this.internal.autoRelockTimer?.stop(); this.internal.autoRelockTimer = undefined; } #scheduleAutoRelock(timeoutSeconds?: number) { this.#stopAutoRelockTimer(); const timeout = timeoutSeconds ?? this.state.autoRelockTime; if (timeout === undefined || timeout === 0) { return; } this.internal.autoRelockTimer = Time.getTimer( "auto-relock", Seconds(timeout), this.callback(this.#autoRelock, { lock: true }), ).start(); } #autoRelock() { if (this.state.lockState !== LockState.Locked) { this.state.lockState = LockState.Locked; this.events.lockOperation.emit( { lockOperationType: LockOperationType.Lock, operationSource: OperationSource.Auto, userIndex: null, fabricIndex: null, sourceNode: null, credentials: null, }, this.context, ); } } // ── PIN Validation ───────────────────────────────────────────────────────── #validatePinIfRequired(pinCode: Bytes | undefined, operationType: LockOperationType) { const requirePin = this.state.requirePinForRemoteOperation; if (pinCode !== undefined) { this.#validatePin(pinCode, operationType); return; } if (requirePin) { this.#emitLockOperationError(operationType, OperationError.InvalidCredential); throw new StatusResponseError("PIN required for remote operation", Status.Failure); } } #validatePin(pinCode: Bytes, operationType: LockOperationType) { // Check if temporarily disabled due to wrong code entries if (this.state.wrongCodeEntryLimit !== undefined && this.state.userCodeTemporaryDisableTime !== undefined) { if (this.internal.wrongCodeCount >= this.state.wrongCodeEntryLimit) { this.#emitLockOperationError(operationType, OperationError.InvalidCredential); throw new StatusResponseError("User code temporarily disabled", Status.Failure); } } // Search all PIN credentials for a match const auth = this.auth; for (const cred of auth.credentials) { if (cred.credentialType !== CredentialType.Pin) { continue; } const decrypted = auth.decrypt(cred.credentialData); if (Bytes.areEqual(decrypted, pinCode)) { const userIndex = auth.findUserIndexForCredential(CredentialType.Pin, cred.credentialIndex); if (userIndex !== null) { const user = auth.findUser(userIndex); if (user && user.userStatus === UserStatus.OccupiedDisabled) { this.#emitLockOperationError(operationType, OperationError.DisabledUserDenied); throw new StatusResponseError("User is disabled", Status.Failure); } } // Reset wrong code count on success this.internal.wrongCodeCount = 0; return; } } // No match found — wrong code const wrongCount = ++this.internal.wrongCodeCount; if (this.state.wrongCodeEntryLimit !== undefined && wrongCount >= this.state.wrongCodeEntryLimit) { this.events.doorLockAlarm.emit({ alarmCode: AlarmCode.WrongCodeEntryLimit }, this.context); if (this.state.userCodeTemporaryDisableTime !== undefined) { Time.getTimer( "wrong-code-disable", Seconds(this.state.userCodeTemporaryDisableTime), this.callback( () => { this.internal.wrongCodeCount = 0; }, { lock: true }, ), ).start(); } } this.#emitLockOperationError(operationType, OperationError.InvalidCredential); throw new StatusResponseError("Invalid PIN code", Status.Failure); } // ── Event Emission ───────────────────────────────────────────────────────── #emitLockOperation(operationType: LockOperationType, pinCode?: Bytes) { let userIndex: number | null = null; let credentials: DoorLock.Credential[] | null = null; if (pinCode !== undefined) { const auth = this.auth; for (const cred of auth.credentials) { if (cred.credentialType !== CredentialType.Pin) continue; const decrypted = auth.decrypt(cred.credentialData); if (Bytes.areEqual(decrypted, pinCode)) { userIndex = auth.findUserIndexForCredential(CredentialType.Pin, cred.credentialIndex); credentials = [{ credentialType: CredentialType.Pin, credentialIndex: cred.credentialIndex }]; break; } } } this.events.lockOperation.emit( { lockOperationType: operationType, operationSource: OperationSource.Remote, userIndex, fabricIndex: this.#fabricIndex, sourceNode: this.#sourceNode, credentials, }, this.context, ); } #emitLockOperationError(operationType: LockOperationType, error: OperationError) { this.events.lockOperationError.emit( { lockOperationType: operationType, operationSource: OperationSource.Remote, operationError: error, userIndex: null, fabricIndex: this.#fabricIndex, sourceNode: this.#sourceNode, credentials: null, }, this.context, ); } #emitLockUserChange( lockDataType: LockDataType, dataOperationType: DataOperationType, userIndex: number | null, fabricIndex: FabricIndex, dataIndex: number | null, ) { this.events.lockUserChange.emit( { lockDataType, dataOperationType, operationSource: OperationSource.Remote, userIndex, fabricIndex, sourceNode: this.#sourceNode, dataIndex, }, this.context, ); } // ── DPS ──────────────────────────────────────────────────────────────────── #handleDoorStateChange(value: DoorState | null) { if (value === null) { return; } this.events.doorStateChange.emit({ doorState: value }, this.context); if (value === DoorState.DoorOpen) { if (this.state.doorOpenEvents !== undefined) { this.state.doorOpenEvents++; } } else if (value === DoorState.DoorClosed) { if (this.state.doorClosedEvents !== undefined) { this.state.doorClosedEvents++; } } } // ── User/Credential/Schedule helpers ─────────────────────────────────────── #requireValidUser(userIndex: number) { const maxUsers = this.state.numberOfTotalUsersSupported; if (userIndex < 1 || userIndex > maxUsers) { throw new StatusResponseError("Invalid user index", Status.InvalidCommand); } if (!this.auth.findUser(userIndex)) { throw new StatusResponseError("User not found", Status.Failure); } } #maxCredentialsForType(type: CredentialType): number { switch (type) { case CredentialType.Pin: case CredentialType.ProgrammingPin: return this.state.numberOfPinUsersSupported; case CredentialType.Rfid: return this.state.numberOfRfidUsersSupported; default: return this.state.numberOfTotalUsersSupported; } } #validateCredentialDataLength(type: CredentialType, data: Bytes): boolean { switch (type) { case CredentialType.Pin: case CredentialType.ProgrammingPin: { const min = this.state.minPinCodeLength; const max = this.state.maxPinCodeLength; return data.byteLength >= min && data.byteLength <= max; } case CredentialType.Rfid: { const min = this.state.minRfidCodeLength; const max = this.state.maxRfidCodeLength; return data.byteLength >= min && data.byteLength <= max; } default: return true; } } #clearCredentialsForUser(auth: LockAuth.Store, userIndex: number) { const user = auth.findUser(userIndex); if (!user) return; for (const cred of user.credentials) { auth.removeCredential(cred.credentialType, cred.credentialIndex); } } #clearAllCredentialsOfType(auth: LockAuth.Store, type: CredentialType, fabricIndex: FabricIndex) { const credsOfType = auth.credentials.filter(c => c.credentialType === type); for (const cred of credsOfType) { this.#removeCredentialFromUsers(auth, type, cred.credentialIndex); } auth.removeAllCredentialsOfType(type); this.#emitLockUserChange( LockAuth.credentialTypeToLockDataType(type), DataOperationType.Clear, null, fabricIndex, 0xfffe, ); } #removeCredentialFromUsers(auth: LockAuth.Store, type: CredentialType, index: number) { const userIndex = auth.findUserIndexForCredential(type, index); if (userIndex === null) return; const user = auth.findUser(userIndex); if (!user) return; auth.replaceUser(userIndex, { ...user, credentials: user.credentials.filter(c => !(c.credentialType === type && c.credentialIndex === index)), }); } #clearSchedulesForUser(userIndex: number) { this.state.weekDaySchedules = this.state.weekDaySchedules.filter(s => s.userIndex !== userIndex); this.state.yearDaySchedules = this.state.yearDaySchedules.filter(s => s.userIndex !== userIndex); } } // ── Supervision wiring ──────────────────────────────────────────────────────── // // Pattern 1: commands that throw on bad input — remap validation errors to INVALID_COMMAND const throwInvalidCommand = () => { throw new StatusResponseError("Invalid field value", Status.InvalidCommand); }; for (const method of [ "setUser", "getUser", "clearUser", "setCredential", "setWeekDaySchedule", "clearWeekDaySchedule", "setYearDaySchedule", "clearYearDaySchedule", "setHolidaySchedule", "clearHolidaySchedule", ]) { Supervision(DoorLockBaseServer, method).onValidationError = throwInvalidCommand; } // Pattern 2: get*Schedule commands do their own range checks and return a status response rather than // throwing — skip constraint validation on the index fields so the handler receives the raw value for (const fieldName of ["weekDayIndex", "userIndex"]) { Supervision(DoorLockBaseServer, "getWeekDaySchedule", fieldName).constraint = false; } for (const fieldName of ["yearDayIndex", "userIndex"]) { Supervision(DoorLockBaseServer, "getYearDaySchedule", fieldName).constraint = false; } Supervision(DoorLockBaseServer, "getHolidaySchedule", "holidayIndex").constraint = false; export namespace DoorLockBaseServer { export class State extends DoorLockBaseServerClass.State { /** * Internal user database. */ @field(listOf(LockAuth.User), nonvolatile) users: LockAuth.User[] = []; /** * Internal credential store. */ @field(listOf(LockAuth.Credential), nonvolatile) credentials: LockAuth.Credential[] = []; /** * Internal week day schedule store. */ @field(listOf(LockSchedule.WeekDay), nonvolatile) weekDaySchedules: LockSchedule.WeekDay[] = []; /** * Internal year day schedule store. */ @field(listOf(LockSchedule.YearDay), nonvolatile) yearDaySchedules: LockSchedule.YearDay[] = []; /** * Internal holiday schedule store. */ @field(listOf(LockSchedule.Holiday), nonvolatile) holidaySchedules: LockSchedule.Holiday[] = []; /** * AES-128-CCM key for credential encryption. Auto-generated on first use. */ @field(octstr, nonvolatile) credentialKey?: Bytes; } export class Internal { wrongCodeCount = 0; autoRelockTimer?: Timer; } } /** * Default DoorLock server with no features enabled. Use `.with()` to enable features. */ export class DoorLockServer extends DoorLockBaseServer.for(DoorLock) {}