/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { ActionContext } from "#behavior/context/ActionContext.js"; import { Duration, Logger, MaybePromise, Millis, Observable, Seconds, Time, Timer } from "@matter/general"; import { FieldElement } from "@matter/model"; import { Status, StatusResponseError } from "@matter/types"; import { Switch } from "@matter/types/clusters/switch"; import { SwitchBehavior } from "./SwitchBehavior.js"; const DEFAULT_MULTIPRESS_DELAY = Millis(300); const DEFAULT_LONG_PRESS_DELAY = Seconds(2); const logger = Logger.get("SwitchServer"); const SwitchServerBase = SwitchBehavior.with( Switch.Feature.LatchingSwitch, Switch.Feature.MomentarySwitch, Switch.Feature.MomentarySwitchRelease, Switch.Feature.MomentarySwitchLongPress, Switch.Feature.MomentarySwitchMultiPress, ); // Enhance Schema to define conformance for some of the additional state attributes const schema = SwitchServerBase.schema.extend({ children: [ FieldElement({ name: "longPressDelay", type: "duration", conformance: "MSL", default: DEFAULT_LONG_PRESS_DELAY, }), FieldElement({ name: "multiPressDelay", type: "duration", conformance: "MSM", default: DEFAULT_MULTIPRESS_DELAY, }), FieldElement({ name: "momentaryNeutralPosition", type: "uint8", conformance: "[MS]", default: 0, }), ], }); /** * This is the default server implementation of {@link SwitchBehavior}. * * This implementation includes all features of {@link Switch.Cluster} and implements all mandatory commands. * You should use {@link SwitchServer.with} to specialize the class for the features your implementation * supports. * * To support all features and events the implementation adds some custom state attributes that can - or are required * to - be used. These are: * * `rawPosition` - The raw position of the switch. This is the position as reported by the device and is not yet * debounced. If the position is already debounced, just set the `currentPosition` attribute. The debouncing uses the * `debounceDelay` attribute value as timeframe in milliseconds and sets the `currentPosition` attribute when the value * is stable for this timeframe. * * `debounceDelay` - The timeframe in milliseconds to wait until a newly reported position is considered stable. * * `longPressDelay` - The timeframe in milliseconds to wait until a position is considered "long" pressed. This * attribute is REQUIRED if the device supports the Momentary Switch LongPress (MSL) feature. * * `multiPressDelay` - The timeframe in milliseconds starting with a stable release to detect multi-presses. This * attribute is REQUIRED if the device supports the Momentary Switch MultiPress (MSM) feature. * * `momentaryNeutralPosition` - The number of the position considered as the neutral position for the momentary switch. * This defaults to position 0 but can be changed by settings this attribute. it is available as soon as the Momentary * Switch (MS) feature is used. */ export class SwitchBaseServer extends SwitchServerBase { declare protected internal: SwitchBaseServer.Internal; declare readonly state: SwitchBaseServer.State; declare readonly events: SwitchBaseServer.Events; static override readonly schema = schema; override initialize(): MaybePromise { this.state.rawPosition = this.state.currentPosition; this.internal.currentUnstablePosition = this.state.currentPosition; this.internal.previouslyReportedPosition = this.state.currentPosition; // Validate positions set to respect constraints, TODO: Remove with 1.3 update this.reactTo(this.events.rawPosition$Changing, this.#assertPositionInRange); this.reactTo(this.events.currentPosition$Changing, this.#assertPositionInRange); // Debounce raw position changes this.reactTo(this.events.rawPosition$Changed, this.#debounceRawPosition); // Handle switch position changes this.reactTo(this.events.currentPosition$Changed, this.#handleSwitchPositionChange); } /** Method to reset the state of the Switch to start a clean new cycle. Mainly relevant for automated testing. */ resetState() { const neutralPosition = this.state.momentaryNeutralPosition; this.internal.currentUnstablePosition = neutralPosition; this.internal.previouslyReportedPosition = neutralPosition; this.internal.multiPressReportingAborted = false; this.internal.currentNumberOfPressesCounter = 1; this.internal.previousMultiPressPosition = null; this.internal.currentLongPressPosition = null; this.internal.currentIsLongPress = false; this.internal.multiPressTimer?.stop(); this.internal.longPressTimer?.stop(); logger.info("State of Switch got reset"); } // TODO remove when Validator logic can assess that with 1.3 introduction #assertPositionInRange(position: number) { if (position < 0 || position >= this.state.numberOfPositions) { throw new StatusResponseError(`Position ${position} invalid`, Status.ConstraintError); } } #debounceRawPosition(newPosition: number) { // When a debounce delay is set then we debounce the raw position, else we set the current position immediately if (this.state.debounceDelay) { this.internal.debounceTimer?.stop(); this.internal.currentUnstablePosition = newPosition; this.internal.debounceTimer = Time.getTimer( "debounce", this.state.debounceDelay, this.callback(this.#setCurrentPositionAfterDebounce, { lock: true }), ).start(); } else { this.state.currentPosition = newPosition; } } #setCurrentPositionAfterDebounce() { this.state.currentPosition = this.internal.currentUnstablePosition; } #handleSwitchPositionChange(newPosition: number) { // Latching Switch if (this.features.latchingSwitch) { // This event SHALL be generated, when the latching switch is moved to a new position. this.events.switchLatched?.emit({ newPosition }, this.context); return; } let pressSequenceFinished = false; // Momentary Switch if ( newPosition !== this.state.momentaryNeutralPosition && !this.internal.multiPressReportingAborted && (!this.features.actionSwitch || !this.internal.multiPressTimer?.isRunning) ) { // This event SHALL be generated, when the momentary switch starts to be pressed. this.events.initialPress?.emit({ newPosition }, this.context); } if (this.features.momentarySwitchLongPress) { if (newPosition === this.state.momentaryNeutralPosition) { if (this.internal.longPressTimer?.isRunning) { if (!this.features.actionSwitch) { // If the server supports the Momentary Switch LongPress (MSL) feature, this event SHALL be generated // when the switch is released if no LongPress event had been generated since the previous InitialPress // event. this.events.shortRelease?.emit( { previousPosition: this.internal.previouslyReportedPosition }, this.context, ); } } else if (this.internal.currentIsLongPress) { // This event SHALL be generated, when the momentary switch has been released (after debouncing) and // after having been pressed for a long time, i.e. this event SHALL be generated when the switch is // released if a LongPress event has been generated since the previous InitialPress event. this.events.longRelease?.emit( { previousPosition: this.internal.previouslyReportedPosition }, this.context, ); this.internal.multiPressTimer?.stop(); pressSequenceFinished = true; } } this.internal.longPressTimer?.stop(); this.internal.currentIsLongPress = false; this.internal.currentLongPressPosition = null; if (newPosition !== this.state.momentaryNeutralPosition) { this.internal.currentLongPressPosition = newPosition; this.internal.longPressTimer = Time.getTimer( "longPress", this.state.longPressDelay, this.callback(this.#handleLongPress, { lock: true }), ).start(); } } else if (this.features.momentarySwitchRelease && newPosition === this.state.momentaryNeutralPosition) { // If the server does not support the Momentary Switch LongPress (MSL) feature, this event SHALL be generated // when the switch is released - even when the switch was pressed for a long time. this.events.shortRelease?.emit( { previousPosition: this.internal.previouslyReportedPosition }, this.context, ); } if (this.features.momentarySwitchMultiPress) { if ( this.internal.multiPressTimer?.isRunning && newPosition !== this.state.momentaryNeutralPosition && !this.internal.multiPressReportingAborted && !this.internal.currentIsLongPress && !pressSequenceFinished ) { // The Button is at the same position as the time before, so it is a multiPress // Increment the number of presses this.internal.currentNumberOfPressesCounter++; if (!this.features.actionSwitch) { // Emit MultiPressOngoing event this.events.multiPressOngoing?.emit( { newPosition, currentNumberOfPressesCounted: this.internal.currentNumberOfPressesCounter, }, this.context, ); } if ( this.state.multiPressMax !== undefined && this.internal.currentNumberOfPressesCounter > this.state.multiPressMax ) { this.internal.multiPressReportingAborted = true; this.events.multiPressComplete?.emit( { previousPosition: newPosition, totalNumberOfPressesCounted: 0, }, this.context, ); pressSequenceFinished = true; } } this.internal.multiPressTimer?.stop(); if (!pressSequenceFinished) { this.internal.multiPressTimer = Time.getTimer( "multiPress", this.state.multiPressDelay, this.callback(this.#handleMultiPressComplete, { lock: true }), ).start(); } if (this.internal.previouslyReportedPosition !== this.state.momentaryNeutralPosition) { this.internal.previousMultiPressPosition = this.internal.previouslyReportedPosition; } } // Store the currently processed position to be used as "previous" in some events this.internal.previouslyReportedPosition = newPosition; } // Method is called by a timer, so no change to the position for longPressDelay #handleLongPress() { if (this.internal.currentLongPressPosition === null || this.internal.currentNumberOfPressesCounter > 1) { return; } // This event SHALL be generated, when the momentary switch has been pressed for a "long" time. this.events.longPress?.emit({ newPosition: this.internal.currentLongPressPosition }, this.context); this.internal.currentIsLongPress = true; this.internal.multiPressTimer?.stop(); } #handleMultiPressComplete() { if (this.internal.previousMultiPressPosition === null || this.internal.longPressTimer?.isRunning) { return; } if (!this.internal.multiPressReportingAborted) { this.events.multiPressComplete?.emit( { previousPosition: this.internal.previousMultiPressPosition, totalNumberOfPressesCounted: this.internal.currentNumberOfPressesCounter, }, this.context, ); } // Reset the number of presses this.internal.currentNumberOfPressesCounter = 1; this.internal.multiPressReportingAborted = false; this.internal.previousMultiPressPosition = null; } override async [Symbol.asyncDispose]() { this.internal.debounceTimer?.stop(); this.internal.longPressTimer?.stop(); this.internal.multiPressTimer?.stop(); await super[Symbol.asyncDispose]?.(); } } export namespace SwitchBaseServer { export class Internal { /** Timer to debounce the raw position. */ debounceTimer?: Timer; /** Timer to detect a long press. */ longPressTimer?: Timer; /** Timer to detect multi presses. */ multiPressTimer?: Timer; /** Counter to count the number of presses. */ currentNumberOfPressesCounter: number = 1; /** Indicator if the multi press sequence was aborted. */ multiPressReportingAborted = false; /** Position previously reported in events. */ previouslyReportedPosition: number = 0; /** Position of the previous multi press. */ previousMultiPressPosition: number | null = null; /** Position of the current long press. */ currentLongPressPosition: number | null = null; /** Flag to indicate if the current press is a long press. */ currentIsLongPress: boolean = false; /** Position of the current unstable position which is debounced */ currentUnstablePosition: number = 0; } export class State extends SwitchServerBase.State { /** Raw position of the switch. Set this to debounce the value. */ rawPosition: number = 0; /** * Debounce Delay to wait until a newly reported raw position is considered stable and written to the * currentPosition attribute. */ debounceDelay?: Duration; /** Time to wait until a value is considered "long" pressed */ longPressDelay: Duration = DEFAULT_LONG_PRESS_DELAY; /** Timeframe starting with a stable release to detect multi-presses. */ multiPressDelay: Duration = DEFAULT_MULTIPRESS_DELAY; /** Number of the position considered as the neutral position for the momentary switch. */ momentaryNeutralPosition: number = 0; // These are mandatory; provide reasonable defaults override numberOfPositions = 2; override multiPressMax = 2; } /** Enhance the relevant events for rawPosition attribute. */ export class Events extends SwitchServerBase.Events { rawPosition$Changed = Observable<[value: number, oldValue: number, context?: ActionContext]>(); rawPosition$Changing = Observable<[value: number, oldValue: number, context?: ActionContext]>(); } } // Reset all Features export class SwitchServer extends SwitchBaseServer.for(Switch) {}