[matter] Door Lock Pin Code Support (#19762)

* [matter] Door Lock Pin Code Support

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
Dan Cunningham
2025-12-13 20:42:28 +01:00
committed by GitHub
parent 8cf4b4d7ce
commit 01264579d0
15 changed files with 1332 additions and 30 deletions
@@ -227,6 +227,8 @@ Possible channels include:
| colorcontrol-temperature-abs | Number:Temperature | Color Temperature | Sets the color temperature of the light in mirek | ColorLight | | %.0f %unit% |
| doorlock-lockstate | Switch | Door Lock State | Locks and unlocks the door and maintains the lock state | Door | | |
| doorlock-doorstate | Contact | Door Sensor State | Door Sensor State | Door | true | |
| doorlock-alarm | Trigger | Door Lock Alarm | Event that fires when a lock alarm occurs (Lock Jammed=0, Lock Factory Reset=1, Lock Radio Power Cycled=3, Wrong Code Entry Limit=4, Front Escutcheon Removed=5, Door Forced Open=6, Door Ajar=7, Forced User=8) | | | |
| doorlock-lockoperationerror | Trigger | Lock Operation Error | Event that fires when a lock operation error occurs (Lock=0, Unlock=1, Non Access User Event=2, Forced User Event=3, Unlatch=4) | | | |
| fancontrol-fanmode | Number | Fan Mode | Set the fan mode | HVAC | | |
| onoffcontrol-onoff | Switch | Switch | Switches the power on and off | Light | | |
| levelcontrol-level | Dimmer | Dimmer | Sets the level of the light | Light | | |
@@ -326,6 +328,63 @@ Other device types may be supported, but with limited or missing functionality.
| Battery Storage | 0x0018 | Electrical Power Measurement (0x0090), Electrical Energy Measurement (0x0091), Power Source (0x002F) |
| Thread Border Router | 0x0091 | Thread Network Diagnostics (0x0035), Thread Border Router Management (0x0452) |
## Door Lock Management
Matter door locks support user and credential management directly from openHAB.
This allows you to create, modify, and delete users and their PIN codes without needing the lock manufacturer's app.
### Lock Configuration
Door locks expose several configuration options in the Thing configuration:
| Setting | Description |
|----------------------|-------------------------------------------------------------------------------------------------------|
| Operating Mode | The lock's operating mode (Normal, Vacation, Privacy, No Remote Lock/Unlock, Passage) |
| Auto Relock Time | Number of seconds to wait after unlocking before automatically relocking (0 to disable) |
| One Touch Locking | Enable or disable the ability to lock the door with a single touch |
| Default Lock PIN | PIN code to use for remote lock/unlock operations when the lock requires a PIN for remote operations |
### User Management
Door locks that support the "User" feature will display user configuration groups.
The configuration shows all existing users plus up to 5 additional empty slots for adding new users.
As you add more users, additional empty slots will automatically appear after saving to allow further expansion (up to the lock's maximum supported users).
Each user slot allows you to:
- **User Name**: Set a descriptive name for the user (max 10 characters)
- **User Type**: Set the user type (Unrestricted, Schedule Restricted, etc.)
- **PIN Code**: Set a PIN credential for the user. After saving, the PIN is sent to the lock and cleared from the configuration for security
- **Enabled**: Enable or disable the user. A disabled user cannot unlock the door with their credentials.
- **Delete User**: Enable this checkbox and save to delete the user from the lock
#### Fabric Ownership
Matter locks track which controller (fabric) created each user. Users created by other Matter controllers (e.g., Apple Home, Google Home, Amazon Alexa) will appear as "Managed by Other Fabric" in the configuration.
These users can only be deleted, enabled, or disabled from openHAB - editing their name, type, or PIN requires using the controller that originally created them.
### Lock Events
Door locks can emit events through trigger channels:
| Trigger Channel | Description |
|----------------------------|---------------------------------------------------------------------------------------------------|
| doorlock-alarm | Fires when a lock alarm occurs (jammed, factory reset, wrong code limit, door forced open, etc.) |
| doorlock-lockoperationerror| Fires when a lock operation error occurs (failed lock/unlock attempts) |
Example rule to handle lock alarms:
```java
rule "Door Lock Alarm"
when
Channel "matter:node:main:12345678901234567890:1#doorlock-alarm" triggered
then
logInfo("DoorLock", "Lock alarm triggered with code: " + receivedEvent)
// Alarm codes: 0=Jammed, 1=Factory Reset, 3=Radio Power Cycled, 4=Wrong Code Limit,
// 5=Escutcheon Removed, 6=Door Forced Open, 7=Door Ajar, 8=Forced User
end
```
## Full Example
### Thing Configuration
@@ -16,6 +16,7 @@
package org.openhab.binding.matter.internal.client.dto.cluster.gen;
import java.math.BigInteger;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Map;
@@ -71,6 +72,10 @@ import com.google.gson.Gson;
this.value = value;
}
public OctetString(String string, Charset charset) {
this.value = string.getBytes(charset);
}
public OctetString(String hexString) {
int length = hexString.length();
value = new byte[length / 2];
@@ -88,6 +88,12 @@ public class MatterBindingConstants {
public static final String CHANNEL_ID_DOORLOCK_DOORSTATE = "doorlock-doorstate";
public static final ChannelTypeUID CHANNEL_DOORLOCK_DOORSTATE = new ChannelTypeUID(BINDING_ID,
CHANNEL_ID_DOORLOCK_DOORSTATE);
public static final String CHANNEL_ID_DOORLOCK_ALARM = "doorlock-alarm";
public static final ChannelTypeUID CHANNEL_DOORLOCK_ALARM = new ChannelTypeUID(BINDING_ID,
CHANNEL_ID_DOORLOCK_ALARM);
public static final String CHANNEL_ID_DOORLOCK_LOCKOPERATIONERROR = "doorlock-lockoperationerror";
public static final ChannelTypeUID CHANNEL_DOORLOCK_LOCKOPERATIONERROR = new ChannelTypeUID(BINDING_ID,
CHANNEL_ID_DOORLOCK_LOCKOPERATIONERROR);
public static final String CHANNEL_ID_WINDOWCOVERING_LIFT = "windowcovering-lift";
public static final ChannelTypeUID CHANNEL_WINDOWCOVERING_LIFT = new ChannelTypeUID(BINDING_ID,
CHANNEL_ID_WINDOWCOVERING_LIFT);
@@ -409,4 +415,51 @@ public class MatterBindingConstants {
public static final String DISCOVERY_MATTER_UNKNOWN_NODE_LABEL = "@text/discovery.matter.unknown-node.label";
public static final String DISCOVERY_MATTER_SCAN_INPUT_LABEL = "@text/discovery.matter.scan-input.label";
public static final String DISCOVERY_MATTER_SCAN_INPUT_DESCRIPTION = "@text/discovery.matter.scan-input.description";
// Door Lock Configuration Keys
public static final String CONFIG_DOORLOCK_OPERATING_MODE = "operatingMode";
public static final String CONFIG_DOORLOCK_AUTO_RELOCK_TIME = "autoRelockTime";
public static final String CONFIG_DOORLOCK_ONE_TOUCH_LOCKING = "oneTouchLocking";
public static final String CONFIG_DOORLOCK_DEFAULT_LOCK_PIN = "defaultLockPin";
public static final String CONFIG_DOORLOCK_DISPLAYED_USER_COUNT = "displayedUserCount";
public static final String CONFIG_DOORLOCK_USER_NAME = "userName";
public static final String CONFIG_DOORLOCK_USER_TYPE = "userType";
public static final String CONFIG_DOORLOCK_USER_ENABLED = "userEnabled";
public static final String CONFIG_DOORLOCK_PIN_CREDENTIAL = "pinCredential";
public static final String CONFIG_DOORLOCK_DELETE_USER = "deleteUser";
// Door Lock Configuration Group Names
public static final String CONFIG_GROUP_DOORLOCK_MANAGEMENT = "doorLockManagement";
public static final String CONFIG_GROUP_DOORLOCK_USER_PREFIX = "doorLockUser";
// Door Lock Configuration Labels
public static final String CONFIG_LABEL_DOORLOCK_MANAGEMENT = "@text/thing-type.config.matter.node.doorlock_management.label";
public static final String CONFIG_LABEL_DOORLOCK_OPERATING_MODE = "@text/thing-type.config.matter.node.doorlock_operating_mode.label";
public static final String CONFIG_LABEL_DOORLOCK_AUTO_RELOCK_TIME = "@text/thing-type.config.matter.node.doorlock_auto_relock_time.label";
public static final String CONFIG_LABEL_DOORLOCK_ONE_TOUCH_LOCKING = "@text/thing-type.config.matter.node.doorlock_one_touch_locking.label";
public static final String CONFIG_LABEL_DOORLOCK_DEFAULT_LOCK_PIN = "@text/thing-type.config.matter.node.doorlock_default_lock_pin.label";
public static final String CONFIG_LABEL_DOORLOCK_DISPLAYED_USER_COUNT = "@text/thing-type.config.matter.node.doorlock_displayed_user_count.label";
public static final String CONFIG_LABEL_DOORLOCK_USER = "@text/thing-type.config.matter.node.doorlock_user.label";
public static final String CONFIG_LABEL_DOORLOCK_USER_NAME = "@text/thing-type.config.matter.node.doorlock_user_name.label";
public static final String CONFIG_LABEL_DOORLOCK_NO_USER_NAME = "@text/thing-type.config.matter.node.doorlock_no_user_name.label";
public static final String CONFIG_LABEL_DOORLOCK_USER_TYPE = "@text/thing-type.config.matter.node.doorlock_user_type.label";
public static final String CONFIG_LABEL_DOORLOCK_USER_ENABLED = "@text/thing-type.config.matter.node.doorlock_user_enabled.label";
public static final String CONFIG_LABEL_DOORLOCK_PIN_CREDENTIAL = "@text/thing-type.config.matter.node.doorlock_pin_credential.label";
public static final String CONFIG_LABEL_DOORLOCK_DELETE_USER = "@text/thing-type.config.matter.node.doorlock_delete_user.label";
public static final String CONFIG_LABEL_DOORLOCK_EXTERNAL_FABRIC = "@text/thing-type.config.matter.node.doorlock_external_fabric.label";
// Door Lock Configuration Descriptions
public static final String CONFIG_DESC_DOORLOCK_MANAGEMENT = "@text/thing-type.config.matter.node.doorlock_management.description";
public static final String CONFIG_DESC_DOORLOCK_OPERATING_MODE = "@text/thing-type.config.matter.node.doorlock_operating_mode.description";
public static final String CONFIG_DESC_DOORLOCK_AUTO_RELOCK_TIME = "@text/thing-type.config.matter.node.doorlock_auto_relock_time.description";
public static final String CONFIG_DESC_DOORLOCK_ONE_TOUCH_LOCKING = "@text/thing-type.config.matter.node.doorlock_one_touch_locking.description";
public static final String CONFIG_DESC_DOORLOCK_DEFAULT_LOCK_PIN = "@text/thing-type.config.matter.node.doorlock_default_lock_pin.description";
public static final String CONFIG_DESC_DOORLOCK_DISPLAYED_USER_COUNT = "@text/thing-type.config.matter.node.doorlock_displayed_user_count.description";
public static final String CONFIG_DESC_DOORLOCK_USER = "@text/thing-type.config.matter.node.doorlock_user.description";
public static final String CONFIG_DESC_DOORLOCK_USER_NAME = "@text/thing-type.config.matter.node.doorlock_user_name.description";
public static final String CONFIG_DESC_DOORLOCK_USER_TYPE = "@text/thing-type.config.matter.node.doorlock_user_type.description";
public static final String CONFIG_DESC_DOORLOCK_USER_ENABLED = "@text/thing-type.config.matter.node.doorlock_user_enabled.description";
public static final String CONFIG_DESC_DOORLOCK_PIN_CREDENTIAL = "@text/thing-type.config.matter.node.doorlock_pin_credential.description";
public static final String CONFIG_DESC_DOORLOCK_DELETE_USER = "@text/thing-type.config.matter.node.doorlock_delete_user.description";
public static final String CONFIG_DESC_DOORLOCK_EXTERNAL_FABRIC = "@text/thing-type.config.matter.node.doorlock_external_fabric.description";
}
@@ -94,9 +94,11 @@ public class MatterWebsocketClient implements WebSocketListener, MatterWebsocket
private final ScheduledExecutorService scheduler = ThreadPoolManager
.getScheduledPool("matter.MatterWebsocketClient");
protected final Gson gson = new GsonBuilder().registerTypeAdapter(Node.class, new NodeDeserializer())
protected final Gson gson = new GsonBuilder().serializeNulls()
.registerTypeAdapter(Node.class, new NodeDeserializer())
.registerTypeAdapter(BigInteger.class, new BigIntegerSerializer())
.registerTypeHierarchyAdapter(BaseCluster.MatterEnum.class, new MatterEnumDeserializer())
.registerTypeHierarchyAdapter(BaseCluster.MatterEnum.class, new MatterEnumSerializer())
.registerTypeAdapter(AttributeChangedMessage.class, new AttributeChangedMessageDeserializer())
.registerTypeAdapter(EventTriggeredMessage.class, new EventTriggeredMessageDeserializer())
.registerTypeAdapter(OctetString.class, new OctetStringDeserializer())
@@ -645,6 +647,14 @@ public class MatterWebsocketClient implements WebSocketListener, MatterWebsocket
}
}
@NonNullByDefault({})
class MatterEnumSerializer implements JsonSerializer<BaseCluster.MatterEnum> {
@Override
public JsonElement serialize(BaseCluster.MatterEnum src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.getValue());
}
}
@NonNullByDefault({})
class EventTriggeredMessageDeserializer implements JsonDeserializer<EventTriggeredMessage> {
@Override
@@ -16,6 +16,7 @@
package org.openhab.binding.matter.internal.client.dto.cluster.gen;
import java.math.BigInteger;
import java.nio.charset.Charset;
import java.util.List;
import org.eclipse.jdt.annotation.NonNull;
@@ -70,6 +71,10 @@ public class BaseCluster {
this.value = value;
}
public OctetString(String string, Charset charset) {
this.value = string.getBytes(charset);
}
public OctetString(String hexString) {
int length = hexString.length();
value = new byte[length / 2];
@@ -14,15 +14,43 @@ package org.openhab.binding.matter.internal.controller.devices.converter;
import static org.openhab.binding.matter.internal.MatterBindingConstants.*;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.matter.internal.MatterBindingConstants;
import org.openhab.binding.matter.internal.client.dto.cluster.ClusterCommand;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.BaseCluster;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.BaseCluster.OctetString;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.CredentialStruct;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.CredentialTypeEnum;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.DataOperationTypeEnum;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.OperatingModeEnum;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.UserStatusEnum;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.UserTypeEnum;
import org.openhab.binding.matter.internal.client.dto.ws.AttributeChangedMessage;
import org.openhab.binding.matter.internal.client.dto.ws.EventTriggeredMessage;
import org.openhab.binding.matter.internal.handler.MatterBaseThingHandler;
import org.openhab.core.config.core.ConfigDescriptionBuilder;
import org.openhab.core.config.core.ConfigDescriptionParameter;
import org.openhab.core.config.core.ConfigDescriptionParameter.Type;
import org.openhab.core.config.core.ConfigDescriptionParameterBuilder;
import org.openhab.core.config.core.ConfigDescriptionParameterGroup;
import org.openhab.core.config.core.ConfigDescriptionParameterGroupBuilder;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.config.core.ParameterOption;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
@@ -30,30 +58,129 @@ import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.type.ChannelKind;
import org.openhab.core.types.Command;
import org.openhab.core.types.StateDescription;
import org.openhab.core.types.StateDescriptionFragmentBuilder;
import org.openhab.core.types.StateOption;
/**
* A converter for translating {@link DoorLockCluster} events and attributes to openHAB channels and back again.
* A converter for translating {@link DoorLockCluster} events and attributes to
* openHAB channels and back again.
*
* @author Dan Cunningham - Initial contribution
*/
@NonNullByDefault
public class DoorLockConverter extends GenericConverter<DoorLockCluster> {
/**
* Represents user information retrieved from the lock.
*/
public static class LockUser {
public final int userIndex;
public @Nullable String userName;
public @Nullable UserTypeEnum userType;
public @Nullable UserStatusEnum userStatus;
public List<CredentialStruct> credentials = new ArrayList<>();
public @Nullable Integer nextUserIndex;
public @Nullable Integer creatorFabricIndex;
public LockUser(int userIndex) {
this.userIndex = userIndex;
}
/**
* Returns true if the user slot is currently in use (occupied in matter terms).
*/
public boolean isOccupied() {
return userStatus != null && userStatus != UserStatusEnum.AVAILABLE;
}
public boolean hasCredentialOfType(CredentialTypeEnum type) {
return credentials.stream().anyMatch(c -> c.credentialType == type);
}
public boolean isManagedByFabric(int ourFabricIndex) {
return creatorFabricIndex == null || creatorFabricIndex == ourFabricIndex;
}
}
private static final int ADDITIONAL_USER_SLOTS = 5;
private final Map<Integer, LockUser> lockUsers = new ConcurrentHashMap<>();
private final AtomicBoolean fetchingUsers = new AtomicBoolean(false);
private int numberOfTotalUsersSupported = 0;
private int minPinCodeLength = 0;
private int maxPinCodeLength = 255; // Maximum length of a PIN code if not specified is 255
private int autoRelockTime = 0;
private boolean pinCredentialSupported = false;
private boolean userFeatureSupported = false;
private boolean requirePinForRemoteOperation = false;
private boolean enableOneTouchLocking = false;
private OperatingModeEnum operatingMode = OperatingModeEnum.NORMAL;
public DoorLockConverter(DoorLockCluster cluster, MatterBaseThingHandler handler, int endpointNumber,
String labelPrefix) {
super(cluster, handler, endpointNumber, labelPrefix);
if (cluster.featureMap != null) {
pinCredentialSupported = cluster.featureMap.pinCredential;
userFeatureSupported = cluster.featureMap.user;
}
if (cluster.numberOfTotalUsersSupported != null) {
numberOfTotalUsersSupported = cluster.numberOfTotalUsersSupported;
}
if (cluster.minPinCodeLength != null) {
minPinCodeLength = cluster.minPinCodeLength;
}
if (cluster.maxPinCodeLength != null) {
maxPinCodeLength = cluster.maxPinCodeLength;
}
if (cluster.requirePinForRemoteOperation != null) {
requirePinForRemoteOperation = cluster.requirePinForRemoteOperation;
}
if (cluster.autoRelockTime != null) {
autoRelockTime = cluster.autoRelockTime;
}
if (cluster.enableOneTouchLocking != null) {
enableOneTouchLocking = cluster.enableOneTouchLocking;
}
if (cluster.operatingMode != null) {
operatingMode = cluster.operatingMode;
}
updateConfigDescription();
}
@Override
public Map<Channel, @Nullable StateDescription> createChannels(ChannelGroupUID channelGroupUID) {
Map<Channel, @Nullable StateDescription> channels = new HashMap<>();
Channel channel = ChannelBuilder
Channel stateChannel = ChannelBuilder
.create(new ChannelUID(channelGroupUID, CHANNEL_ID_DOORLOCK_STATE), CoreItemFactory.SWITCH)
.withType(CHANNEL_DOORLOCK_STATE).build();
channels.put(channel, null);
channels.put(stateChannel, null);
Channel alarmChannel = ChannelBuilder.create(new ChannelUID(channelGroupUID, CHANNEL_ID_DOORLOCK_ALARM), null)
.withType(CHANNEL_DOORLOCK_ALARM).withKind(ChannelKind.TRIGGER).build();
List<StateOption> alarmOptions = new ArrayList<StateOption>();
for (DoorLockCluster.AlarmCodeEnum e : DoorLockCluster.AlarmCodeEnum.values()) {
alarmOptions.add(new StateOption(e.getValue().toString(), e.getLabel()));
}
StateDescription stateDescriptionAlarm = StateDescriptionFragmentBuilder.create().withOptions(alarmOptions)
.build().toStateDescription();
channels.put(alarmChannel, stateDescriptionAlarm);
Channel lockOperationErrorChannel = ChannelBuilder
.create(new ChannelUID(channelGroupUID, CHANNEL_ID_DOORLOCK_LOCKOPERATIONERROR), null)
.withType(CHANNEL_DOORLOCK_LOCKOPERATIONERROR).withKind(ChannelKind.TRIGGER).build();
List<StateOption> lockOperationErrorOptions = new ArrayList<StateOption>();
for (DoorLockCluster.OperationErrorEnum e : DoorLockCluster.OperationErrorEnum.values()) {
lockOperationErrorOptions.add(new StateOption(e.getValue().toString(), e.getLabel()));
}
StateDescription stateDescriptionLockOperationError = StateDescriptionFragmentBuilder.create()
.withOptions(lockOperationErrorOptions).build().toStateDescription();
channels.put(lockOperationErrorChannel, stateDescriptionLockOperationError);
if (initializingCluster.featureMap.doorPositionSensor) {
Channel doorStateChannel = ChannelBuilder
@@ -68,8 +195,9 @@ public class DoorLockConverter extends GenericConverter<DoorLockCluster> {
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof OnOffType onOffType) {
ClusterCommand doorLockCommand = onOffType == OnOffType.ON ? DoorLockCluster.lockDoor(null)
: DoorLockCluster.unlockDoor(null);
OctetString pinCode = getPinCodeForRemoteOperation();
ClusterCommand doorLockCommand = onOffType == OnOffType.ON ? DoorLockCluster.lockDoor(pinCode)
: DoorLockCluster.unlockDoor(pinCode);
handler.sendClusterCommand(endpointNumber, DoorLockCluster.CLUSTER_NAME, doorLockCommand);
}
super.handleCommand(channelUID, command);
@@ -78,23 +206,75 @@ public class DoorLockConverter extends GenericConverter<DoorLockCluster> {
@Override
public void onEvent(AttributeChangedMessage message) {
switch (message.path.attributeName) {
case "lockState":
case DoorLockCluster.ATTRIBUTE_LOCK_STATE:
if (message.value instanceof DoorLockCluster.LockStateEnum lockState) {
updateState(CHANNEL_ID_DOORLOCK_STATE,
lockState == DoorLockCluster.LockStateEnum.LOCKED ? OnOffType.ON : OnOffType.OFF);
}
case "doorState":
break;
case DoorLockCluster.ATTRIBUTE_DOOR_STATE:
if (message.value instanceof DoorLockCluster.DoorStateEnum doorState) {
updateState(CHANNEL_ID_DOORLOCK_DOORSTATE,
doorState == DoorLockCluster.DoorStateEnum.DOOR_CLOSED ? OpenClosedType.CLOSED
: OpenClosedType.OPEN);
}
break;
case DoorLockCluster.ATTRIBUTE_OPERATING_MODE:
if (message.value instanceof OperatingModeEnum operatingMode) {
this.operatingMode = operatingMode;
handler.updateConfiguration(Map.of(MatterBindingConstants.CONFIG_DOORLOCK_OPERATING_MODE,
String.valueOf(operatingMode.getValue())));
}
break;
case DoorLockCluster.ATTRIBUTE_AUTO_RELOCK_TIME:
if (message.value instanceof Number autoRelockTime) {
this.autoRelockTime = autoRelockTime.intValue();
handler.updateConfiguration(
Map.of(MatterBindingConstants.CONFIG_DOORLOCK_AUTO_RELOCK_TIME, this.autoRelockTime));
}
break;
case DoorLockCluster.ATTRIBUTE_ENABLE_ONE_TOUCH_LOCKING:
if (message.value instanceof Boolean oneTouchLocking) {
this.enableOneTouchLocking = oneTouchLocking;
handler.updateConfiguration(Map.of(MatterBindingConstants.CONFIG_DOORLOCK_ONE_TOUCH_LOCKING,
String.valueOf(oneTouchLocking)));
}
break;
case DoorLockCluster.ATTRIBUTE_REQUIRE_PIN_FOR_REMOTE_OPERATION:
if (message.value instanceof Boolean requirePin) {
requirePinForRemoteOperation = requirePin;
}
break;
default:
break;
}
super.onEvent(message);
}
@Override
public void onEvent(EventTriggeredMessage message) {
switch (message.path.eventName) {
case "doorLockAlarm":
if (message.events != null && message.events.length > 0
&& message.events[0].data instanceof DoorLockCluster.DoorLockAlarm doorLockAlarm) {
triggerChannel(CHANNEL_ID_DOORLOCK_ALARM, doorLockAlarm.alarmCode.getValue().toString());
}
break;
case "lockOperationError":
if (message.events != null && message.events.length > 0
&& message.events[0].data instanceof DoorLockCluster.LockOperationError lockOperationError) {
triggerChannel(CHANNEL_ID_DOORLOCK_LOCKOPERATIONERROR,
lockOperationError.operationError.getValue().toString());
}
break;
case "lockUserChange":
if (userFeatureSupported) {
fetchAllUsers();
}
break;
}
}
@Override
public void initState() {
updateState(CHANNEL_ID_DOORLOCK_STATE,
@@ -105,5 +285,683 @@ public class DoorLockConverter extends GenericConverter<DoorLockCluster> {
initializingCluster.doorState == DoorLockCluster.DoorStateEnum.DOOR_CLOSED ? OpenClosedType.CLOSED
: OpenClosedType.OPEN);
}
Map<String, Object> entries = new HashMap<>();
if (initializingCluster.operatingMode != null) {
entries.put(MatterBindingConstants.CONFIG_DOORLOCK_OPERATING_MODE,
String.valueOf(initializingCluster.operatingMode.getValue()));
}
if (initializingCluster.autoRelockTime != null) {
entries.put(MatterBindingConstants.CONFIG_DOORLOCK_AUTO_RELOCK_TIME, initializingCluster.autoRelockTime);
}
if (initializingCluster.enableOneTouchLocking != null) {
entries.put(MatterBindingConstants.CONFIG_DOORLOCK_ONE_TOUCH_LOCKING,
String.valueOf(initializingCluster.enableOneTouchLocking));
}
if (!entries.isEmpty()) {
handler.updateConfiguration(entries);
}
if (userFeatureSupported) {
fetchAllUsers();
}
}
@Override
public void handleConfigurationUpdate(Configuration config) {
processLockManagementConfiguration(config);
if (userFeatureSupported) {
int userGroupsToProcess = calculateUserGroupsToShow();
for (int userIndex = 1; userIndex <= userGroupsToProcess; userIndex++) {
processUserConfiguration(config, userIndex);
}
}
}
private @Nullable OctetString getPinCodeForRemoteOperation() {
if (!requirePinForRemoteOperation) {
return null;
}
Object pinValue = handler.getThing().getConfiguration()
.get(MatterBindingConstants.CONFIG_DOORLOCK_DEFAULT_LOCK_PIN);
if (pinValue instanceof String pin && !pin.isEmpty()) {
return new OctetString(pin, StandardCharsets.UTF_8);
}
return null;
}
private void processLockManagementConfiguration(Configuration config) {
Object operatingModeValue = config.get(MatterBindingConstants.CONFIG_DOORLOCK_OPERATING_MODE);
if (operatingModeValue instanceof Number number) {
int configuredMode = number.intValue();
final int currentMode = operatingMode.getValue();
if (currentMode != configuredMode) {
logger.debug("Updating operating mode from {} to {}", currentMode, configuredMode);
handler.writeAttribute(endpointNumber, DoorLockCluster.CLUSTER_NAME,
DoorLockCluster.ATTRIBUTE_OPERATING_MODE, String.valueOf(configuredMode)).exceptionally(e -> {
logger.warn("Failed to update operating mode: {}", e.getMessage());
handler.updateConfiguration(Map.of(MatterBindingConstants.CONFIG_DOORLOCK_OPERATING_MODE,
String.valueOf(Objects.requireNonNullElse(currentMode, 0))));
return Void.TYPE.cast(null);
});
}
}
Object autoRelockTimeValue = config.get(MatterBindingConstants.CONFIG_DOORLOCK_AUTO_RELOCK_TIME);
if (autoRelockTimeValue instanceof Number number) {
int configuredTime = number.intValue();
final int currentTime = autoRelockTime;
if (currentTime != configuredTime) {
logger.debug("Updating auto relock time from {} to {}", currentTime, configuredTime);
handler.writeAttribute(endpointNumber, DoorLockCluster.CLUSTER_NAME,
DoorLockCluster.ATTRIBUTE_AUTO_RELOCK_TIME, String.valueOf(configuredTime)).exceptionally(e -> {
logger.warn("Failed to update auto relock time: {}", e.getMessage());
handler.updateConfiguration(
Map.of(MatterBindingConstants.CONFIG_DOORLOCK_AUTO_RELOCK_TIME, currentTime));
return Void.TYPE.cast(null);
});
}
}
Object oneTouchLockingValue = config.get(MatterBindingConstants.CONFIG_DOORLOCK_ONE_TOUCH_LOCKING);
if (oneTouchLockingValue instanceof Boolean configuredValue) {
final Boolean currentValue = enableOneTouchLocking;
if (!currentValue.equals(configuredValue)) {
logger.debug("Updating one touch locking from {} to {}", currentValue, configuredValue);
handler.writeAttribute(endpointNumber, DoorLockCluster.CLUSTER_NAME,
DoorLockCluster.ATTRIBUTE_ENABLE_ONE_TOUCH_LOCKING, String.valueOf(configuredValue))
.exceptionally(e -> {
logger.warn("Failed to update one touch locking: {}", e.getMessage());
handler.updateConfiguration(Map.of(MatterBindingConstants.CONFIG_DOORLOCK_ONE_TOUCH_LOCKING,
String.valueOf(Objects.requireNonNullElse(currentValue, false))));
return Void.TYPE.cast(null);
});
}
}
}
private void updateConfigDescription() {
List<ConfigDescriptionParameter> params = new ArrayList<>();
List<ConfigDescriptionParameterGroup> groups = new ArrayList<>();
groups.add(
ConfigDescriptionParameterGroupBuilder.create(MatterBindingConstants.CONFIG_GROUP_DOORLOCK_MANAGEMENT)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_MANAGEMENT))
.withDescription(handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_MANAGEMENT))
.build());
ConfigDescriptionParameterBuilder builder = ConfigDescriptionParameterBuilder
.create(MatterBindingConstants.CONFIG_DOORLOCK_OPERATING_MODE, Type.INTEGER)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_OPERATING_MODE))
.withDescription(handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_OPERATING_MODE))
.withGroupName(MatterBindingConstants.CONFIG_GROUP_DOORLOCK_MANAGEMENT)
.withDefault(String.valueOf(OperatingModeEnum.NORMAL.getValue()))
.withOptions(Arrays.stream(OperatingModeEnum.values())
.map(mode -> new ParameterOption(mode.getValue().toString(), mode.getLabel()))
.collect(Collectors.toList()));
params.add(builder.build());
builder = ConfigDescriptionParameterBuilder
.create(MatterBindingConstants.CONFIG_DOORLOCK_AUTO_RELOCK_TIME, Type.INTEGER)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_AUTO_RELOCK_TIME))
.withDescription(handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_AUTO_RELOCK_TIME))
.withGroupName(MatterBindingConstants.CONFIG_GROUP_DOORLOCK_MANAGEMENT).withDefault("10")
.withMinimum(BigDecimal.ZERO).withUnit("s");
params.add(builder.build());
builder = ConfigDescriptionParameterBuilder
.create(MatterBindingConstants.CONFIG_DOORLOCK_ONE_TOUCH_LOCKING, Type.BOOLEAN)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_ONE_TOUCH_LOCKING))
.withDescription(handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_ONE_TOUCH_LOCKING))
.withGroupName(MatterBindingConstants.CONFIG_GROUP_DOORLOCK_MANAGEMENT).withDefault("false");
params.add(builder.build());
if (pinCredentialSupported) {
builder = ConfigDescriptionParameterBuilder
.create(MatterBindingConstants.CONFIG_DOORLOCK_DEFAULT_LOCK_PIN, Type.TEXT)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_DEFAULT_LOCK_PIN))
.withDescription(
handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_DEFAULT_LOCK_PIN,
minPinCodeLength, maxPinCodeLength))
.withGroupName(MatterBindingConstants.CONFIG_GROUP_DOORLOCK_MANAGEMENT).withDefault("")
.withPattern(buildPinCodePattern(minPinCodeLength, maxPinCodeLength)).withContext("password");
params.add(builder.build());
}
// Add user groups (only if user feature is supported)
if (userFeatureSupported && numberOfTotalUsersSupported > 0) {
addUserGroupsToConfig(params, groups);
}
handler.addConfigDescription(ConfigDescriptionBuilder.create(handler.getConfigDescriptionURI())
.withParameters(params).withParameterGroups(groups).build());
}
/**
* Adds user configuration groups based on occupied users plus additional slots for new users. Shows all slots from
* 1 to (maxOccupiedUserIndex + ADDITIONAL_USER_SLOTS), up to the max supported.
*/
private void addUserGroupsToConfig(List<ConfigDescriptionParameter> params,
List<ConfigDescriptionParameterGroup> groups) {
int userGroupsToShow = calculateUserGroupsToShow();
int ourFabricIndex = handler.getCurrentFabricIndex();
for (int userIndex = 1; userIndex <= userGroupsToShow; userIndex++) {
String groupName = MatterBindingConstants.CONFIG_GROUP_DOORLOCK_USER_PREFIX + userIndex;
LockUser user = lockUsers.get(userIndex);
boolean isOccupied = user != null && user.isOccupied();
boolean isManagedByUs = user == null || user.isManagedByFabric(ourFabricIndex);
String groupLabel = buildUserGroupLabel(userIndex, user, isManagedByUs);
String groupDescription = isManagedByUs || !isOccupied
? handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_USER)
: handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_EXTERNAL_FABRIC);
groups.add(ConfigDescriptionParameterGroupBuilder.create(groupName).withLabel(groupLabel)
.withDescription(groupDescription).build());
// User Name
ConfigDescriptionParameterBuilder builder = ConfigDescriptionParameterBuilder
.create(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_NAME, Type.TEXT)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_USER_NAME))
.withDescription(
handler.getTranslation(isManagedByUs ? MatterBindingConstants.CONFIG_DESC_DOORLOCK_USER_NAME
: MatterBindingConstants.CONFIG_DESC_DOORLOCK_EXTERNAL_FABRIC))
.withGroupName(groupName)
.withDefault(Objects.requireNonNullElse(user != null ? user.userName : null, ""))
.withReadOnly(!isManagedByUs);
params.add(builder.build());
if (isManagedByUs) {
// User Type
builder = ConfigDescriptionParameterBuilder
.create(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_TYPE, Type.INTEGER)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_USER_TYPE))
.withDescription(handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_USER_TYPE))
.withGroupName(groupName).withDefault(String.valueOf(UserTypeEnum.UNRESTRICTED_USER.getValue()))
.withLimitToOptions(true)
.withOptions(Arrays.stream(UserTypeEnum.values())
.map(type -> new ParameterOption(type.getValue().toString(), type.getLabel()))
.collect(Collectors.toList()));
params.add(builder.build());
// PIN Credential
if (pinCredentialSupported) {
builder = ConfigDescriptionParameterBuilder
.create(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_PIN_CREDENTIAL, Type.TEXT)
.withLabel(
handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_PIN_CREDENTIAL,
minPinCodeLength, maxPinCodeLength))
.withPattern(buildPinCodePattern(minPinCodeLength, maxPinCodeLength))
.withDescription(
buildPinCodeDescription(MatterBindingConstants.CONFIG_DESC_DOORLOCK_PIN_CREDENTIAL))
.withGroupName(groupName).withDefault("").withContext("password");
params.add(builder.build());
}
}
if (isOccupied) {
// User Enabled
boolean isEnabled = user != null && user.userStatus == UserStatusEnum.OCCUPIED_ENABLED;
builder = ConfigDescriptionParameterBuilder
.create(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_ENABLED, Type.BOOLEAN)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_USER_ENABLED))
.withDescription(
handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_USER_ENABLED))
.withGroupName(groupName).withDefault(String.valueOf(isEnabled));
params.add(builder.build());
// Delete User
builder = ConfigDescriptionParameterBuilder
.create(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_DELETE_USER, Type.BOOLEAN)
.withLabel(handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_DELETE_USER))
.withDescription(
handler.getTranslation(MatterBindingConstants.CONFIG_DESC_DOORLOCK_DELETE_USER))
.withGroupName(groupName).withDefault("false");
params.add(builder.build());
}
}
}
private String buildUserGroupLabel(int userIndex, @Nullable LockUser user, boolean isManagedByUs) {
String baseLabel = handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_USER) + " " + userIndex;
if (user == null || !user.isOccupied()) {
return baseLabel;
}
String userName = Objects.requireNonNullElse(user.userName, "");
String userNamePart = userName.isEmpty()
? handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_NO_USER_NAME)
: userName;
if (!isManagedByUs) {
return baseLabel + " (" + userNamePart + " - "
+ handler.getTranslation(MatterBindingConstants.CONFIG_LABEL_DOORLOCK_EXTERNAL_FABRIC) + ")";
}
return baseLabel + " (" + userNamePart + ")";
}
/**
* Calculates how many user groups to show in the configuration.
* Shows all slots from 1 to (maxOccupiedUserIndex + ADDITIONAL_USER_SLOTS) up
* to at max supported.
*
* @return The number of user groups to display
*/
private int calculateUserGroupsToShow() {
int maxOccupiedUserIndex = lockUsers.entrySet().stream().filter(e -> e.getValue().isOccupied())
.mapToInt(Map.Entry::getKey).max().orElse(0);
return Math.min(maxOccupiedUserIndex + ADDITIONAL_USER_SLOTS, numberOfTotalUsersSupported);
}
/**
* Fetches all users from the lock starting at slot/index 1.
*/
private void fetchAllUsers() {
if (!userFeatureSupported || numberOfTotalUsersSupported == 0) {
return;
}
if (!fetchingUsers.compareAndSet(false, true)) {
logger.debug("User fetch already in progress, skipping");
return;
}
fetchUserStartingAtIndex(1, new ConcurrentHashMap<>()).thenAccept(users -> {
lockUsers.clear();
lockUsers.putAll(users);
logger.debug("User enumeration complete, found {} users", lockUsers.size());
}).exceptionally(e -> {
logger.debug("Error fetching users: {}", e.getMessage());
return null;
}).whenComplete((result, error) -> {
updateConfigDescription();
updateUserConfiguration();
fetchingUsers.set(false);
});
}
/**
* Fetches users starting at a specific index, will skip empty slots and stop
* when the last user is reached.
*
* @param userIndex the user index to start fetching from
* @param users the map to accumulate users into
* @return a CompletableFuture containing the map of all fetched users
*/
private CompletableFuture<Map<Integer, LockUser>> fetchUserStartingAtIndex(int userIndex,
Map<Integer, LockUser> users) {
return getUser(userIndex).thenCompose(user -> {
if (user != null) {
if (user.isOccupied()) {
users.put(userIndex, user);
}
Integer nextUserIndex = user.nextUserIndex;
// Use nextUserIndex from the response to jump to the next occupied user
if (nextUserIndex != null) {
return fetchUserStartingAtIndex(nextUserIndex.intValue(), users);
}
}
// No more users or null response - return accumulated users
return CompletableFuture.completedFuture(users);
});
}
private CompletableFuture<@Nullable LockUser> getUser(int userIndex) {
return handler
.sendClusterCommand(endpointNumber, DoorLockCluster.CLUSTER_NAME, DoorLockCluster.getUser(userIndex))
.thenApply(result -> {
LockUser user = new LockUser(userIndex);
var jsonObject = result.getAsJsonObject();
if (jsonObject.has("userName") && !jsonObject.get("userName").isJsonNull()) {
user.userName = jsonObject.get("userName").getAsString();
}
if (jsonObject.has("userStatus") && !jsonObject.get("userStatus").isJsonNull()) {
int statusValue = jsonObject.get("userStatus").getAsInt();
for (UserStatusEnum status : UserStatusEnum.values()) {
if (status.getValue() == statusValue) {
user.userStatus = status;
break;
}
}
}
if (jsonObject.has("userType") && !jsonObject.get("userType").isJsonNull()) {
int typeValue = jsonObject.get("userType").getAsInt();
for (UserTypeEnum type : UserTypeEnum.values()) {
if (type.getValue() == typeValue) {
user.userType = type;
break;
}
}
}
if (jsonObject.has("credentials") && !jsonObject.get("credentials").isJsonNull()) {
for (var credElement : jsonObject.get("credentials").getAsJsonArray()) {
var credObj = credElement.getAsJsonObject();
int credTypeValue = credObj.get("credentialType").getAsInt();
int credIndex = credObj.get("credentialIndex").getAsInt();
for (CredentialTypeEnum credType : CredentialTypeEnum.values()) {
if (credType.getValue() == credTypeValue) {
user.credentials.add(new CredentialStruct(credType, credIndex));
break;
}
}
}
}
// nextUserIndex indicates the next occupied user slot to read from
if (jsonObject.has("nextUserIndex") && !jsonObject.get("nextUserIndex").isJsonNull()) {
user.nextUserIndex = jsonObject.get("nextUserIndex").getAsInt();
}
// Users can only be edited by their creator fabric; other fabrics can only
// delete/enable/disable
if (jsonObject.has("creatorFabricIndex") && !jsonObject.get("creatorFabricIndex").isJsonNull()) {
user.creatorFabricIndex = jsonObject.get("creatorFabricIndex").getAsInt();
}
return user;
}).exceptionally(e -> {
logger.debug("Error getting user {}: {}", userIndex, e.getMessage());
return null;
});
}
/**
* Sets or creates a user on the lock.
* For ADD operations all fields are sent with values.
* For MODIFY operations only specified fields are updated, other fields are sent as null per Matter spec.
*
* @param userIndex The user index starting at 1
* @param userName The user name (can be null for changes to foreign fabric users)
* @param userType The user type (can be null for status-only updates)
* @param userStatus The user status (can be null to preserve existing status on modify)
*/
private CompletableFuture<Void> setUser(int userIndex, @Nullable String userName, @Nullable UserTypeEnum userType,
@Nullable UserStatusEnum userStatus) {
LockUser existingUser = lockUsers.get(userIndex);
boolean isModify = existingUser != null && existingUser.isOccupied();
DataOperationTypeEnum operationType = isModify ? DataOperationTypeEnum.MODIFY : DataOperationTypeEnum.ADD;
logger.debug("setUser called: userIndex={}, userName='{}', userType={}, userStatus={}, operationType={}",
userIndex, userName, userType, userStatus, operationType);
ClusterCommand command;
if (isModify) {
command = DoorLockCluster.setUser(operationType, userIndex, userName, null, userStatus, userType, null);
// Explicitly add null values to args map so they're serialized, normally null
// values are omitted
command.args.put("userUniqueId", null);
if (userStatus == null) {
command.args.put("userStatus", null);
}
if (userType == null) {
command.args.put("userType", null);
}
if (userName == null) {
command.args.put("userName", null);
}
command.args.put("credentialRule", null);
} else {
UserStatusEnum status = userStatus != null ? userStatus : UserStatusEnum.OCCUPIED_ENABLED;
UserTypeEnum type = userType != null ? userType : UserTypeEnum.UNRESTRICTED_USER;
String name = userName != null ? userName : "User " + userIndex;
command = DoorLockCluster.setUser(operationType, userIndex, name, 0, status, type,
DoorLockCluster.CredentialRuleEnum.SINGLE);
}
logger.debug("Sending setUser command: {}", command.args);
return handler.sendClusterCommand(endpointNumber, DoorLockCluster.CLUSTER_NAME, command)
.<Void> thenApply(result -> Void.TYPE.cast(null));
}
private CompletableFuture<Void> deleteUser(int userIndex) {
return handler
.sendClusterCommand(endpointNumber, DoorLockCluster.CLUSTER_NAME, DoorLockCluster.clearUser(userIndex))
.<Void> thenApply(result -> Void.TYPE.cast(null));
}
private CompletableFuture<Void> setPinCredential(int userIndex, String pinCode) {
LockUser existingUser = lockUsers.get(userIndex);
// Check if user already has a pin credential
if (existingUser != null && existingUser.hasCredentialOfType(CredentialTypeEnum.PIN)) {
// Find the existing pin credential index (which is different from the user
// index)
Integer existingCredentialIndex = existingUser.credentials.stream()
.filter(c -> c.credentialType == CredentialTypeEnum.PIN).findFirst().map(c -> c.credentialIndex)
.orElse(null);
if (existingCredentialIndex != null) {
logger.debug("User {} has existing PIN credential at index {}, modifying", userIndex,
existingCredentialIndex);
return setCredentialWithIndex(userIndex, pinCode, existingCredentialIndex,
DataOperationTypeEnum.MODIFY);
}
}
logger.debug("Finding available credential slot for user {}", userIndex);
return findAvailableCredentialSlot(1).thenCompose(availableIndex -> {
logger.debug("Found available credential slot {} for user {}", availableIndex, userIndex);
return setCredentialWithIndex(userIndex, pinCode, availableIndex, DataOperationTypeEnum.ADD);
});
}
/**
* Finds an available credential slot by iterating through credential indices.
*/
private CompletableFuture<Integer> findAvailableCredentialSlot(int startIndex) {
CredentialStruct credential = new CredentialStruct(CredentialTypeEnum.PIN, startIndex);
return handler.sendClusterCommand(endpointNumber, DoorLockCluster.CLUSTER_NAME,
DoorLockCluster.getCredentialStatus(credential)).thenCompose(result -> {
var jsonObject = result.getAsJsonObject();
boolean credentialExists = jsonObject.has("credentialExists")
&& jsonObject.get("credentialExists").getAsBoolean();
if (!credentialExists) {
logger.debug("Credential slot {} is available", startIndex);
return CompletableFuture.completedFuture(startIndex);
}
// Slot is occupied, check nextCredentialIndex or try next slot
Integer nextIndex = null;
if (jsonObject.has("nextCredentialIndex") && !jsonObject.get("nextCredentialIndex").isJsonNull()) {
nextIndex = jsonObject.get("nextCredentialIndex").getAsInt();
}
if (nextIndex != null && nextIndex > startIndex) {
logger.debug("Credential slot {} occupied, nextCredentialIndex suggests {}", startIndex,
nextIndex);
return findAvailableCredentialSlot(nextIndex);
} else {
// Try the next sequential slot
logger.debug("Credential slot {} occupied, trying next slot", startIndex);
return findAvailableCredentialSlot(startIndex + 1);
}
});
}
private CompletableFuture<Void> setCredentialWithIndex(int userIndex, String pinCode, int credentialIndex,
DataOperationTypeEnum operationType) {
CredentialStruct credential = new CredentialStruct(CredentialTypeEnum.PIN, credentialIndex);
BaseCluster.OctetString pinData = new BaseCluster.OctetString(pinCode, StandardCharsets.UTF_8);
ClusterCommand command = DoorLockCluster.setCredential(operationType, credential, pinData, userIndex, null,
null);
// This is a workaround to send null values vs omitting values.
// TODO: Refactor command construction to use a builder pattern when matter.js 0.16 comes out.
command.args.put("userStatus", null);
command.args.put("userType", null);
logger.debug("Setting credential at index {}: {}", credentialIndex, command.args);
return handler.sendClusterCommand(endpointNumber, DoorLockCluster.CLUSTER_NAME, command)
.<Void> thenApply(result -> Void.TYPE.cast(null));
}
/**
* Updates configuration with current user data from the lock.
*/
private void updateUserConfiguration() {
Map<String, Object> entries = new HashMap<>();
int userGroupsToShow = calculateUserGroupsToShow();
for (int userIndex = 1; userIndex <= userGroupsToShow; userIndex++) {
String groupName = MatterBindingConstants.CONFIG_GROUP_DOORLOCK_USER_PREFIX + userIndex;
LockUser user = lockUsers.get(userIndex);
if (user != null && user.isOccupied()) {
UserTypeEnum userType = user.userType;
entries.put(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_NAME,
Objects.requireNonNullElse(user.userName, ""));
entries.put(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_TYPE, String
.valueOf(userType != null ? userType.getValue() : UserTypeEnum.UNRESTRICTED_USER.getValue()));
boolean isEnabled = user.userStatus == UserStatusEnum.OCCUPIED_ENABLED;
entries.put(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_ENABLED, isEnabled);
} else {
entries.put(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_NAME, "");
entries.put(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_TYPE,
String.valueOf(UserTypeEnum.UNRESTRICTED_USER.getValue()));
}
entries.put(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_DELETE_USER, false);
}
if (!entries.isEmpty()) {
handler.updateConfiguration(entries);
}
}
/**
* Processes configuration for a single user.
*/
private void processUserConfiguration(Configuration config, int userIndex) {
String groupName = MatterBindingConstants.CONFIG_GROUP_DOORLOCK_USER_PREFIX + userIndex;
LockUser currentUser = lockUsers.get(userIndex);
boolean userExistsOnLock = currentUser != null && currentUser.isOccupied();
Object deleteValue = config.get(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_DELETE_USER);
if (deleteValue instanceof Boolean delete && delete) {
logger.debug("Delete flag enabled for user {}, deleting user", userIndex);
// Reset the delete flag immediately
Map<String, Object> resetFlag = new HashMap<>();
resetFlag.put(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_DELETE_USER, false);
handler.updateConfiguration(resetFlag);
deleteUser(userIndex).thenRun(() -> {
logger.debug("User {} deleted, refreshing users from lock", userIndex);
fetchAllUsers();
}).exceptionally(e -> {
logger.warn("Failed to delete user {}: {}", userIndex, e.getMessage());
return null;
});
return; // Don't process other updates if deleting
}
int ourFabricIndex = handler.getCurrentFabricIndex();
Object userEnabledValue = config.get(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_ENABLED);
if (userExistsOnLock && currentUser != null && userEnabledValue instanceof Boolean enabled) {
UserStatusEnum currentStatus = currentUser.userStatus;
UserStatusEnum desiredStatus = enabled ? UserStatusEnum.OCCUPIED_ENABLED : UserStatusEnum.OCCUPIED_DISABLED;
if (currentStatus != desiredStatus) {
logger.debug("User {} enabled status changed from {} to {}", userIndex, currentStatus, desiredStatus);
// For users managed by our fabric, we MUST include userName per the Matter spec
// For external fabric users, we MUST pass null for userName
String userName = currentUser.isManagedByFabric(ourFabricIndex) ? currentUser.userName : null;
setUser(userIndex, userName, null, desiredStatus).thenRun(() -> {
logger.debug("User {} status updated, refreshing users from lock", userIndex);
fetchAllUsers();
}).exceptionally(e -> {
logger.warn("Failed to update user {} status: {}", userIndex, e.getMessage());
fetchAllUsers();
return null;
});
return; // Don't process other updates when changing status
}
}
if (userExistsOnLock && currentUser != null && !currentUser.isManagedByFabric(ourFabricIndex)) {
logger.debug("User {} is managed by fabric {}, our fabric is {}. Only status changes allowed.", userIndex,
currentUser.creatorFabricIndex, ourFabricIndex);
return;
}
Object userNameValue = config.get(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_NAME);
Object userTypeValue = config.get(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_USER_TYPE);
Object pinValue = config.get(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_PIN_CREDENTIAL);
String configuredUserName = userNameValue instanceof String ? (String) userNameValue : "";
UserTypeEnum configuredUserType = UserTypeEnum.UNRESTRICTED_USER;
if (userTypeValue instanceof Number number) {
int typeInt = number.intValue();
for (UserTypeEnum type : UserTypeEnum.values()) {
if (type.getValue() == typeInt) {
configuredUserType = type;
break;
}
}
}
String configuredPin = pinValue instanceof String ? (String) pinValue : "";
// Check if pin is provided - this takes priority as it can create a user
if (pinCredentialSupported && !configuredPin.isEmpty()) {
// Clear the pin from config since we can not retrieve it from the lock once set
handler.updateConfiguration(
Map.of(groupName + "_" + MatterBindingConstants.CONFIG_DOORLOCK_PIN_CREDENTIAL, ""));
// If user doesn't exist, create them first, then set pin
if (!userExistsOnLock) {
String userName = configuredUserName.isEmpty() ? "User " + userIndex : configuredUserName;
final UserTypeEnum finalUserType = configuredUserType;
setUser(userIndex, userName, finalUserType, null).thenCompose(v -> {
logger.debug("User {} created, now setting PIN", userIndex);
return setPinCredential(userIndex, configuredPin);
}).thenRun(() -> {
logger.debug("PIN set for user {}, refreshing users from lock", userIndex);
fetchAllUsers();
}).exceptionally(e -> {
logger.warn("Failed to create user {} with PIN: {}", userIndex, e.getMessage());
fetchAllUsers();
return null;
});
} else {
setPinCredential(userIndex, configuredPin).thenRun(() -> {
logger.debug("PIN set for user {}, refreshing users from lock", userIndex);
fetchAllUsers();
}).exceptionally(e -> {
logger.warn("Failed to set PIN for user {}: {}", userIndex, e.getMessage());
return null;
});
}
return;
} else {
logger.warn("PIN credentials not supported or No PIN provided for user at index {}, skipping PIN set",
userIndex);
}
// Check if user name/type changed
if (!configuredUserName.isEmpty()) {
String currentUserName = currentUser != null ? currentUser.userName : null;
boolean nameChanged = !userExistsOnLock || !configuredUserName.equals(currentUserName);
boolean typeChanged = userExistsOnLock && currentUser != null && currentUser.userType != null
&& currentUser.userType != configuredUserType;
logger.debug(
"User {} config check: configuredName='{}', currentName='{}', userExists={}, nameChanged={}, typeChanged={}",
userIndex, configuredUserName, currentUserName, userExistsOnLock, nameChanged, typeChanged);
if (nameChanged || typeChanged) {
final UserTypeEnum finalUserType = configuredUserType;
logger.debug("Updating user {} with name='{}', type={}", userIndex, configuredUserName, finalUserType);
setUser(userIndex, configuredUserName, finalUserType, null).thenRun(() -> {
logger.debug("User {} updated, refreshing users from lock", userIndex);
fetchAllUsers();
}).exceptionally(e -> {
logger.warn("Failed to update user {}: {}", userIndex, e.getMessage());
fetchAllUsers();
return null;
});
}
}
}
private String buildPinCodePattern(int minLength, int maxLength) {
if (minLength > 0 && maxLength > 0) {
return "^[0-9]{" + minLength + "," + maxLength + "}$";
} else if (minLength > 0) {
return "^[0-9]{" + minLength + ",}$";
} else if (maxLength > 0) {
return "^[0-9]{0," + maxLength + "}$";
} else {
return "^[0-9]*$";
}
}
private String buildPinCodeDescription(String descriptionKey) {
return handler.getTranslation(descriptionKey, minPinCodeLength, maxPinCodeLength);
}
}
@@ -138,13 +138,19 @@ public class FanControlConverter extends GenericConverter<FanControlCluster> {
}
} else if (command instanceof PercentType percentType) {
handler.writeAttribute(endpointNumber, FanControlCluster.CLUSTER_NAME,
FanControlCluster.ATTRIBUTE_PERCENT_SETTING, percentType.toString());
FanControlCluster.ATTRIBUTE_PERCENT_SETTING, percentType.toString()).exceptionally(e -> {
logger.debug("Failed to set percent setting: {}", e.getMessage());
return Void.TYPE.cast(null);
});
}
}
if (channelUID.getIdWithoutGroup().equals(CHANNEL_ID_FANCONTROL_MODE)) {
if (command instanceof DecimalType decimalType) {
handler.writeAttribute(endpointNumber, FanControlCluster.CLUSTER_NAME,
FanControlCluster.ATTRIBUTE_FAN_MODE, decimalType.toString());
FanControlCluster.ATTRIBUTE_FAN_MODE, decimalType.toString()).exceptionally(e -> {
logger.debug("Failed to set fan mode: {}", e.getMessage());
return Void.TYPE.cast(null);
});
}
}
super.handleCommand(channelUID, command);
@@ -23,6 +23,7 @@ import org.openhab.binding.matter.internal.client.dto.cluster.gen.BaseCluster;
import org.openhab.binding.matter.internal.client.dto.ws.AttributeChangedMessage;
import org.openhab.binding.matter.internal.client.dto.ws.EventTriggeredMessage;
import org.openhab.binding.matter.internal.handler.MatterBaseThingHandler;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
@@ -84,6 +85,16 @@ public abstract class GenericConverter<T extends BaseCluster> implements Attribu
// add polling logic here in subclasses
}
/**
* This method is designed to be optionally overridden in subclasses when a cluster needs to handle configuration
* updates from the Thing configuration.
*
* @param config The updated configuration
*/
public void handleConfigurationUpdate(Configuration config) {
// add configuration update handling logic here in subclasses
}
@Override
public void onEvent(AttributeChangedMessage message) {
}
@@ -188,18 +188,27 @@ public class ThermostatConverter extends GenericConverter<ThermostatCluster> {
if (!(command instanceof RefreshType)) {
String id = channelUID.getIdWithoutGroup();
if (id.equals(CHANNEL_ID_THERMOSTAT_SYSTEMMODE)) {
handler.writeAttribute(endpointNumber, ThermostatCluster.CLUSTER_NAME, "systemMode",
command.toString());
handler.writeAttribute(endpointNumber, ThermostatCluster.CLUSTER_NAME, "systemMode", command.toString())
.exceptionally(e -> {
logger.debug("Failed to set system mode: {}", e.getMessage());
return Void.TYPE.cast(null);
});
return;
}
if (id.equals(CHANNEL_ID_THERMOSTAT_OCCUPIEDHEATING)) {
handler.writeAttribute(endpointNumber, ThermostatCluster.CLUSTER_NAME, "occupiedHeatingSetpoint",
String.valueOf(ValueUtils.temperatureToValue(command)));
String.valueOf(ValueUtils.temperatureToValue(command))).exceptionally(e -> {
logger.debug("Failed to set occupied heating setpoint: {}", e.getMessage());
return Void.TYPE.cast(null);
});
return;
}
if (id.equals(CHANNEL_ID_THERMOSTAT_OCCUPIEDCOOLING)) {
handler.writeAttribute(endpointNumber, ThermostatCluster.CLUSTER_NAME, "occupiedCoolingSetpoint",
String.valueOf(ValueUtils.temperatureToValue(command)));
String.valueOf(ValueUtils.temperatureToValue(command))).exceptionally(e -> {
logger.debug("Failed to set occupied cooling setpoint: {}", e.getMessage());
return Void.TYPE.cast(null);
});
return;
}
}
@@ -29,6 +29,7 @@ import org.openhab.binding.matter.internal.client.dto.ws.EventTriggeredMessage;
import org.openhab.binding.matter.internal.controller.devices.converter.ConverterRegistry;
import org.openhab.binding.matter.internal.controller.devices.converter.GenericConverter;
import org.openhab.binding.matter.internal.handler.MatterBaseThingHandler;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
@@ -182,6 +183,17 @@ public abstract class DeviceType implements AttributeListener, EventTriggeredLis
});
}
/**
* Handles configuration updates by delegating to all cluster converters
*
* @param config The updated configuration
*/
public void handleConfigurationUpdate(Configuration config) {
clusterToConverters.forEach((clusterId, converter) -> {
converter.handleConfigurationUpdate(config);
});
}
/**
* Returns an unmodifiable map of all clusters associated with this device type.
* The map keys are cluster names
@@ -76,7 +76,8 @@ import org.slf4j.LoggerFactory;
public class ControllerHandler extends BaseBridgeHandler implements MatterClientListener, MatterDiscoveryHandler {
private final Logger logger = LoggerFactory.getLogger(ControllerHandler.class);
private static final int CONNECTION_TIMEOUT_MS = 180000; // 3 minutes
private static final int CONNECTION_TIMEOUT_MS = 300000; // 5 minutes to allow for slow devices to connect (like
// thread powered devices)
private final MatterWebsocketService websocketService;
// Set of nodes we are waiting to connect to
private Set<BigInteger> outstandingNodeRequests = Collections.synchronizedSet(new HashSet<>());
@@ -44,6 +44,7 @@ import org.openhab.binding.matter.internal.client.dto.cluster.gen.BasicInformati
import org.openhab.binding.matter.internal.client.dto.cluster.gen.BridgedDeviceBasicInformationCluster;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.GeneralDiagnosticsCluster;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.GeneralDiagnosticsCluster.NetworkInterface;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.OperationalCredentialsCluster;
import org.openhab.binding.matter.internal.client.dto.ws.AttributeChangedMessage;
import org.openhab.binding.matter.internal.client.dto.ws.EventTriggeredMessage;
import org.openhab.binding.matter.internal.controller.MatterControllerClient;
@@ -101,6 +102,7 @@ public abstract class MatterBaseThingHandler extends BaseThingHandler
protected final TranslationService translationService;
protected Map<Integer, DeviceType> devices = new HashMap<>();
protected @Nullable MatterControllerClient cachedClient;
private int currentFabricIndex = 0;
private @Nullable ScheduledFuture<?> pollingTask;
public MatterBaseThingHandler(Thing thing, BaseThingHandlerFactory thingHandlerFactory,
@@ -172,6 +174,14 @@ public abstract class MatterBaseThingHandler extends BaseThingHandler
super.handleRemoval();
}
@Override
public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
super.handleConfigurationUpdate(configurationParameters);
// Delegate configuration updates to all device types/converters
Configuration config = getThing().getConfiguration();
devices.values().forEach(deviceType -> deviceType.handleConfigurationUpdate(config));
}
@Override
protected ThingBuilder editThing() {
return ThingBuilder.create(getDynamicThingTypeUID(), getThing().getUID()).withBridge(getThing().getBridgeUID())
@@ -271,10 +281,11 @@ public abstract class MatterBaseThingHandler extends BaseThingHandler
* @param attributeName The attribute name.
* @param value The value to write.
*/
public void writeAttribute(Integer endpointId, String clusterName, String attributeName, String value) {
public CompletableFuture<Void> writeAttribute(Integer endpointId, String clusterName, String attributeName,
String value) {
MatterControllerClient ws = getClient();
if (ws != null) {
ws.clusterWriteAttribute(getNodeId(), endpointId, clusterName, attributeName, value);
return ws.clusterWriteAttribute(getNodeId(), endpointId, clusterName, attributeName, value);
} else {
throw new IllegalStateException("Client is null");
}
@@ -396,6 +407,14 @@ public abstract class MatterBaseThingHandler extends BaseThingHandler
return translationService.getTranslation(key);
}
public final String getTranslation(String key, Object... args) {
return translationService.getTranslation(key, args);
}
public int getCurrentFabricIndex() {
return currentFabricIndex;
}
protected @Nullable ControllerHandler controllerHandler() {
Bridge bridge = getBridge();
while (bridge != null) {
@@ -465,6 +484,14 @@ public abstract class MatterBaseThingHandler extends BaseThingHandler
String.join(",", allIpv4Addresses));
}
}
cluster = root.clusters.get(OperationalCredentialsCluster.CLUSTER_NAME);
if (cluster != null && cluster instanceof OperationalCredentialsCluster operationalCredentialsCluster) {
updateClusterAttributeProperty(OperationalCredentialsCluster.CLUSTER_NAME,
OperationalCredentialsCluster.ATTRIBUTE_CURRENT_FABRIC_INDEX,
operationalCredentialsCluster.currentFabricIndex);
currentFabricIndex = operationalCredentialsCluster.currentFabricIndex;
}
}
/**
@@ -77,8 +77,12 @@ channel-type.matter.colorcontrol-temperature-abs.label = Color Temperature
channel-type.matter.colorcontrol-temperature-abs.description = Sets the color temperature of the light in mirek
channel-type.matter.colorcontrol-temperature.label = Color Temperature
channel-type.matter.colorcontrol-temperature.description = Sets the color temperature of the light
channel-type.matter.doorlock-alarm.label = Door Lock Alarm
channel-type.matter.doorlock-alarm.description = Event that fires when a lock alarm occurs with a payload containing the alarm code
channel-type.matter.doorlock-doorstate.label = Door Sensor State
channel-type.matter.doorlock-doorstate.description = Door Sensor State
channel-type.matter.doorlock-lockoperationerror.label = Door Lock Lock Operation Error
channel-type.matter.doorlock-lockoperationerror.description = Event that fires when a lock operation error occurs with a payload containing the lock operation type
channel-type.matter.doorlock-lockstate.label = Door Lock State
channel-type.matter.doorlock-lockstate.description = Locks and unlocks the door and maintains the lock state
channel-type.matter.electricalenergymeasurement-energymeasurmement-energy.label = Energy
@@ -224,6 +228,36 @@ thing-type.config.matter.node.security_policy_to_ble_link.description = Security
thing-type.config.matter.node.security_policy_non_ccm_routers.label = Security Policy: Non-CCM Routers
thing-type.config.matter.node.security_policy_non_ccm_routers.description = Security Policy: Non-CCM Routers. Defaults to false.
# door lock configuration
thing-type.config.matter.node.doorlock_management.label = Door Lock Management
thing-type.config.matter.node.doorlock_management.description = General door lock settings and user management options.
thing-type.config.matter.node.doorlock_operating_mode.label = Operating Mode
thing-type.config.matter.node.doorlock_operating_mode.description = The current operating mode of the lock (Normal, Vacation, Privacy, etc.).
thing-type.config.matter.node.doorlock_auto_relock_time.label = Auto Relock Time
thing-type.config.matter.node.doorlock_auto_relock_time.description = The number of seconds to wait after unlocking before automatically relocking. Set to 0 to disable.
thing-type.config.matter.node.doorlock_one_touch_locking.label = One Touch Locking
thing-type.config.matter.node.doorlock_one_touch_locking.description = Enable or disable the ability to lock the door with a single touch.
thing-type.config.matter.node.doorlock_default_lock_pin.label = Default Lock PIN
thing-type.config.matter.node.doorlock_default_lock_pin.description = The PIN code to use when locking or unlocking the door remotely. Required when the lock has "Require PIN for Remote Operation" enabled. Only numeric digits are allowed with a minimum length of {0} digits, and maximum length of {1} digits.
thing-type.config.matter.node.doorlock_displayed_user_count.label = Displayed User Count
thing-type.config.matter.node.doorlock_displayed_user_count.description = The number of user credential slots to display for configuration. Set to 0 to only show existing users.
thing-type.config.matter.node.doorlock_user.label = User
thing-type.config.matter.node.doorlock_user.description = User credential configuration for this lock slot.
thing-type.config.matter.node.doorlock_user_name.label = User Name
thing-type.config.matter.node.doorlock_user_name.description = The name of this user.
thing-type.config.matter.node.doorlock_no_user_name.label = Unnamed User
thing-type.config.matter.node.doorlock_user_type.label = User Type
thing-type.config.matter.node.doorlock_user_type.description = The type of user (Unrestricted, Schedule Restricted, etc.).
thing-type.config.matter.node.doorlock_user_enabled.label = User Enabled
thing-type.config.matter.node.doorlock_user_enabled.description = Enable or disable this user. A disabled user cannot unlock the door with their credentials.
thing-type.config.matter.node.doorlock_pin_credential.label = PIN Code
thing-type.config.matter.node.doorlock_pin_credential.description = The PIN code for this user. After saving, the PIN will be sent to the lock and cleared from configuration for security. Only numeric digits are allowed with a minimum length of {0} digits, and maximum length of {1} digits.
thing-type.config.matter.node.doorlock_delete_user.label = Delete User
thing-type.config.matter.node.doorlock_delete_user.description = Enable this option and save to delete this user from the lock. The flag will be reset after deletion.
thing-type.config.matter.node.doorlock_external_fabric.label = Managed by Other Fabric
thing-type.config.matter.node.doorlock_external_fabric.description = This user was created by another Matter controller (e.g., Apple Home, Google Home). Only deletion or disabling is allowed from openHAB.
# discovery
discovery.matter.scan-input.label = Matter Pairing Code
@@ -440,6 +440,38 @@
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="doorlock-alarm">
<kind>trigger</kind>
<label>Door Lock Alarm</label>
<description>Event that fires when a lock alarm occurs with a payload containing the alarm code</description>
<event>
<options>
<option value="0">Lock Jammed</option>
<option value="1">Lock Factory Reset</option>
<option value="3">Lock Radio Power Cycled</option>
<option value="4">Wrong Code Entry Limit</option>
<option value="5">Front Escutcheon Removed</option>
<option value="6">Door Forced Open</option>
<option value="7">Door Ajar</option>
<option value="8">Forced User</option>
</options>
</event>
</channel-type>
<channel-type id="doorlock-lockoperationerror">
<kind>trigger</kind>
<label>Door Lock Lock Operation Error</label>
<description>Event that fires when a lock operation error occurs with a payload containing the lock operation type</description>
<event>
<options>
<option value="0">Lock</option>
<option value="1">Unlock</option>
<option value="2">Non Access User Event</option>
<option value="3">Forced User Event</option>
<option value="4">Unlatch</option>
</options>
</event>
</channel-type>
<channel-type id="electricalenergymeasurement-energymeasurmement-energy">
<item-type>Number:Energy</item-type>
@@ -12,7 +12,7 @@
*/
package org.openhab.binding.matter.internal.controller.devices.converter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@@ -24,13 +24,20 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.CredentialTypeEnum;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.UserStatusEnum;
import org.openhab.binding.matter.internal.client.dto.cluster.gen.DoorLockCluster.UserTypeEnum;
import org.openhab.binding.matter.internal.client.dto.ws.AttributeChangedMessage;
import org.openhab.binding.matter.internal.client.dto.ws.EventTriggeredMessage;
import org.openhab.binding.matter.internal.client.dto.ws.Path;
import org.openhab.binding.matter.internal.client.dto.ws.TriggerEvent;
import org.openhab.binding.matter.internal.controller.devices.converter.DoorLockConverter.LockUser;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.type.ChannelKind;
import org.openhab.core.types.StateDescription;
/**
@@ -61,10 +68,30 @@ class DoorLockConverterTest extends BaseMatterConverterTest {
void testCreateChannels() {
ChannelGroupUID channelGroupUID = new ChannelGroupUID("matter:node:test:12345:1");
Map<Channel, @Nullable StateDescription> channels = converter.createChannels(channelGroupUID);
assertEquals(1, channels.size());
Channel channel = channels.keySet().iterator().next();
assertEquals("matter:node:test:12345:1#doorlock-lockstate", channel.getUID().toString());
assertEquals("Switch", channel.getAcceptedItemType());
assertEquals(3, channels.size());
boolean foundLockState = false;
boolean foundAlarm = false;
boolean foundLockOperationError = false;
for (Channel channel : channels.keySet()) {
String channelId = channel.getUID().getIdWithoutGroup();
switch (channelId) {
case "doorlock-lockstate":
assertEquals("Switch", channel.getAcceptedItemType());
foundLockState = true;
break;
case "doorlock-alarm":
assertEquals(ChannelKind.TRIGGER, channel.getKind());
foundAlarm = true;
break;
case "doorlock-lockoperationerror":
assertEquals(ChannelKind.TRIGGER, channel.getKind());
foundLockOperationError = true;
break;
}
}
assertTrue(foundLockState, "Should have lockstate channel");
assertTrue(foundAlarm, "Should have alarm trigger channel");
assertTrue(foundLockOperationError, "Should have lockoperationerror trigger channel");
}
@Test
@@ -73,18 +100,16 @@ class DoorLockConverterTest extends BaseMatterConverterTest {
ChannelGroupUID channelGroupUID = new ChannelGroupUID("matter:node:test:12345:1");
Map<Channel, @Nullable StateDescription> channels = converter.createChannels(channelGroupUID);
assertEquals(2, channels.size());
assertEquals(4, channels.size());
boolean foundDoorState = false;
for (Channel channel : channels.keySet()) {
String channelId = channel.getUID().getIdWithoutGroup();
switch (channelId) {
case "doorlock-lockstate":
assertEquals("Switch", channel.getAcceptedItemType());
break;
case "doorlock-doorstate":
assertEquals("Contact", channel.getAcceptedItemType());
break;
if ("doorlock-doorstate".equals(channelId)) {
assertEquals("Contact", channel.getAcceptedItemType());
foundDoorState = true;
}
}
assertTrue(foundDoorState, "Should have doorstate channel when doorPositionSensor feature is enabled");
}
@Test
@@ -123,10 +148,165 @@ class DoorLockConverterTest extends BaseMatterConverterTest {
verify(mockHandler, times(1)).updateState(eq(1), eq("doorlock-doorstate"), eq(OpenClosedType.CLOSED));
}
@Test
void testOnEventWithDoorStateOpen() {
AttributeChangedMessage message = new AttributeChangedMessage();
message.path = new Path();
message.path.attributeName = "doorState";
message.value = DoorLockCluster.DoorStateEnum.DOOR_OPEN;
converter.onEvent(message);
verify(mockHandler, times(1)).updateState(eq(1), eq("doorlock-doorstate"), eq(OpenClosedType.OPEN));
}
@Test
void testOnEventWithOperatingMode() {
AttributeChangedMessage message = new AttributeChangedMessage();
message.path = new Path();
message.path.attributeName = "operatingMode";
message.value = DoorLockCluster.OperatingModeEnum.VACATION;
converter.onEvent(message);
verify(mockHandler, times(1)).updateConfiguration(anyMap());
}
@Test
void testOnEventWithAutoRelockTime() {
AttributeChangedMessage message = new AttributeChangedMessage();
message.path = new Path();
message.path.attributeName = "autoRelockTime";
message.value = Integer.valueOf(30);
converter.onEvent(message);
verify(mockHandler, times(1)).updateConfiguration(anyMap());
}
@Test
void testOnEventWithOneTouchLocking() {
AttributeChangedMessage message = new AttributeChangedMessage();
message.path = new Path();
message.path.attributeName = "enableOneTouchLocking";
message.value = Boolean.TRUE;
converter.onEvent(message);
verify(mockHandler, times(1)).updateConfiguration(anyMap());
}
@Test
void testInitState() {
mockCluster.lockState = DoorLockCluster.LockStateEnum.LOCKED;
converter.initState();
verify(mockHandler, times(1)).updateState(eq(1), eq("doorlock-lockstate"), eq(OnOffType.ON));
}
@Test
void testInitStateUnlocked() {
mockCluster.lockState = DoorLockCluster.LockStateEnum.UNLOCKED;
converter.initState();
verify(mockHandler, times(1)).updateState(eq(1), eq("doorlock-lockstate"), eq(OnOffType.OFF));
}
@Test
void testOnDoorLockAlarmEvent() {
EventTriggeredMessage message = new EventTriggeredMessage();
message.path = new Path();
message.path.eventName = "doorLockAlarm";
DoorLockCluster.DoorLockAlarm alarmData = new DoorLockCluster.DoorLockAlarm(
DoorLockCluster.AlarmCodeEnum.LOCK_JAMMED);
message.events = new TriggerEvent[] { new TriggerEvent() };
message.events[0].data = alarmData;
converter.onEvent(message);
verify(mockHandler, times(1)).triggerChannel(eq(1), eq("doorlock-alarm"), eq("0"));
}
@Test
void testOnLockOperationErrorEvent() {
EventTriggeredMessage message = new EventTriggeredMessage();
message.path = new Path();
message.path.eventName = "lockOperationError";
// INVALID_CREDENTIAL has value 1
DoorLockCluster.LockOperationError errorData = new DoorLockCluster.LockOperationError(
DoorLockCluster.LockOperationTypeEnum.LOCK, DoorLockCluster.OperationSourceEnum.MANUAL,
DoorLockCluster.OperationErrorEnum.INVALID_CREDENTIAL, null, null, null, null);
message.events = new TriggerEvent[] { new TriggerEvent() };
message.events[0].data = errorData;
converter.onEvent(message);
verify(mockHandler, times(1)).triggerChannel(eq(1), eq("doorlock-lockoperationerror"), eq("1"));
}
// LockUser tests
@Test
void testLockUserIsOccupied() {
LockUser user = new LockUser(1);
assertFalse(user.isOccupied(), "New user should not be occupied");
user.userStatus = UserStatusEnum.AVAILABLE;
assertFalse(user.isOccupied(), "Available user should not be occupied");
user.userStatus = UserStatusEnum.OCCUPIED_ENABLED;
assertTrue(user.isOccupied(), "Occupied enabled user should be occupied");
user.userStatus = UserStatusEnum.OCCUPIED_DISABLED;
assertTrue(user.isOccupied(), "Occupied disabled user should be occupied");
}
@Test
void testLockUserHasCredentialOfType() {
LockUser user = new LockUser(1);
assertFalse(user.hasCredentialOfType(CredentialTypeEnum.PIN), "New user should have no credentials");
user.credentials.add(new DoorLockCluster.CredentialStruct(CredentialTypeEnum.PIN, 1));
assertTrue(user.hasCredentialOfType(CredentialTypeEnum.PIN), "User should have PIN credential");
assertFalse(user.hasCredentialOfType(CredentialTypeEnum.RFID), "User should not have RFID credential");
user.credentials.add(new DoorLockCluster.CredentialStruct(CredentialTypeEnum.RFID, 2));
assertTrue(user.hasCredentialOfType(CredentialTypeEnum.RFID), "User should have RFID credential");
}
@Test
void testLockUserIsManagedByFabric() {
LockUser user = new LockUser(1);
// No creator fabric - should be managed by any fabric
assertTrue(user.isManagedByFabric(1), "User with no creator fabric should be managed by any fabric");
assertTrue(user.isManagedByFabric(2), "User with no creator fabric should be managed by any fabric");
// Set creator fabric
user.creatorFabricIndex = 1;
assertTrue(user.isManagedByFabric(1), "User should be managed by its creator fabric");
assertFalse(user.isManagedByFabric(2), "User should not be managed by other fabrics");
}
@Test
void testCreateChannelsWithUserFeature() {
// Enable user feature
mockCluster.featureMap.user = true;
mockCluster.numberOfTotalUsersSupported = 10;
// Create new converter with user feature enabled
DoorLockConverter userConverter = new DoorLockConverter(mockCluster, mockHandler, 1, "TestLabel");
ChannelGroupUID channelGroupUID = new ChannelGroupUID("matter:node:test:12345:1");
Map<Channel, @Nullable StateDescription> channels = userConverter.createChannels(channelGroupUID);
// Still creates the same channels - user management is via config, not channels
assertEquals(3, channels.size());
}
@Test
void testCreateChannelsWithPinCredentialFeature() {
// Enable PIN credential feature
mockCluster.featureMap.pinCredential = true;
mockCluster.minPinCodeLength = 4;
mockCluster.maxPinCodeLength = 8;
DoorLockConverter pinConverter = new DoorLockConverter(mockCluster, mockHandler, 1, "TestLabel");
ChannelGroupUID channelGroupUID = new ChannelGroupUID("matter:node:test:12345:1");
Map<Channel, @Nullable StateDescription> channels = pinConverter.createChannels(channelGroupUID);
assertEquals(3, channels.size());
}
@Test
void testLockUserDefaultUserType() {
LockUser user = new LockUser(1);
assertNull(user.userType, "New user should have null userType");
user.userType = UserTypeEnum.UNRESTRICTED_USER;
assertEquals(UserTypeEnum.UNRESTRICTED_USER, user.userType);
}
}