capabilities;
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DeviceAccessMethodSettings.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DeviceAccessMethodSettings.java
new file mode 100644
index 0000000000000..4617cd1f0cef2
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DeviceAccessMethodSettings.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * Device access-method settings.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class DeviceAccessMethodSettings {
+ public Nfc nfc;
+ public Bt btTap;
+ public Bt btButton;
+ public Bt btShake;
+ public MobileWave mobileWave;
+ public Wave wave;
+ public PinCode pinCode;
+ public Face face;
+ public QrCode qrCode;
+ public TouchPass touchPass;
+
+ public abstract static class EnabledFlag {
+ public Boolean enabled;
+ }
+
+ public static class Nfc extends EnabledFlag {
+ }
+
+ public static class Bt extends EnabledFlag {
+ }
+
+ public static class MobileWave extends EnabledFlag {
+ }
+
+ public static class Wave extends EnabledFlag {
+ }
+
+ public static class PinCode extends EnabledFlag {
+ public Boolean pinCodeShuffle;
+ }
+
+ public static class Face extends EnabledFlag {
+ public String antiSpoofingLevel; // high, medium, no
+ public String detectDistance; // near, medium, far
+ }
+
+ public static class QrCode extends EnabledFlag {
+ }
+
+ public static class TouchPass extends EnabledFlag {
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Door.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Door.java
new file mode 100644
index 0000000000000..6757dc544aee8
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Door.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * Door detail
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class Door {
+ public String id;
+ public String name;
+ public String fullName;
+ public String floorId;
+ public String type; // "door"
+ public Boolean isBindHub; // must be bound for remote unlock
+ public DoorState.LockState doorLockRelayStatus;
+ public DoorState.DoorPosition doorPositionStatus;
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorEmergencySettings.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorEmergencySettings.java
new file mode 100644
index 0000000000000..a66ca676308cd
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorEmergencySettings.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * Door emergency settings payload for get/set endpoints.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class DoorEmergencySettings {
+ public Boolean evacuation;
+ public Boolean lockdown;
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorGroup.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorGroup.java
new file mode 100644
index 0000000000000..6a91a715f8c76
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorGroup.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.util.List;
+
+/**
+ * Door Group (a.k.a. Space Group) composed of door resources.
+ *
+ *
+ * Use for create/fetch/update of door groups. Complements the topology view.
+ *
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class DoorGroup {
+ public String id;
+ public String name;
+ /**
+ * Group type: examples include "access" and "building".
+ * Kept as String to remain forward-compatible.
+ */
+ public String type;
+
+ public List resources;
+
+ public static class Resource {
+ public String id;
+ public String name;
+ /** Expected "door" but kept as String for forward-compatibility. */
+ public String type;
+ }
+
+ public boolean hasDoors() {
+ return resources != null && !resources.isEmpty();
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorLockRule.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorLockRule.java
new file mode 100644
index 0000000000000..fafacba3f234b
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorLockRule.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Lock rule payload and value object used for both setting and reading lock rules.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class DoorLockRule {
+ public DoorState.DoorLockRuleType type;
+ /** minutes, only for type=custom and for setting the rule */
+ public Integer interval = 0;
+ @SerializedName(value = "until", alternate = { "ended_time", "endtime" })
+ public Long until = 0L; // milliseconds since epoch
+
+ public DoorLockRule(DoorState.DoorLockRuleType type, Integer minutes) {
+ this.type = type;
+ this.interval = minutes;
+ this.until = System.currentTimeMillis() + minutes * 60 * 1000;
+ }
+
+ public DoorLockRule(DoorState.DoorLockRuleType type) {
+ this.type = type;
+ }
+
+ public static DoorLockRule keepUnlock() {
+ return new DoorLockRule(DoorState.DoorLockRuleType.KEEP_UNLOCK);
+ }
+
+ public static DoorLockRule keepLock() {
+ return new DoorLockRule(DoorState.DoorLockRuleType.KEEP_LOCK);
+ }
+
+ public static DoorLockRule customMinutes(int minutes) {
+ return new DoorLockRule(DoorState.DoorLockRuleType.CUSTOM, minutes);
+ }
+
+ public static DoorLockRule reset() {
+ return new DoorLockRule(DoorState.DoorLockRuleType.RESET);
+ }
+
+ public static DoorLockRule lockEarly() {
+ return new DoorLockRule(DoorState.DoorLockRuleType.LOCK_EARLY);
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorState.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorState.java
new file mode 100644
index 0000000000000..7beede5326747
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorState.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Shared door-related enums used across DTOs.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public final class DoorState {
+ private DoorState() {
+ }
+
+ /**
+ * Lock state. Accepts both "locked"/"unlocked" and "lock"/"unlock" during
+ * deserialization.
+ */
+ public enum LockState {
+ @SerializedName(value = "lock", alternate = { "locked" })
+ LOCKED,
+ @SerializedName(value = "unlock", alternate = { "unlocked" })
+ UNLOCKED
+ }
+
+ /** Door position status. */
+ public enum DoorPosition {
+ @SerializedName("open")
+ OPEN,
+ @SerializedName("close")
+ CLOSE
+ }
+
+ /**
+ * Remain unlock / lock rule type used by both notifications and lock rule APIs.
+ */
+ public enum DoorLockRuleType {
+ @SerializedName("custom")
+ CUSTOM,
+ @SerializedName("keep_unlock")
+ KEEP_UNLOCK,
+ @SerializedName("keep_lock")
+ KEEP_LOCK,
+ @SerializedName("reset")
+ RESET,
+ @SerializedName("lock_early")
+ LOCK_EARLY,
+ @SerializedName("lock_now")
+ LOCK_NOW,
+ @SerializedName("schedule")
+ SCHEDULE,
+ @SerializedName("")
+ NONE
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorUnlockRequest.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorUnlockRequest.java
new file mode 100644
index 0000000000000..5e8387bccf2c8
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/DoorUnlockRequest.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Request body for remote door unlock (optional actor + passthrough extra).
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class DoorUnlockRequest {
+ public String actorId;
+ public String actorName;
+ public Map extra = Map.of();
+
+ public DoorUnlockRequest(String actorId, String actorName, @Nullable Map extra) {
+ this.actorId = actorId;
+ this.actorName = actorName;
+ if (extra != null) {
+ this.extra = extra;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Image.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Image.java
new file mode 100644
index 0000000000000..37c8e78e95f4c
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Image.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * Hold images for door thumbnails.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class Image {
+ public String mediaType;
+ public byte[] data;
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcCard.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcCard.java
new file mode 100644
index 0000000000000..566d5e2a7d805
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcCard.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * NFC Card model.
+ * Represents a single NFC card and its assignment to a user.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class NfcCard {
+ public String alias;
+ public String cardType;
+ public String displayId;
+ public String note;
+ public String status;
+ public String token;
+ public UserSummary user;
+ public String userId;
+ public String userType;
+
+ public static class UserSummary {
+ public String firstName;
+ public String id;
+ public String lastName;
+ public String name;
+ }
+
+ /* ----------------- Helpers ----------------- */
+
+ /**
+ * Returns true if the card is assigned to any user.
+ */
+ public boolean isAssigned() {
+ return userId != null && !userId.isBlank();
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcEnrollSession.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcEnrollSession.java
new file mode 100644
index 0000000000000..4918f490066b2
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcEnrollSession.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * NFC Card Enrollment Session model
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class NfcEnrollSession {
+ public String id;
+ public String status;
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcEnrollStatus.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcEnrollStatus.java
new file mode 100644
index 0000000000000..7330d1fdbb362
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/NfcEnrollStatus.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * NFC Card Enrollment Status model
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class NfcEnrollStatus {
+ public String id;
+ public String status;
+ public String token;
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Notification.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Notification.java
new file mode 100644
index 0000000000000..66cd747759301
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Notification.java
@@ -0,0 +1,529 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.gson.annotations.JsonAdapter;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Notification envelope for UniFi Access WebSocket stream.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class Notification {
+ public String event;
+ public String receiverId;
+ public String eventObjectId;
+ public Boolean saveToHistory;
+ public JsonElement data;
+
+ /**
+ * access.remote_view data payload.
+ * When a doorbell rings
+ */
+ public static class RemoteViewData {
+ public String channel;
+ public String token;
+ public String deviceId;
+ public String deviceType;
+ public String deviceName;
+ public String doorName;
+ public String controllerId;
+ public String floorName;
+ public String requestId;
+ public String clearRequestId;
+ public String inOrOut;
+ public long createTime;
+ public int reasonCode;
+ public List doorGuardIds;
+ public String connectedUahId;
+ public String roomId;
+ public String hostDeviceMac;
+ }
+
+ /**
+ * access.remote_view.change data payload.
+ * Doorbell status change
+ */
+ public static class RemoteViewChangeData {
+ public Reason reason;
+ public String remoteCallRequestId;
+
+ /** Possible values for {@code reason_code} in access.remote_view.change events. */
+ @JsonAdapter(Reason.Adapter.class)
+ public enum Reason {
+ DOORBELL_TIMED_OUT(105, "Doorbell timed out."),
+ ADMIN_REJECTED_UNLOCK(106, "An admin rejected to unlock a door."),
+ ADMIN_UNLOCK_SUCCEEDED(107, "An admin successfully unlocked a door."),
+ VISITOR_CANCELED_DOORBELL(108, "A visitor canceled a doorbell."),
+ ANSWERED_BY_ANOTHER_ADMIN(400, "Doorbell was answered by another admin."),
+ UNKNOWN(-1, "Unknown");
+
+ private final int code;
+ private final String description;
+
+ Reason(int code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ public int code() {
+ return code;
+ }
+
+ public String description() {
+ return description;
+ }
+
+ public static Reason fromCode(int code) {
+ for (Reason value : values()) {
+ if (value.code == code) {
+ return value;
+ }
+ }
+ return UNKNOWN;
+ }
+
+ public static final class Adapter implements JsonDeserializer, JsonSerializer {
+ @Override
+ public Reason deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+ throws JsonParseException {
+ try {
+ int code = json.getAsInt();
+ return Reason.fromCode(code);
+ } catch (Exception e) {
+ return Reason.UNKNOWN;
+ }
+ }
+
+ @Override
+ public JsonElement serialize(Reason src, Type typeOfSrc, JsonSerializationContext context) {
+ return new JsonPrimitive(src.code());
+ }
+ }
+ }
+ }
+
+ /**
+ * access.data.device.remote_unlock data payload.
+ * Remote door unlock by admin
+ */
+ public static class RemoteUnlockData {
+ public String uniqueId;
+ public String name;
+ public String upId;
+ public String timezone;
+ public String locationType;
+ public String extraType;
+ public String fullName;
+ public int level;
+ public String workTime;
+ public String workTimeId;
+ public Map extras;
+ }
+
+ public @Nullable T dataAs(Gson gson, Class type) {
+ if (data == null || !data.isJsonObject()) {
+ return null;
+ }
+ return gson.fromJson(data.getAsJsonObject(), type);
+ }
+
+ public RemoteViewData dataAsRemoteView(Gson gson) {
+ return dataAs(gson, RemoteViewData.class);
+ }
+
+ public RemoteViewChangeData dataAsRemoteViewChange(Gson gson) {
+ return dataAs(gson, RemoteViewChangeData.class);
+ }
+
+ public RemoteUnlockData dataAsRemoteUnlock(Gson gson) {
+ return dataAs(gson, RemoteUnlockData.class);
+ }
+
+ /** access.data.device.update data payload (covers all observed fields). */
+ public static class DeviceUpdateData {
+ public String uniqueId;
+ public String name;
+ public String alias;
+ public String deviceType;
+ public String connectedUahId;
+ public String locationId;
+ public String firmware;
+ public String version;
+ public String ip;
+ public String mac;
+ public String hwType;
+ public long startTime;
+ public long lastSeen;
+ public boolean securityCheck;
+ public String source;
+ public String bomRev;
+ public String guid;
+ public boolean needAdvisory;
+ public boolean isAdopted;
+ public boolean isManaged;
+ public boolean isConnected;
+ public boolean isOnline;
+ public boolean isRebooting;
+ public boolean isUnavailable;
+ public boolean adopting;
+ public Location location;
+ public Location door;
+ public Floor floor;
+ public List configEntries;
+ public List capabilities;
+ public String resourceName;
+ public String displayModel;
+ public String revision;
+ public long revisionUpdateTime;
+ public long versionUpdateTime;
+ public long firmwareUpdateTime;
+ public long adoptTime;
+ public JsonElement update;
+ public UpdateManual updateManual;
+ public List extensions;
+ public String model;
+ public Images images;
+ public String provisionTime;
+ public String provisionPercent;
+ public JsonElement template;
+
+ public static class Location {
+ public String uniqueId;
+ public String name;
+ public String upId;
+ public String timezone;
+ public String locationType;
+ public String extraType;
+ public String fullName;
+ public int level;
+ public String workTime;
+ public String workTimeId;
+ public JsonObject extras;
+ public List previousName;
+ }
+
+ public static class Floor {
+ public String uniqueId;
+ public String name;
+ public String upId;
+ public String timezone;
+ public String locationType;
+ public String extraType;
+ public String fullName;
+ public int level;
+ public String workTime;
+ public String workTimeId;
+ public JsonObject extras;
+ public JsonElement previousName;
+ }
+
+ public static class ConfigEntry {
+ public String deviceId;
+ public String key;
+ public String value;
+ public String tag;
+ public String updateTime;
+ public String createTime;
+ }
+
+ public static class UpdateManual {
+ public DeviceVersionUpgradeStatus dvus;
+ public String fromVersion;
+ public JsonElement lastUpgradeStartTime;
+ public JsonElement lastUpgradeSuccess;
+ public String lastUpgradeFailureReason;
+ public JsonElement lastDownloadStartTime;
+ public JsonElement lastDownloadSuccess;
+ public String lastDownloadFailureReason;
+ public boolean downloaded;
+
+ public static class DeviceVersionUpgradeStatus {
+ public boolean completed;
+ public boolean isWaiting;
+ public boolean isPreparing;
+ public boolean isUpgrading;
+ public int upgradeSeconds;
+ public boolean timedOut;
+ public boolean failed;
+ public String failureReason;
+ public boolean isDownloading;
+ }
+ }
+
+ public static class Extension {
+ public String uniqueId;
+ public String deviceId;
+ public String extensionName;
+ public String sourceId;
+ public String targetType;
+ public String targetValue;
+ public String targetName;
+ public List targetConfig;
+
+ public static class TargetConfig {
+ public String configTag;
+ public String configKey;
+ public JsonElement configValue;
+ }
+ }
+
+ public static class Images {
+ public String xs;
+ public String s;
+ public String m;
+ public String l;
+ public String xl;
+ public String xxl;
+ public String base;
+ }
+ }
+
+ public static class LocationState {
+ public String locationId;
+ public DoorState.LockState lock;
+ public DoorState.DoorPosition dps;
+ public Boolean dpsConnected;
+ public DoorLockRule remainUnlock;
+ public List alarms;
+ public Emergency emergency;
+ public Boolean isUnavailable;
+ }
+
+ public static class Alarm {
+ public String type;
+ }
+
+ public static class Emergency {
+ public String software;
+ public String hardware;
+ }
+
+ // public static class RemainUnlock {
+ // public DoorState.LockState state;
+ // public Long until;
+ // public DoorState.DoorLockRuleType type;
+ // }
+
+ /** access.data.v2.device.update payload (partial). */
+ public static class DeviceUpdateV2Data {
+ public String name;
+ public String alias;
+ public String id;
+ public String ip;
+ public String mac;
+ public Boolean online;
+ public Boolean adopting;
+ public String deviceType;
+ public String connectedHubId;
+ public String locationId;
+ public String firmware;
+ public String version;
+ public String guid;
+ public Long startTime;
+ public String hwType;
+ public String revision;
+ public JsonElement cap;
+ @Nullable
+ public List locationStates;
+ public List category;
+ }
+
+ /** access.data.v2.location.update payload (partial). */
+ public static class LocationUpdateV2Data {
+ public String id;
+ public String locationType;
+ public String name;
+ public String upId;
+ public JsonElement extras;
+ public List deviceIds;
+ public LocationState state;
+ public Thumbnail thumbnail;
+ public Long lastActivity;
+
+ public static class Thumbnail {
+ public String type;
+ public String url;
+ public Long doorThumbnailLastUpdate;
+ }
+ }
+
+ /**
+ * Reusable simple references used across log payloads.
+ */
+ public static class Actor {
+ public String id;
+ public String type;
+ public String displayName;
+ public String firstName;
+ public String lastName;
+ }
+
+ // this is use to point to a base access object like a door, floor, camera, config
+ public static class BaseReference {
+ public String id;
+ public String type;
+ public String displayName;
+ }
+
+ public static class CameraCaptureReference extends BaseReference {
+ public String alternateId; // this is the camera ID used in the Protect binding !
+ public String alternateName; // strange, actually a URL to a tiny thumbnail of the product picture ?
+ public String videoUrl; // relative path to the video using the Unifi Protect App
+ public String videoFileName; // interesting, this a reference to the recorded clip ?
+ public String videoSource; // protect
+ public String thumbnailUrl; // relative path to the thumbnail using the Unifi Protect App
+ }
+
+ /** access.logs.insights.add payload (partial). */
+ public static class LogsInsightsAddData {
+ public String logKey;
+ public String eventType; // access.door.unlock....
+ public String message;
+ public Long published; // epoch millis
+ public String result; // ACCESS / DENY / etc
+ public JsonObject metadata;
+ }
+
+ /** access.logs.insights.add payload (typed). */
+ public static class InsightLogsAddData {
+ public String logKey; // dashboard.access.door.unlock.success
+ public String eventType; // access.door.unlock
+ public String message; // human-readable message
+ public Long published; // epoch millis
+ public String result; // ACCESS / DENY / etc
+ public Metadata metadata;
+
+ public static class Metadata {
+ public Actor actor;
+ public Authentication authentication;
+ public BaseReference building;
+ public CameraCaptureReference cameraCapture;
+ public BaseReference device;
+ public BaseReference deviceConfig;
+ public BaseReference deviceUaHub;
+ public BaseReference door;
+ @SerializedName("fo")
+ public BaseReference floor;
+ public BaseReference openedDirection;
+ public BaseReference openedMethod;
+ public BaseReference userStatus;
+ }
+
+ public static class Authentication {
+ public String id;
+ public String type;
+ public String displayName;
+ public String credentialProvider; // REMOTE_THROUGH_UAH, TOUCH_PASS, NFC, PIN_CODE, etc...
+ }
+ }
+
+ /** access.logs.add payload (partial). */
+ public static class LogsAddData {
+ @SerializedName("_id")
+ public String id;
+
+ @SerializedName("@timestamp")
+ public String timestamp;
+
+ @SerializedName("_source")
+ public Source source;
+
+ /** e.g. "access" */
+ public String tag;
+
+ public static class Source {
+ public Actor actor;
+ public Event event;
+ public Authentication authentication;
+ public List target;
+ }
+
+ public static class Event {
+ public String type; // access.door.unlock
+ public String displayMessage;
+ public String result;
+ public Long published; // epoch millis
+ public String logKey; // dashboard.access.door.unlock.success
+ public String logCategory; // Unlocks...
+ }
+
+ public static class Authentication {
+ public String credentialProvider; // REMOTE_THROUGH_UAH, TOUCH_PASS, NFC, PIN_CODE, etc...
+ }
+
+ public static class Target extends BaseReference {
+ public String alternateId;
+ public String alternateName;
+ }
+ }
+
+ /** access.base.info payload (partial). */
+ public static class BaseInfoData {
+ public Integer topLogCount;
+ }
+
+ public static class DoorBellData {
+ public String deviceId;
+ public String doorId;
+ public String requestId;
+ }
+
+ public DeviceUpdateData dataAsDeviceUpdate(Gson gson) {
+ return dataAs(gson, DeviceUpdateData.class);
+ }
+
+ public DeviceUpdateV2Data dataAsDeviceUpdateV2(Gson gson) {
+ return dataAs(gson, DeviceUpdateV2Data.class);
+ }
+
+ public LocationUpdateV2Data dataAsLocationUpdateV2(Gson gson) {
+ return dataAs(gson, LocationUpdateV2Data.class);
+ }
+
+ public LogsInsightsAddData dataAsInsightsAddData(Gson gson) {
+ return dataAs(gson, LogsInsightsAddData.class);
+ }
+
+ public LogsAddData dataAsLogsAdd(Gson gson) {
+ return dataAs(gson, LogsAddData.class);
+ }
+
+ public InsightLogsAddData dataAsInsightLogsAdd(Gson gson) {
+ return dataAs(gson, InsightLogsAddData.class);
+ }
+
+ public BaseInfoData dataAsBaseInfo(Gson gson) {
+ return dataAs(gson, BaseInfoData.class);
+ }
+
+ public DoorBellData dataAsDoorBell(Gson gson) {
+ return dataAs(gson, DoorBellData.class);
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Space.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Space.java
new file mode 100644
index 0000000000000..752204bee1b83
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Space.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.util.List;
+
+/**
+ * Space topology (Section 7 "Space"): buildings, floors, doors.
+ *
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class Space {
+ /** Top-level grouping, e.g., a “building”. */
+ public String id;
+ public String name;
+ public String type; // e.g., "building"
+ public List resourceTopologies;
+
+ /** A floor within a Space. */
+ public static class ResourceTopology {
+ public String id;
+ public String name;
+ public String type; // "floor"
+ public List resources;
+ }
+
+ /** A door resource on a floor. */
+ public static class Resource {
+ public String id;
+ public String name;
+ public String type; // "door"
+ public Boolean isBindHub; // true if the door is bound to a hub
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/TouchPass.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/TouchPass.java
new file mode 100644
index 0000000000000..49b7644cd58f7
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/TouchPass.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.time.Instant;
+import java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Touch Pass model (Section 6.11 "Touch Pass Schemas").
+ *
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class TouchPass {
+ public Object activatedAt;
+ public String cardId;
+ public String cardName;
+ public Object expiredAt;
+ public String id;
+ public String lastActivity;
+ public Status status;
+ public String userAvatar;
+ public String userEmail;
+ public String userId;
+ public String userName;
+ public UserStatus userStatus;
+ public List bundles;
+
+ public enum Status {
+ @SerializedName("ACTIVE")
+ ACTIVE,
+ @SerializedName("PENDING")
+ PENDING,
+ @SerializedName("SUSPENDED")
+ SUSPENDED,
+ @SerializedName("INACTIVE")
+ INACTIVE,
+ @SerializedName("EXPIRED")
+ EXPIRED
+ }
+
+ public enum UserStatus {
+ @SerializedName("ACTIVE")
+ ACTIVE,
+ @SerializedName("PENDING")
+ PENDING,
+ @SerializedName("UNLINK")
+ UNLINK
+ }
+
+ public static class Bundle {
+ public String bundleId;
+ public BundleStatus bundleStatus;
+ public String deviceId;
+ public String deviceName;
+ public Integer deviceType;
+ public Source source;
+ }
+
+ public enum BundleStatus {
+ @SerializedName("ACTIVE")
+ ACTIVE,
+ @SerializedName("SUSPENDED")
+ SUSPENDED
+ }
+
+ public enum Source {
+ @SerializedName("google")
+ GOOGLE,
+ @SerializedName("apple")
+ APPLE
+ }
+
+ public Instant lastActivityInstant() {
+ return UaTime.parseInstant(lastActivity);
+ }
+
+ public boolean isLinked() {
+ return userStatus != null && userStatus != UserStatus.UNLINK;
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/UaTime.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/UaTime.java
new file mode 100644
index 0000000000000..933a0e6cdcedd
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/UaTime.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Shared time utilities for UniFi Access DTO helpers.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public final class UaTime {
+ private UaTime() {
+ }
+
+ private static final DateTimeFormatter ISO_ZONED = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+ private static final DateTimeFormatter SPACEY = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ private static final DateTimeFormatter HHMMSS = DateTimeFormatter.ofPattern("HH:mm:ss");
+
+ public static Instant fromEpochSeconds(Long epochSeconds) {
+ return (epochSeconds == null) ? null : Instant.ofEpochSecond(epochSeconds);
+ }
+
+ public static Instant parseInstant(String s) {
+ if (s == null || s.isBlank()) {
+ return null;
+ }
+ try {
+ return OffsetDateTime.parse(s, ISO_ZONED).toInstant();
+ } catch (Exception ignored) {
+ }
+ try {
+ return LocalDateTime.parse(s, SPACEY).toInstant(ZoneOffset.UTC);
+ } catch (Exception ignored) {
+ }
+ return null;
+ }
+
+ public static LocalTime parseHhmmss(String hhmmss) {
+ try {
+ return (hhmmss == null || hhmmss.isBlank()) ? null : LocalTime.parse(hhmmss, HHMMSS);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ public static boolean within(LocalTime t, LocalTime start, LocalTime end) {
+ if (t == null || start == null || end == null || start.equals(end)) {
+ return false;
+ }
+ if (start.isBefore(end)) {
+ return !t.isBefore(start) && t.isBefore(end);
+ }
+ // overnight
+ return !t.isBefore(start) || t.isBefore(end);
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/UniFiAccessApiException.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/UniFiAccessApiException.java
new file mode 100644
index 0000000000000..70e8f11e0af48
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/UniFiAccessApiException.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Parse/serialization error for UniFi Access client.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+@NonNullByDefault
+public class UniFiAccessApiException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public UniFiAccessApiException(String message) {
+ super(message);
+ }
+
+ public UniFiAccessApiException(String message, @Nullable Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/User.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/User.java
new file mode 100644
index 0000000000000..2a24bf96e6750
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/User.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.time.Instant;
+import java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * UniFi Access User DTO.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class User {
+ public String id;
+ public String firstName;
+ public String lastName;
+ public String fullName;
+ public String alias;
+ public String userEmail;
+ public String emailStatus;
+ public String phone;
+ public String employeeNumber;
+ public Long onboardTime;
+ public List nfcCards;
+ public List licensePlates;
+ public PinCode pinCode;
+ public List accessPolicyIds;
+ public List accessPolicies;
+ public Status status;
+ public TouchPass touchPass;
+
+ public Instant onboardInstant() {
+ return UaTime.fromEpochSeconds(onboardTime);
+ }
+
+ public enum Status {
+ @SerializedName("ACTIVE")
+ ACTIVE,
+ @SerializedName("PENDING")
+ PENDING,
+ @SerializedName("DEACTIVATED")
+ DEACTIVATED
+ }
+
+ public static class NfcCard {
+ public String id;
+ public String token;
+ public String type;
+ }
+
+ public static class LicensePlate {
+ public String id;
+ public String credential;
+ public String credentialType;
+ public String credentialStatus;
+
+ public enum LicenseStatus {
+ @SerializedName("active")
+ ACTIVE,
+ @SerializedName("deactivate")
+ DEACTIVATE
+ }
+ }
+
+ public static class PinCode {
+ public String token;
+ }
+
+ public static class AccessPolicy {
+ public String id;
+ public String name;
+ public List resources;
+ public String scheduleId;
+
+ public static class Resource {
+ public String id;
+ public String type;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Visitor.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Visitor.java
new file mode 100644
index 0000000000000..1d0a0304a20c7
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Visitor.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.time.Instant;
+import java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Visitor model for UniFi Access API (Section 4.1 Schemas).
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class Visitor {
+ public String id;
+ public String firstName;
+ public String lastName;
+ public String remarks;
+ public String mobilePhone;
+ public String email;
+ public String visitorCompany;
+ public Long startTime;
+ public Long endTime;
+ public VisitReason visitReason;
+ public List nfcCards;
+ public PinCode pinCode;
+ public String scheduleId;
+ public Schedule schedule;
+ public List resources;
+ public List licensePlates;
+
+ public Instant startInstant() {
+ return UaTime.fromEpochSeconds(startTime);
+ }
+
+ public Instant endInstant() {
+ return UaTime.fromEpochSeconds(endTime);
+ }
+
+ public static class NfcCard {
+ public String id;
+ public String token;
+ }
+
+ public static class PinCode {
+ public String token;
+ }
+
+ public static class Schedule {
+ public String id;
+ public Boolean isDefault;
+ public String name;
+ public String type;
+ public WeekSchedule weekSchedule;
+ }
+
+ public static class WeekSchedule {
+ public List sunday;
+ public List monday;
+ public List tuesday;
+ public List wednesday;
+ public List thursday;
+ public List friday;
+ public List saturday;
+ }
+
+ public static class TimeRange {
+ public String startTime;
+ public String endTime;
+ }
+
+ public static class Resource {
+ public String id;
+ public String name;
+ public String type;
+ }
+
+ public static class LicensePlate {
+ public String id;
+ public String credential;
+ public String credentialType;
+ public CredentialStatus credentialStatus;
+ }
+
+ public enum VisitReason {
+ @SerializedName("Interview")
+ INTERVIEW,
+ @SerializedName("Business")
+ BUSINESS,
+ @SerializedName("Cooperation")
+ COOPERATION,
+ @SerializedName("Others")
+ OTHERS
+ }
+
+ public enum CredentialStatus {
+ @SerializedName("active")
+ ACTIVE,
+ @SerializedName("deactivate")
+ DEACTIVATE
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/WebhookEndpoint.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/WebhookEndpoint.java
new file mode 100644
index 0000000000000..c438f1b4b6664
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/WebhookEndpoint.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+/**
+ * Webhook Endpoint model
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class WebhookEndpoint {
+ public String id;
+ public String url;
+ public Boolean enabled;
+ public String secret;
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/WebhookEvent.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/WebhookEvent.java
new file mode 100644
index 0000000000000..25cfbc02d6416
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/WebhookEvent.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.dto;
+
+import java.time.Instant;
+
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Generic Webhook event wrapper.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+public class WebhookEvent {
+ /** Event name string, e.g., "access.door.unlock". */
+ public String event;
+
+ /** Primary object related to the event (door, device, etc.). */
+ public String eventObjectId;
+
+ /** Receiver (webhook endpoint) ID when present. */
+ public String receiverId;
+
+ /** Whether this event was saved to history. */
+ public Boolean saveToHistory;
+
+ /** Event-specific payload. */
+ public @Nullable T data;
+
+ /**
+ * Optional event creation time (varies by event; kept as String and
+ * parsed via {@link #eventInstant()} when present).
+ */
+ public String createdAt;
+
+ public Instant eventInstant() {
+ return UaTime.parseInstant(createdAt);
+ }
+
+ public static class DoorUnlockEventData {
+ public Location location;
+ public Device device;
+ /** Sometimes present as "start_time" or "time". Keep both. */
+ public String startTime;
+ public String time;
+
+ public Instant startInstant() {
+ Instant i = UaTime.parseInstant(startTime);
+ return (i != null) ? i : UaTime.parseInstant(time);
+ }
+ }
+
+ public static class Location {
+ public String id;
+ public String locationType; // e.g., "door"
+ public String name;
+ public String upId;
+ public Extras extras;
+ }
+
+ public static class Extras {
+ public String doorThumbnail;
+ }
+
+ public static class Device {
+ public String id;
+ public String name;
+ public String alias;
+ public String ip;
+ public String deviceType;
+ public String firmware;
+ public String version;
+ public String startTime;
+
+ public Instant startInstant() {
+ return UaTime.parseInstant(startTime);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessBaseHandler.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessBaseHandler.java
new file mode 100644
index 0000000000000..aab35c5ca3561
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessBaseHandler.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.handler;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
+import org.openhab.binding.unifiaccess.internal.dto.Notification;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.DeviceUpdateData;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationState;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationUpdateV2Data;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.State;
+
+/**
+ * Base class for all UniFi Access device and door handlers.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+@NonNullByDefault
+public abstract class UnifiAccessBaseHandler extends BaseThingHandler {
+ protected Map stateCache = new HashMap<>();
+ /* This is the universal ID for the device or door, will match locationId as well in API responses */
+ protected String deviceId = "";
+
+ public UnifiAccessBaseHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ protected void updateState(String channelUID, State state) {
+ super.updateState(channelUID, state);
+ stateCache.put(channelUID, state);
+ }
+
+ @Override
+ protected void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
+ super.updateStatus(status, statusDetail, description);
+ }
+
+ protected void refreshState(String channelId) {
+ State state = stateCache.get(channelId);
+ if (state != null) {
+ super.updateState(channelId, state);
+ }
+ }
+
+ protected @Nullable UnifiAccessBridgeHandler getBridgeHandler() {
+ var b = getBridge();
+ if (b == null) {
+ return null;
+ }
+ var h = b.getHandler();
+ return (h instanceof UnifiAccessBridgeHandler) ? (UnifiAccessBridgeHandler) h : null;
+ }
+
+ protected @Nullable UniFiAccessApiClient getApiClient() {
+ UnifiAccessBridgeHandler bridge = getBridgeHandler();
+ return bridge != null ? bridge.getApiClient() : null;
+ }
+
+ // updates from the WebSocket
+
+ protected void handleDeviceUpdate(DeviceUpdateData updateData) {
+ if (!updateData.isConnected) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+ "Device reported as offline");
+ }
+ }
+
+ protected void handleDeviceUpdateV2(Notification.DeviceUpdateV2Data updateData) {
+ if (!updateData.online) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+ "Device reported as offline");
+ }
+ }
+
+ protected void handleLocationUpdateV2(LocationUpdateV2Data locationUpdate) {
+ if (locationUpdate.state != null) {
+ handleLocationState(locationUpdate.state);
+ }
+ }
+
+ protected abstract void handleLocationState(LocationState locationState);
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessBridgeHandler.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessBridgeHandler.java
new file mode 100644
index 0000000000000..4dd41185d74a4
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessBridgeHandler.java
@@ -0,0 +1,591 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.handler;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants;
+import org.openhab.binding.unifiaccess.internal.UnifiAccessDiscoveryService;
+import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
+import org.openhab.binding.unifiaccess.internal.config.UnifiAccessBridgeConfiguration;
+import org.openhab.binding.unifiaccess.internal.dto.Device;
+import org.openhab.binding.unifiaccess.internal.dto.Door;
+import org.openhab.binding.unifiaccess.internal.dto.Notification;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.DeviceUpdateData;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.DeviceUpdateV2Data;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationUpdateV2Data;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteUnlockData;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewChangeData;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewData;
+import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * Bridge handler that manages the UniFi Access API client
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+@NonNullByDefault
+public class UnifiAccessBridgeHandler extends BaseBridgeHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(UnifiAccessBridgeHandler.class);
+ private static final int DEFAULT_PORT = 12445;
+ private static final String DEFAULT_PATH = "/api/v1/developer";
+ private final Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+ private final HttpClient httpClient;
+ private @Nullable UniFiAccessApiClient apiClient;
+ private UnifiAccessBridgeConfiguration config = new UnifiAccessBridgeConfiguration();
+ private @Nullable ScheduledFuture> reconnectFuture;
+ private @Nullable UnifiAccessDiscoveryService discoveryService;
+ private final Map remoteViewRequestToDeviceId = new ConcurrentHashMap<>();
+
+ public UnifiAccessBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
+ super(bridge);
+ httpClient = httpClientFactory.createHttpClient(UnifiAccessBindingConstants.BINDING_ID,
+ new SslContextFactory.Client(true));
+ }
+
+ @Override
+ public Collection> getServices() {
+ return List.of(UnifiAccessDiscoveryService.class);
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing bridge handler");
+ config = getConfigAs(UnifiAccessBridgeConfiguration.class);
+ updateStatus(ThingStatus.UNKNOWN);
+ scheduler.execute(this::connect);
+ }
+
+ @Override
+ public void dispose() {
+ logger.debug("Disposing bridge handler");
+ try {
+ UniFiAccessApiClient client = this.apiClient;
+ if (client != null) {
+ client.close();
+ }
+ } catch (Exception e) {
+ logger.debug("Failed to close notifications WebSocket: {}", e.getMessage());
+ }
+ cancelReconnect();
+ try {
+ httpClient.stop();
+ } catch (Exception ignored) {
+ }
+ super.dispose();
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ scheduler.execute(this::syncDevices);
+ }
+ }
+
+ @Override
+ public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
+ super.childHandlerInitialized(childHandler, childThing);
+ logger.debug("Child handler initialized: {}", childHandler);
+ if (childHandler instanceof UnifiAccessDoorHandler) {
+ if (getThing().getStatus() == ThingStatus.ONLINE) {
+ syncDevices();
+ }
+ } else if (childHandler instanceof UnifiAccessDeviceHandler) {
+ if (getThing().getStatus() == ThingStatus.ONLINE) {
+ syncDevices();
+ }
+ }
+ }
+
+ private synchronized void connect() {
+ UniFiAccessApiClient client = this.apiClient;
+ if (client != null) {
+ client.close();
+ }
+ if (!httpClient.isStarted()) {
+ try {
+ httpClient.start();
+ } catch (Exception e) {
+ logger.debug("Failed to start HTTP client: {}", e.getMessage());
+ setOfflineAndReconnect(e.getMessage());
+ }
+ }
+ URI configuredBase = URI.create("https://" + config.host + ":" + DEFAULT_PORT + DEFAULT_PATH);
+ client = new UniFiAccessApiClient(httpClient, configuredBase, gson, config.authToken, scheduler);
+ this.apiClient = client;
+ try {
+ client.openNotifications(() -> {
+ logger.info("Notifications WebSocket opened");
+ updateStatus(ThingStatus.ONLINE);
+ scheduler.execute(UnifiAccessBridgeHandler.this::syncDevices);
+ }, notification -> {
+ logger.debug("Notification event: {} data: {}", notification.event, notification.data);
+ try {
+ switch (notification.event) {
+ // When a doorbell rings
+ case "access.remote_view":
+ RemoteViewData rv = notification.dataAsRemoteView(gson);
+ if (rv == null) {
+ break;
+ }
+ try {
+ if (rv.requestId != null && rv.deviceId != null) {
+ remoteViewRequestToDeviceId.put(rv.requestId, rv.deviceId);
+ }
+ if (rv.clearRequestId != null && rv.deviceId != null) {
+ remoteViewRequestToDeviceId.put(rv.clearRequestId, rv.deviceId);
+ }
+ handleRemoteView(rv);
+ } catch (Exception ex) {
+ logger.debug("Failed to handle remote_view: {}", ex.getMessage());
+ }
+ break;
+ // Doorbell status change
+ case "access.remote_view.change":
+ RemoteViewChangeData rvc = notification.dataAsRemoteViewChange(gson);
+ if (rvc == null) {
+ break;
+ }
+ try {
+ handleRemoteViewChange(rvc);
+ // route doorbell status to both device and door handlers if possible
+ if (rvc.remoteCallRequestId != null) {
+ String deviceId = remoteViewRequestToDeviceId.get(rvc.remoteCallRequestId);
+ if (deviceId != null) {
+ UnifiAccessDeviceHandler dh = getDeviceHandler(deviceId);
+ if (dh != null) {
+ dh.handleRemoteViewChange(rvc);
+ }
+ UnifiAccessDoorHandler d = getDoorHandler(deviceId);
+ if (d != null) {
+ d.handleDoorbellStatus(rvc);
+ }
+ }
+ }
+ } catch (Exception ex) {
+ logger.debug("Failed to handle remote_view.change: {}", ex.getMessage());
+ }
+ break;
+ // Remote door unlock by admin
+ case "access.data.device.remote_unlock":
+ RemoteUnlockData ru = notification.dataAsRemoteUnlock(gson);
+ logger.debug("Device remote unlock: {}", ru.name);
+ handleRemoteUnlock(ru);
+ break;
+ case "access.data.device.update":
+ DeviceUpdateData du = notification.dataAsDeviceUpdate(gson);
+ if (du == null) {
+ break;
+ }
+ try {
+ handleDeviceUpdate(du);
+ } catch (Exception ex) {
+ logger.debug("Failed to handle device update: {}", ex.getMessage());
+ }
+ break;
+ case "access.data.v2.device.update":
+ DeviceUpdateV2Data du2 = notification.dataAsDeviceUpdateV2(gson);
+ if (du2 == null) {
+ break;
+ }
+ try {
+ handleDeviceUpdateV2(du2);
+ } catch (Exception ex) {
+ logger.debug("Failed to handle device update: {}", ex.getMessage());
+ }
+
+ break;
+ case "access.data.v2.location.update":
+ LocationUpdateV2Data lu2 = notification.dataAsLocationUpdateV2(gson);
+ if (lu2 == null) {
+ break;
+ }
+ try {
+ handleLocationUpdateV2(lu2);
+ } catch (Exception ex) {
+ logger.debug("Failed to handle location update: {}", ex.getMessage());
+ }
+ break;
+ case "access.logs.insights.add": {
+ var data = notification.dataAsInsightLogsAdd(gson);
+ if (data == null) {
+ break;
+ }
+ String cameraId = data.metadata != null && data.metadata.cameraCapture != null
+ ? data.metadata.cameraCapture.alternateId
+ : null;
+ // fire bridge trigger with compact JSON payload
+ Map insight = new LinkedHashMap<>();
+ if (data.logKey != null) {
+ insight.put("logKey", data.logKey);
+ }
+ if (data.eventType != null) {
+ insight.put("eventType", data.eventType);
+ }
+ if (data.message != null) {
+ insight.put("message", data.message);
+ }
+ if (data.published != null) {
+ insight.put("published", data.published);
+ }
+ if (data.result != null) {
+ insight.put("result", data.result);
+ }
+ String actorName = (data.metadata != null && data.metadata.actor != null)
+ ? data.metadata.actor.displayName
+ : null;
+ if (actorName != null) {
+ insight.put("actorName", actorName);
+ }
+ String insightDoorId = (data.metadata != null && data.metadata.door != null)
+ ? data.metadata.door.id
+ : null;
+ if (insightDoorId != null) {
+ insight.put("doorId", insightDoorId);
+ }
+ String insightDoorName = (data.metadata != null && data.metadata.door != null)
+ ? data.metadata.door.displayName
+ : null;
+ if (insightDoorName != null) {
+ insight.put("doorName", insightDoorName);
+ }
+ String insightDeviceId = (data.metadata != null && data.metadata.device != null)
+ ? data.metadata.device.id
+ : null;
+ if (insightDeviceId != null) {
+ insight.put("deviceId", insightDeviceId);
+ }
+ if (cameraId != null) {
+ insight.put("cameraId", cameraId);
+ }
+ String payload = gson.toJson(insight);
+ // bridge-level trigger
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_BRIDGE_LOG_INSIGHT, payload);
+
+ // route to specific door/device if referenced
+ String doorId = data.metadata != null && data.metadata.door != null ? data.metadata.door.id
+ : null;
+ if (doorId != null) {
+ UnifiAccessDoorHandler dh = getDoorHandler(doorId);
+ if (dh != null) {
+ dh.triggerLogInsight(payload);
+ }
+ }
+ String deviceId = data.metadata != null && data.metadata.device != null
+ ? data.metadata.device.id
+ : null;
+ if (deviceId != null) {
+ UnifiAccessDeviceHandler d = getDeviceHandler(deviceId);
+ if (d != null) {
+ d.triggerLogInsight(payload);
+ }
+ }
+ }
+ break;
+ case "access.logs.add": {
+ var data = notification.dataAsLogsAdd(gson);
+ if (data == null || data.source == null) {
+ break;
+ }
+ Map logMap = new LinkedHashMap<>();
+ if (data.source.event != null) {
+ if (data.source.event.type != null) {
+ logMap.put("type", data.source.event.type);
+ }
+ if (data.source.event.displayMessage != null) {
+ logMap.put("displayMessage", data.source.event.displayMessage);
+ }
+ if (data.source.event.result != null) {
+ logMap.put("result", data.source.event.result);
+ }
+ if (data.source.event.published != null) {
+ logMap.put("published", data.source.event.published);
+ }
+ if (data.source.event.logKey != null) {
+ logMap.put("logKey", data.source.event.logKey);
+ }
+ if (data.source.event.logCategory != null) {
+ logMap.put("logCategory", data.source.event.logCategory);
+ }
+ }
+ if (data.source.actor != null && data.source.actor.displayName != null) {
+ logMap.put("actorName", data.source.actor.displayName);
+ }
+ String payload = gson.toJson(logMap);
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_BRIDGE_LOG, payload);
+
+ // door-level success/failure triggers
+ String doorId = (data.source.target == null) ? null
+ : data.source.target.stream().filter(t -> "door".equalsIgnoreCase(t.type))
+ .map(t -> t.id).findFirst().orElse(null);
+
+ if (doorId != null) {
+ boolean isSuccess = data.source.event != null
+ && "ACCESS".equalsIgnoreCase(data.source.event.result);
+ Map accessMap = new LinkedHashMap<>();
+ if (data.source.actor != null && data.source.actor.displayName != null) {
+ accessMap.put("actorName", data.source.actor.displayName);
+ }
+ if (data.source.authentication != null
+ && data.source.authentication.credentialProvider != null) {
+ accessMap.put("credentialProvider", data.source.authentication.credentialProvider);
+ }
+ if (data.source.event != null && data.source.event.displayMessage != null) {
+ accessMap.put("message", data.source.event.displayMessage);
+ }
+ String accessPayload = gson.toJson(accessMap);
+ UnifiAccessDoorHandler dh = getDoorHandler(doorId);
+ if (dh != null) {
+ if (isSuccess) {
+ dh.triggerAccessAttemptSuccess(accessPayload);
+ } else {
+ dh.triggerAccessAttemptFailure(accessPayload);
+ }
+ }
+ }
+ }
+ break;
+ case "access.base.info":
+ // notification.dataAsBaseInfo(gson);
+ break;
+ case "access.hw.door_bell":
+ // this is notified of a hardware doorbell event start, but we don't get a stop
+ // event
+ // notification.dataAsDoorBell(gson);
+ break;
+ default:
+ // leave as raw
+ break;
+ }
+ } catch (Exception ex) {
+ logger.debug("Failed to parse typed notification for {}: {}", notification.event, ex.getMessage());
+ }
+ }, error -> {
+ logger.debug("Notifications error: {}", error.getMessage());
+ setOfflineAndReconnect(error.getMessage());
+ }, (statusCode, reason) -> {
+ logger.debug("Notifications closed: {} - {}", statusCode, reason);
+ setOfflineAndReconnect(reason);
+ });
+ } catch (UniFiAccessApiException e) {
+ logger.debug("Failed to open notifications WebSocket", e);
+ setOfflineAndReconnect("Failed to open notifications WebSocket");
+ }
+ }
+
+ public void setDiscoveryService(UnifiAccessDiscoveryService discoveryService) {
+ this.discoveryService = discoveryService;
+ }
+
+ private void setOfflineAndReconnect(@Nullable String message) {
+ ScheduledFuture> reconnectFuture = this.reconnectFuture;
+ if (reconnectFuture != null && !reconnectFuture.isDone()) {
+ return;
+ }
+ String msg = message != null ? message : "Unknown error";
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, msg);
+ this.reconnectFuture = scheduler.schedule(() -> {
+ try {
+ // schedule this so our reconnectFuture completes after calling right away so if
+ // we need to reconnect again, this job is already finished.
+ scheduler.execute(this::connect);
+ } catch (Exception ex) {
+ logger.debug("Reconnect attempt failed to schedule connect: {}", ex.getMessage());
+ }
+ }, 5, java.util.concurrent.TimeUnit.SECONDS);
+ }
+
+ private void cancelReconnect() {
+ try {
+ ScheduledFuture> f = reconnectFuture;
+ if (f != null) {
+ f.cancel(true);
+ }
+ } catch (Exception ignored) {
+ } finally {
+ reconnectFuture = null;
+ }
+ }
+
+ private synchronized void syncDevices() {
+ UniFiAccessApiClient client = this.apiClient;
+ if (client == null) {
+ return;
+ }
+ try {
+ List doors = client.getDoors();
+ List devices = client.getDevices();
+
+ // exclude any whose locationId matches a door id
+ List filteredDevices = devices.stream()
+ .filter(device -> device.locationId == null
+ || !doors.stream().anyMatch(door -> door.id.equals(device.locationId)))
+ .collect(Collectors.toList());
+
+ UnifiAccessDiscoveryService discoveryService = this.discoveryService;
+ if (discoveryService != null) {
+ discoveryService.discoverDoors(doors);
+ discoveryService.discoverDevices(filteredDevices);
+ }
+ logger.trace("Polled UniFi Access: {} doors", doors.size());
+ if (!doors.isEmpty()) {
+ for (Door door : doors) {
+ logger.trace("Checking door: {}", door.id);
+ UnifiAccessDoorHandler dh = getDoorHandler(door.id);
+ if (dh != null) {
+ logger.trace("Updating door: {}", dh.deviceId);
+ dh.updateFromDoor(door);
+ }
+ }
+ }
+ if (!filteredDevices.isEmpty()) {
+ for (Device device : filteredDevices) {
+ UnifiAccessDeviceHandler dh = getDeviceHandler(device.id);
+ if (dh != null) {
+ if (!device.isOnline) {
+ dh.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+ "Device reported as offline");
+ } else {
+ try {
+ var settings = client.getDeviceAccessMethodSettings(dh.deviceId);
+ dh.updateFromSettings(settings);
+ } catch (UniFiAccessApiException ex) {
+ logger.debug("Failed to update device {}: {}", dh.deviceId, ex.getMessage());
+ }
+ }
+ }
+ }
+ }
+ } catch (UniFiAccessApiException e) {
+ logger.debug("Polling error: {}", e.getMessage());
+ }
+ }
+
+ public @Nullable UniFiAccessApiClient getApiClient() {
+ return apiClient;
+ }
+
+ public @Nullable UnifiAccessBridgeConfiguration getUaConfig() {
+ return config;
+ }
+
+ private void handleRemoteUnlock(Notification.RemoteUnlockData data) {
+ UnifiAccessDoorHandler dh = getDoorHandler(data.uniqueId);
+ if (dh != null) {
+ dh.handleRemoteUnlock(data);
+ }
+ }
+
+ private void handleRemoteView(RemoteViewData rv) {
+ UnifiAccessDeviceHandler dh = getDeviceHandler(rv.deviceId);
+ if (dh != null) {
+ dh.handleRemoteView(rv);
+ }
+ }
+
+ private void handleRemoteViewChange(RemoteViewChangeData rvc) {
+ // First try to route via remote call request id mapping (if available)
+ String deviceId = null;
+ if (rvc.remoteCallRequestId != null) {
+ deviceId = remoteViewRequestToDeviceId.get(rvc.remoteCallRequestId);
+ }
+ if (deviceId != null) {
+ UnifiAccessDeviceHandler dh = getDeviceHandler(deviceId);
+ if (dh != null) {
+ dh.handleRemoteViewChange(rvc);
+ return;
+ }
+ }
+ }
+
+ private void handleDeviceUpdate(DeviceUpdateData updateData) {
+ UnifiAccessBaseHandler bh = getBaseHandler(updateData.uniqueId);
+ if (bh != null) {
+ bh.handleDeviceUpdate(updateData);
+ }
+ }
+
+ private void handleDeviceUpdateV2(Notification.DeviceUpdateV2Data updateData) {
+ UnifiAccessBaseHandler bh = getBaseHandler(updateData.locationId);
+ if (bh != null) {
+ bh.handleDeviceUpdateV2(updateData);
+ } else {
+ logger.debug("No handler found for device update V2: {}", updateData.locationId);
+ }
+ }
+
+ private void handleLocationUpdateV2(LocationUpdateV2Data lu2) {
+ // Forward to matching device handlers by device ids under this location
+ if (lu2.state != null && lu2.deviceIds != null) {
+ for (String deviceId : lu2.deviceIds) {
+ UnifiAccessBaseHandler bh = getBaseHandler(deviceId);
+ if (bh != null) {
+ bh.handleLocationUpdateV2(lu2);
+ }
+ }
+ }
+ }
+
+ private @Nullable UnifiAccessDoorHandler getDoorHandler(String doorId) {
+ if (getBaseHandler(doorId) instanceof UnifiAccessDoorHandler dh) {
+ return dh;
+ }
+ return null;
+ }
+
+ private @Nullable UnifiAccessDeviceHandler getDeviceHandler(String deviceId) {
+ if (getBaseHandler(deviceId) instanceof UnifiAccessDeviceHandler dh) {
+ return dh;
+ }
+ return null;
+ }
+
+ private @Nullable UnifiAccessBaseHandler getBaseHandler(String deviceId) {
+ for (Thing t : getThing().getThings()) {
+ if (t.getHandler() instanceof UnifiAccessBaseHandler bh && bh.deviceId.equals(deviceId)) {
+ return bh;
+ }
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessDeviceHandler.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessDeviceHandler.java
new file mode 100644
index 0000000000000..e34f101751d6a
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessDeviceHandler.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants;
+import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
+import org.openhab.binding.unifiaccess.internal.config.UnifiAccessDeviceConfiguration;
+import org.openhab.binding.unifiaccess.internal.dto.DeviceAccessMethodSettings;
+import org.openhab.binding.unifiaccess.internal.dto.DoorEmergencySettings;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationState;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewChangeData;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewData;
+import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Thing handler for UniFi Access Device things.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+@NonNullByDefault
+public class UnifiAccessDeviceHandler extends UnifiAccessBaseHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(UnifiAccessDeviceHandler.class);
+
+ public UnifiAccessDeviceHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void initialize() {
+ deviceId = getConfigAs(UnifiAccessDeviceConfiguration.class).deviceId;
+ updateStatus(ThingStatus.UNKNOWN);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ String channelId = channelUID.getId();
+ if (command instanceof RefreshType) {
+ refreshState(channelId);
+ return;
+ }
+ UnifiAccessBridgeHandler bridge = getBridgeHandler();
+ UniFiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
+ if (api == null) {
+ return;
+ }
+ try {
+ DeviceAccessMethodSettings current = api.getDeviceAccessMethodSettings(deviceId);
+ boolean updated = false;
+ if (command instanceof OnOffType onOff) {
+ boolean enable = onOff == OnOffType.ON;
+ switch (channelId) {
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_NFC_ENABLED:
+ if (current.nfc == null) {
+ current.nfc = new DeviceAccessMethodSettings.Nfc();
+ }
+ current.nfc.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_PIN_ENABLED:
+ if (current.pinCode == null) {
+ current.pinCode = new DeviceAccessMethodSettings.PinCode();
+ }
+ current.pinCode.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_PIN_SHUFFLE:
+ if (current.pinCode == null) {
+ current.pinCode = new DeviceAccessMethodSettings.PinCode();
+ }
+ current.pinCode.pinCodeShuffle = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_FACE_ENABLED:
+ if (current.face == null) {
+ current.face = new DeviceAccessMethodSettings.Face();
+ }
+ current.face.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_TAP_ENABLED:
+ if (current.btTap == null) {
+ current.btTap = new DeviceAccessMethodSettings.Bt();
+ }
+ current.btTap.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_BUTTON_ENABLED:
+ if (current.btButton == null) {
+ current.btButton = new DeviceAccessMethodSettings.Bt();
+ }
+ current.btButton.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_SHAKE_ENABLED:
+ if (current.btShake == null) {
+ current.btShake = new DeviceAccessMethodSettings.Bt();
+ }
+ current.btShake.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_WAVE_ENABLED:
+ if (current.mobileWave == null) {
+ current.mobileWave = new DeviceAccessMethodSettings.MobileWave();
+ }
+ current.mobileWave.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_WAVE_ENABLED:
+ if (current.wave == null) {
+ current.wave = new DeviceAccessMethodSettings.Wave();
+ }
+ current.wave.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_QR_CODE_ENABLED:
+ if (current.qrCode == null) {
+ current.qrCode = new DeviceAccessMethodSettings.QrCode();
+ }
+ current.qrCode.enabled = enable;
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_TOUCH_PASS_ENABLED:
+ if (current.touchPass == null) {
+ current.touchPass = new DeviceAccessMethodSettings.TouchPass();
+ }
+ current.touchPass.enabled = enable;
+ updated = true;
+ break;
+ default:
+ break;
+ }
+ } else {
+ String value = command.toString();
+ switch (channelId) {
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_FACE_ANTI_SPOOFING:
+ if (current.face == null) {
+ current.face = new DeviceAccessMethodSettings.Face();
+ }
+ current.face.antiSpoofingLevel = value; // expects high|medium|no
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_FACE_DETECT_DISTANCE:
+ if (current.face == null) {
+ current.face = new DeviceAccessMethodSettings.Face();
+ }
+ current.face.detectDistance = value; // expects near|medium|far
+ updated = true;
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_DEVICE_EMERGENCY_STATUS:
+ String normalized = value.toLowerCase();
+ DoorEmergencySettings des = new DoorEmergencySettings();
+ String status = "normal";
+ if ("lockdown".equals(normalized)) {
+ des.lockdown = Boolean.TRUE;
+ des.evacuation = Boolean.FALSE;
+ status = "lockdown";
+ } else if ("evacuation".equals(normalized)) {
+ des.lockdown = Boolean.FALSE;
+ des.evacuation = Boolean.TRUE;
+ status = "evacuation";
+ } else {
+ des.lockdown = Boolean.FALSE;
+ des.evacuation = Boolean.FALSE;
+ status = "normal";
+ }
+ try {
+ api.setDoorEmergencySettings(deviceId, des);
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_EMERGENCY_STATUS,
+ new StringType(status));
+ } catch (UniFiAccessApiException e) {
+ logger.debug("Failed to set door emergency settings for device {}: {}", deviceId,
+ e.getMessage());
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_EMERGENCY_STATUS, UnDefType.UNDEF);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (updated) {
+ DeviceAccessMethodSettings saved = api.updateDeviceAccessMethodSettings(deviceId, current);
+ updateFromSettings(saved);
+ }
+ } catch (Exception e) {
+ logger.debug("Command failed for device {}: {}", deviceId, e.getMessage());
+ }
+ }
+
+ @Override
+ protected void handleLocationState(LocationState locationState) {
+ if (locationState.dps != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_DOOR_SENSOR,
+ locationState.dps == org.openhab.binding.unifiaccess.internal.dto.DoorState.DoorPosition.OPEN
+ ? OpenClosedType.OPEN
+ : OpenClosedType.CLOSED);
+ }
+ String status = "normal";
+ if (locationState.emergency != null) {
+ String sw = locationState.emergency.software;
+ String hw = locationState.emergency.hardware;
+ if ("lockdown".equalsIgnoreCase(sw) || "lockdown".equalsIgnoreCase(hw)) {
+ status = "lockdown";
+ } else if ("evacuation".equalsIgnoreCase(sw) || "evacuation".equalsIgnoreCase(hw)) {
+ status = "evacuation";
+ }
+ }
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_EMERGENCY_STATUS, new StringType(status));
+ }
+
+ protected void updateFromSettings(DeviceAccessMethodSettings settings) {
+ if (getThing().getStatus() != ThingStatus.ONLINE) {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ if (settings.nfc != null && settings.nfc.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_NFC_ENABLED,
+ settings.nfc.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.pinCode != null) {
+ if (settings.pinCode.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_PIN_ENABLED,
+ settings.pinCode.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.pinCode.pinCodeShuffle != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_PIN_SHUFFLE,
+ settings.pinCode.pinCodeShuffle ? OnOffType.ON : OnOffType.OFF);
+ }
+ }
+ if (settings.btTap != null && settings.btTap.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_TAP_ENABLED,
+ settings.btTap.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.btButton != null && settings.btButton.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_BUTTON_ENABLED,
+ settings.btButton.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.btShake != null && settings.btShake.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_SHAKE_ENABLED,
+ settings.btShake.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.mobileWave != null && settings.mobileWave.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_MOBILE_WAVE_ENABLED,
+ settings.mobileWave.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.wave != null && settings.wave.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_WAVE_ENABLED,
+ settings.wave.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.qrCode != null && settings.qrCode.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_QR_CODE_ENABLED,
+ settings.qrCode.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.touchPass != null && settings.touchPass.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_TOUCH_PASS_ENABLED,
+ settings.touchPass.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.face != null && settings.face.enabled != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_FACE_ENABLED,
+ settings.face.enabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (settings.face != null && settings.face.antiSpoofingLevel != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_FACE_ANTI_SPOOFING,
+ new StringType(settings.face.antiSpoofingLevel));
+ }
+ if (settings.face != null && settings.face.detectDistance != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_FACE_DETECT_DISTANCE,
+ new StringType(settings.face.detectDistance));
+ }
+ }
+
+ protected void handleRemoteView(RemoteViewData remoteView) {
+ if (!deviceId.equals(remoteView.deviceId)) {
+ return;
+ }
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_DEVICE_DOORBELL_TRIGGER, "incoming");
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_DOORBELL_CONTACT, OpenClosedType.OPEN);
+ }
+
+ protected void handleRemoteViewChange(RemoteViewChangeData change) {
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_DEVICE_DOORBELL_TRIGGER, "completed");
+ updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_DOORBELL_CONTACT, OpenClosedType.CLOSED);
+ String event = change.reason != null ? change.reason.name() : "UNKNOWN";
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_DOORBELL_STATUS, event);
+ }
+
+ protected void triggerLogInsight(String payload) {
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_BRIDGE_LOG_INSIGHT, payload);
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessDoorHandler.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessDoorHandler.java
new file mode 100644
index 0000000000000..840e3106c25ca
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/handler/UnifiAccessDoorHandler.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.unifiaccess.internal.handler;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants;
+import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient;
+import org.openhab.binding.unifiaccess.internal.config.UnifiAccessDeviceConfiguration;
+import org.openhab.binding.unifiaccess.internal.dto.Door;
+import org.openhab.binding.unifiaccess.internal.dto.DoorLockRule;
+import org.openhab.binding.unifiaccess.internal.dto.DoorState;
+import org.openhab.binding.unifiaccess.internal.dto.Image;
+import org.openhab.binding.unifiaccess.internal.dto.Notification;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationState;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.LocationUpdateV2Data;
+import org.openhab.binding.unifiaccess.internal.dto.Notification.RemoteViewChangeData;
+import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * Thing handler for UniFi Access Door things.
+ *
+ * @author Dan Cunningham - Initial contribution
+ */
+@NonNullByDefault
+public class UnifiAccessDoorHandler extends UnifiAccessBaseHandler {
+
+ public static final String CONFIG_DOOR_ID = UnifiAccessBindingConstants.CONFIG_DEVICE_ID;
+
+ private final Logger logger = LoggerFactory.getLogger(UnifiAccessDoorHandler.class);
+ private Door door = new Door();
+ private @Nullable DoorLockRule lockRule;
+
+ public UnifiAccessDoorHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void initialize() {
+ deviceId = getConfigAs(UnifiAccessDeviceConfiguration.class).deviceId;
+ door.id = deviceId;
+ updateStatus(ThingStatus.UNKNOWN);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ String channelId = channelUID.getId();
+ if (command instanceof RefreshType) {
+ refreshState(channelId);
+ return;
+ }
+ UnifiAccessBridgeHandler bridge = getBridgeHandler();
+ UniFiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
+ if (api == null) {
+ return;
+ }
+ try {
+ switch (channelId) {
+ case UnifiAccessBindingConstants.CHANNEL_LOCK:
+ if (command instanceof OnOffType onOff) {
+ if (onOff == OnOffType.ON) {
+ if (lockRule instanceof DoorLockRule rule
+ && rule.type == DoorState.DoorLockRuleType.SCHEDULE) {
+ api.lockEarly(deviceId);
+ } else {
+ api.resetDoorLockRule(deviceId);
+ }
+ } else {
+ api.unlockDoor(deviceId, null, null, null);
+ }
+ }
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_KEEP_UNLOCKED:
+ if (command instanceof OnOffType onOff) {
+ if (onOff == OnOffType.ON) {
+ api.keepDoorUnlocked(deviceId);
+ } else {
+ api.resetDoorLockRule(deviceId);
+ }
+ }
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_KEEP_LOCKED:
+ if (command instanceof OnOffType onOff) {
+ if (onOff == OnOffType.ON) {
+ api.keepDoorLocked(deviceId);
+ } else {
+ api.resetDoorLockRule(deviceId);
+ }
+ }
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_UNLOCK_MINUTES: {
+ int minutes = Integer.parseInt(command.toString());
+ if (minutes > 0) {
+ api.unlockForMinutes(deviceId, minutes);
+ } else {
+ api.resetDoorLockRule(deviceId);
+ }
+ updateState(UnifiAccessBindingConstants.CHANNEL_UNLOCK_MINUTES, UnDefType.UNDEF);
+ }
+ break;
+ case UnifiAccessBindingConstants.CHANNEL_UNLOCK_UNTIL: {
+ if (command instanceof DateTimeType dateTime) {
+ int minutes = (int) Math.max(0,
+ java.time.temporal.ChronoUnit.MINUTES.between(Instant.now(), dateTime.getInstant()));
+ api.unlockForMinutes(deviceId, minutes);
+ } else {
+ api.resetDoorLockRule(deviceId);
+ }
+ }
+ break;
+ default:
+ break;
+ }
+ } catch (Exception e) {
+ logger.debug("Command failed for door {}: {}", deviceId, e.getMessage());
+ }
+ }
+
+ @Override
+ protected void handleLocationState(LocationState locationState) {
+ if (locationState.lock != null) {
+ door.doorLockRelayStatus = locationState.lock;
+ updateLock(locationState.lock);
+ }
+ if (locationState.dps != null) {
+ door.doorPositionStatus = locationState.dps;
+ updatePosition(locationState.dps);
+ }
+ updateLockRule(locationState.remainUnlock);
+ }
+
+ @Override
+ protected void handleLocationUpdateV2(LocationUpdateV2Data locationUpdate) {
+ if (locationUpdate.thumbnail != null) {
+ UnifiAccessBridgeHandler bridge = getBridgeHandler();
+ UniFiAccessApiClient api = bridge != null ? bridge.getApiClient() : null;
+ if (api == null) {
+ return;
+ }
+ try {
+ Image thumbnail = api.getDoorThumbnail(locationUpdate.thumbnail.url);
+ updateState(UnifiAccessBindingConstants.CHANNEL_DOOR_THUMBNAIL,
+ new RawType(thumbnail.data, thumbnail.mediaType));
+ } catch (Exception e) {
+ logger.debug("Failed to get door thumbnail for door {}: {}", door.id, e.getMessage());
+ }
+ }
+ // will call handleLocationState() if locationUpdate.state is not null
+ super.handleLocationUpdateV2(locationUpdate);
+ }
+
+ @Override
+ protected void handleDeviceUpdateV2(Notification.DeviceUpdateV2Data updateData) {
+ if (updateData.locationStates != null) {
+ updateData.locationStates.stream().filter(locationState -> locationState.locationId.equals(door.id))
+ .findFirst().ifPresent(this::handleLocationState);
+ }
+ super.handleDeviceUpdateV2(updateData);
+ }
+
+ // Door specific update methods
+ protected void updateFromDoor(Door door) {
+ logger.debug("Updating door state from door: {}", door);
+ this.door = door;
+ if (getThing().getStatus() != ThingStatus.ONLINE) {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ if (door.doorLockRelayStatus != null) {
+ updateLock(door.doorLockRelayStatus);
+ }
+ if (door.doorPositionStatus != null) {
+ updatePosition(door.doorPositionStatus);
+ }
+ UniFiAccessApiClient api = getApiClient();
+ if (api != null) {
+ try {
+ DoorLockRule rule = api.getDoorLockRule(door.id);
+ updateLockRule(rule);
+ } catch (UniFiAccessApiException e) {
+ logger.debug("Failed to get door lock rule for door {}: {}", door.id, e.getMessage());
+ }
+ }
+ }
+
+ protected void handleRemoteUnlock(Notification.RemoteUnlockData remoteUnlock) {
+ setLastUnlock(remoteUnlock.fullName, System.currentTimeMillis());
+ door.doorLockRelayStatus = DoorState.LockState.UNLOCKED;
+ String payload = new Gson().toJson(Map.of("deviceId", remoteUnlock.uniqueId, "name", remoteUnlock.name,
+ "fullName", remoteUnlock.fullName, "level", remoteUnlock.level, "workTimeId", remoteUnlock.workTimeId));
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_DOOR_REMOTE_UNLOCK, payload);
+ }
+
+ protected void handleDoorbellStatus(RemoteViewChangeData change) {
+ String event = change.reason != null ? change.reason.name() : "UNKNOWN";
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_DOORBELL_STATUS, event);
+ }
+
+ protected void triggerAccessAttemptSuccess(String payload) {
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_DOOR_ACCESS_ATTEMPT_SUCCESS, payload);
+ }
+
+ protected void triggerAccessAttemptFailure(String payload) {
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_DOOR_ACCESS_ATTEMPT_FAILURE, payload);
+ }
+
+ protected void triggerLogInsight(String payload) {
+ triggerChannel(UnifiAccessBindingConstants.CHANNEL_BRIDGE_LOG_INSIGHT, payload);
+ }
+
+ protected void updateLock(DoorState.LockState lock) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_LOCK,
+ lock == DoorState.LockState.LOCKED ? OnOffType.ON : OnOffType.OFF);
+ }
+
+ protected void updatePosition(DoorState.DoorPosition position) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_DOOR_POSITION,
+ position == DoorState.DoorPosition.OPEN ? OpenClosedType.OPEN : OpenClosedType.CLOSED);
+ }
+
+ protected void setLastUnlock(@Nullable String actorName, long whenEpochMs) {
+ if (actorName != null) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_LAST_ACTOR, StringType.valueOf(actorName));
+ }
+ if (whenEpochMs > 0) {
+ updateState(UnifiAccessBindingConstants.CHANNEL_LAST_UNLOCK,
+ new DateTimeType(Instant.ofEpochMilli(whenEpochMs)));
+ }
+ }
+
+ private void updateLockRule(@Nullable DoorLockRule rule) {
+ List lockChannels = new ArrayList<>(Arrays.asList(UnifiAccessBindingConstants.CHANNEL_KEEP_UNLOCKED,
+ UnifiAccessBindingConstants.CHANNEL_KEEP_LOCKED, UnifiAccessBindingConstants.CHANNEL_UNLOCK_MINUTES,
+ UnifiAccessBindingConstants.CHANNEL_UNLOCK_UNTIL));
+ if (rule != null) {
+ this.lockRule = rule;
+ updateState(UnifiAccessBindingConstants.CHANNEL_LOCK_RULE, new StringType(rule.type.name().toLowerCase()));
+ switch (rule.type) {
+ case KEEP_UNLOCK:
+ updateState(UnifiAccessBindingConstants.CHANNEL_KEEP_UNLOCKED, OnOffType.ON);
+ lockChannels.remove(UnifiAccessBindingConstants.CHANNEL_KEEP_UNLOCKED);
+ break;
+ case KEEP_LOCK:
+ updateState(UnifiAccessBindingConstants.CHANNEL_KEEP_LOCKED, OnOffType.ON);
+ lockChannels.remove(UnifiAccessBindingConstants.CHANNEL_KEEP_LOCKED);
+ break;
+ case CUSTOM:
+ updateState(UnifiAccessBindingConstants.CHANNEL_UNLOCK_UNTIL,
+ new DateTimeType(Instant.ofEpochSecond(rule.until)));
+ lockChannels.remove(UnifiAccessBindingConstants.CHANNEL_UNLOCK_UNTIL);
+ break;
+ default:
+ break;
+ }
+ }
+ lockChannels.forEach(channel -> {
+ if (UnifiAccessBindingConstants.CHANNEL_UNLOCK_MINUTES.equals(channel)) {
+ // updateState(channel, new QuantityType<>(0, Units.MINUTE));
+ updateState(channel, UnDefType.UNDEF);
+ } else if (UnifiAccessBindingConstants.CHANNEL_UNLOCK_UNTIL.equals(channel)) {
+ updateState(channel, UnDefType.UNDEF);
+ } else {
+ updateState(channel, OnOffType.OFF);
+ }
+ });
+ }
+}
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 0000000000000..b2f6c81adc961
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,10 @@
+
+
+
+ binding
+ UnifiAccess Binding
+ This is the binding for UnifiAccess.
+
+
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/i18n/unifiaccess.properties b/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/i18n/unifiaccess.properties
new file mode 100644
index 0000000000000..1c3b0738c1efd
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/i18n/unifiaccess.properties
@@ -0,0 +1,94 @@
+# add-on
+
+addon.unifiaccess.name = UnifiAccess Binding
+addon.unifiaccess.description = This is the binding for UnifiAccess.
+
+# thing types
+
+thing-type.unifiaccess.bridge.label = UniFi Access Bridge
+thing-type.unifiaccess.bridge.description = UniFi Access controller
+thing-type.unifiaccess.device.label = UniFi Access Device
+thing-type.unifiaccess.device.description = Reader / Hub / Camera
+thing-type.unifiaccess.device.channel.doorbell.label = Doorbell
+thing-type.unifiaccess.device.channel.doorbell-contact.label = Doorbell
+thing-type.unifiaccess.device.channel.face-enabled.label = Face Unlock Enabled
+thing-type.unifiaccess.device.channel.log-insight.label = Insight Log
+thing-type.unifiaccess.device.channel.log-insight.description = Fires for access.logs.insights.add referencing this device; event payload JSON includes logKey, eventType, message, published, result, actorName, doorId, doorName, deviceId, cameraId.
+thing-type.unifiaccess.device.channel.mobile-button-enabled.label = Mobile Unlock Button Enabled
+thing-type.unifiaccess.device.channel.mobile-shake-enabled.label = Mobile Shake Enabled
+thing-type.unifiaccess.device.channel.mobile-tap-enabled.label = Mobile Tap Enabled
+thing-type.unifiaccess.device.channel.mobile-wave-enabled.label = Mobile Wave Enabled
+thing-type.unifiaccess.device.channel.nfc-enabled.label = NFC Enabled
+thing-type.unifiaccess.device.channel.pin-enabled.label = PIN Enabled
+thing-type.unifiaccess.device.channel.pin-shuffle.label = PIN Shuffle
+thing-type.unifiaccess.device.channel.qr-code-enabled.label = QR Code Enabled
+thing-type.unifiaccess.device.channel.touch-pass-enabled.label = Touch Pass Enabled
+thing-type.unifiaccess.device.channel.wave-enabled.label = Hand Wave Enabled
+thing-type.unifiaccess.door.label = UniFi Access Door
+thing-type.unifiaccess.door.description = Door with remote control and status
+thing-type.unifiaccess.door.channel.thumbnail.label = Door Thumbnail
+
+# thing types config
+
+thing-type.config.unifiaccess.bridge.authToken.label = Auth Token
+thing-type.config.unifiaccess.bridge.authToken.description = API token from the UniFi Access controller
+thing-type.config.unifiaccess.bridge.host.label = Host
+thing-type.config.unifiaccess.bridge.host.description = Host or IP address of the UniFi Access controller
+thing-type.config.unifiaccess.device.deviceId.label = Device ID
+thing-type.config.unifiaccess.device.deviceId.description = Device ID
+thing-type.config.unifiaccess.door.deviceId.label = Door ID
+
+# channel types
+
+channel-type.unifiaccess.device-access-toggle.label = Access Method Toggle
+channel-type.unifiaccess.device-access-toggle.description = Toggles this access method
+channel-type.unifiaccess.device-doorbell-contact.label = Doorbell
+channel-type.unifiaccess.device-doorbell.label = Doorbell
+channel-type.unifiaccess.device-doorbell.description = Fires on incoming and completed doorbell events
+channel-type.unifiaccess.device-emergency-status.label = Emergency Status
+channel-type.unifiaccess.device-emergency-status.description = Emergency Status
+channel-type.unifiaccess.device-emergency-status.state.option.normal = Normal
+channel-type.unifiaccess.device-emergency-status.state.option.lockdown = Lockdown
+channel-type.unifiaccess.device-emergency-status.state.option.evacuation = Evacuation
+channel-type.unifiaccess.door-access-attempt-failure.label = Access Attempt Failure
+channel-type.unifiaccess.door-access-attempt-failure.description = Event payload is a JSON string with keys: doorId, doorName, actorName, credentialProvider, message, published.
+channel-type.unifiaccess.door-access-attempt-success.label = Access Attempt Success
+channel-type.unifiaccess.door-access-attempt-success.description = Event payload is a JSON string with keys: doorId, doorName, actorName, credentialProvider, message, published.
+channel-type.unifiaccess.door-position.label = Door Position
+channel-type.unifiaccess.door-remote-unlock.label = Remote Unlock
+channel-type.unifiaccess.door-remote-unlock.description = Payload is a JSON string with keys: deviceId, name, fullName, level, workTimeId.
+channel-type.unifiaccess.door-thumbnail.label = Door Thumbnail
+channel-type.unifiaccess.doorbell-status.label = Doorbell Status
+channel-type.unifiaccess.doorbell-status.description = Fires on doorbell status changes
+channel-type.unifiaccess.face-anti-spoofing.label = Face Anti-Spoofing
+channel-type.unifiaccess.face-anti-spoofing.description = Face Anti-Spoofing level
+channel-type.unifiaccess.face-anti-spoofing.state.option.high = High
+channel-type.unifiaccess.face-anti-spoofing.state.option.medium = Medium
+channel-type.unifiaccess.face-anti-spoofing.state.option.no = None
+channel-type.unifiaccess.face-detect-distance.label = Face Detect Distance
+channel-type.unifiaccess.face-detect-distance.description = Face Detect Distance
+channel-type.unifiaccess.face-detect-distance.state.option.near = Near
+channel-type.unifiaccess.face-detect-distance.state.option.medium = Medium
+channel-type.unifiaccess.face-detect-distance.state.option.far = Far
+channel-type.unifiaccess.keep-locked.label = Keep Locked
+channel-type.unifiaccess.keep-unlocked.label = Keep Unlocked
+channel-type.unifiaccess.last-actor.label = Last Actor
+channel-type.unifiaccess.last-unlock.label = Last Unlock
+channel-type.unifiaccess.lock-rule.label = Lock Rule
+channel-type.unifiaccess.lock-rule.state.option.schedule = Schedule
+channel-type.unifiaccess.lock-rule.state.option.custom = Custom
+channel-type.unifiaccess.lock-rule.state.option.keep_unlock = Keep Unlocked
+channel-type.unifiaccess.lock-rule.state.option.keep_lock = Keep Locked
+channel-type.unifiaccess.lock-rule.state.option.reset = Reset
+channel-type.unifiaccess.lock-rule.state.option.lock_early = Lock Early
+channel-type.unifiaccess.lock-rule.state.option.lock_now = Lock Now
+channel-type.unifiaccess.lock-rule.state.option.none = None
+channel-type.unifiaccess.lock.label = Lock
+channel-type.unifiaccess.log-insight.label = Insight Log
+channel-type.unifiaccess.log-insight.description = Fires for insights log events. Payload is a JSON string with keys: logKey, eventType, message, published, result, and common refs like actor/displayName, door/name.
+channel-type.unifiaccess.log.label = Log
+channel-type.unifiaccess.log.description = Fires for access log events. Payload is a JSON string with keys: type, displayMessage, result, published, logKey, logCategory, actor/displayName, and targets.
+channel-type.unifiaccess.unlock-minutes.label = Unlock Minutes
+channel-type.unifiaccess.unlock-minutes.description = The number of minutes the door will be unlocked for
+channel-type.unifiaccess.unlock-until.label = Unlock Until
+channel-type.unifiaccess.unlock-until.description = The time the door will be unlocked until
diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 0000000000000..47cee0b5a78c7
--- /dev/null
+++ b/bundles/org.openhab.binding.unifiaccess/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+ UniFi Access controller
+
+
+
+
+
+
+
+ Host or IP address of the UniFi Access controller
+
+
+
+ API token from the UniFi Access controller
+
+
+
+
+
+
+
+
+
+
+
+
+ Door with remote control and status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reader / Hub / Camera
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fires for access.logs.insights.add referencing this device; event payload JSON includes logKey,
+ eventType, message, published, result, actorName, doorId, doorName, deviceId, cameraId.
+
+
+
+
+
+
+
+ Device ID
+
+
+
+
+
+ Switch
+
+ Toggles this access method
+ Switch
+
+
+
+ String
+
+ Face Anti-Spoofing level
+
+
+
+
+
+
+
+
+
+
+ String
+
+ Face Detect Distance
+
+
+
+
+
+
+
+
+
+
+ String
+
+ Emergency Status
+ Alarm
+
+
+
+
+
+
+
+
+
+
+ trigger
+
+ Fires on incoming and completed doorbell events
+
+
+
+
+
+
+
+
+
+
+
+ trigger
+
+ Fires on doorbell status changes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ trigger
+
+ Event payload is a JSON string with keys: doorId, doorName, actorName, credentialProvider, message,
+ published.
+
+
+
+ trigger
+
+ Event payload is a JSON string with keys: doorId, doorName, actorName, credentialProvider, message,
+ published.
+
+
+
+ trigger
+
+ Payload is a JSON string with keys: deviceId, name, fullName, level, workTimeId.
+
+
+
+ trigger
+
+ Fires for insights log events. Payload is a JSON string with keys: logKey, eventType,
+ message, published,
+ result, and common refs like actor/displayName, door/name.
+
+
+
+ trigger
+
+ Fires for access log events. Payload is a JSON string with keys: type, displayMessage,
+ result, published,
+ logKey, logCategory, actor/displayName, and targets.
+
+
+
+ Contact
+
+ Door
+
+
+
+
+ Switch
+
+ Lock
+
+
+
+ Contact
+
+ Door
+
+
+
+
+ String
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DateTime
+
+ Time
+
+
+
+
+ String
+
+
+
+
+
+ Switch
+
+ Lock
+
+
+
+ Switch
+
+ Lock
+
+
+
+ Number:Time
+
+ The number of minutes the door will be unlocked for
+ Time
+
+
+
+
+ DateTime
+
+ The time the door will be unlocked until
+ Time
+
+
+
+ Image
+
+ Door
+
+
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 818669b741fcf..a754ba31816ed 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -54,7 +54,6 @@
org.openhab.binding.airquality
org.openhab.binding.airvisualnode
org.openhab.binding.alarmdecoder
- org.openhab.binding.allplay
org.openhab.binding.amazondashbutton
org.openhab.binding.amazonechocontrol
org.openhab.binding.amberelectric
@@ -444,6 +443,7 @@
org.openhab.binding.tradfri
org.openhab.binding.tuya
org.openhab.binding.unifi
+ org.openhab.binding.unifiaccess
org.openhab.binding.unifiedremote
org.openhab.binding.upnpcontrol
org.openhab.binding.upb
@@ -668,7 +668,7 @@
org.apache.maven.plugins
maven-dependency-plugin
- 3.8.1
+ 3.9.0
embed-dependencies
@@ -709,7 +709,7 @@
org.codehaus.mojo
xml-maven-plugin
- 1.1.0
+ 1.2.0