diff --git a/CODEOWNERS b/CODEOWNERS index 1865229aed998..5552e2e8cabaa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -411,6 +411,7 @@ /bundles/org.openhab.binding.tradfri/ @cweitkamp @kaikreuzer /bundles/org.openhab.binding.tuya/ @J-N-K /bundles/org.openhab.binding.unifi/ @mgbowman @Hilbrand +/bundles/org.openhab.binding.unifiaccess/ @digitaldan /bundles/org.openhab.binding.unifiedremote/ @GiviMAD /bundles/org.openhab.binding.upb/ @marcusb /bundles/org.openhab.binding.upnpcontrol/ @mherwege diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 1ce231440bb18..2c1c54bb2322d 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -2031,6 +2031,11 @@ org.openhab.binding.unifi ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.unifiaccess + ${project.version} + org.openhab.addons.bundles org.openhab.binding.unifiedremote diff --git a/bundles/org.openhab.binding.unifiaccess/NOTICE b/bundles/org.openhab.binding.unifiaccess/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.unifiaccess/README.md b/bundles/org.openhab.binding.unifiaccess/README.md new file mode 100644 index 0000000000000..76839acc84e27 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/README.md @@ -0,0 +1,181 @@ +# UniFi Access Binding + +![logo](doc/logo.png) + +This binding integrates Ubiquiti UniFi Access with openHAB. +It connects to your UniFi Access controller over HTTPS and listens for live door events while exposing channels to monitor and control door locks. + +## Supported Things + +- `unifiaccess:bridge` (Bridge): The UniFi Access controller instance. + Required to discover and manage door things. +- `unifiaccess:door`: A UniFi Access door with status and control channels. +- `unifiaccess:device`: A UniFi Access device (reader, hub, or camera) with device-level status and controls. + +## Discovery + +- Add the `Bridge` by entering the controller Hostname or IP and an API Token. +- Once the Bridge is ONLINE, Doors are discovered automatically and appear in the Inbox. +- Approve discovered doors and devices to add them to your system, or create them manually using `deviceId`. + +## Binding Configuration + +There are no global binding settings. +All configuration is on the Bridge and on individual Door and Device things. + +## Thing Configuration + +### Bridge `unifiaccess:bridge` + +| Name | Type | Description | Default | Required | Advanced | +|-----------|------|--------------------------------------------------------|---------|----------|----------| +| host | text | Hostname or IP address of the UniFi Access controller. | N/A | yes | no | +| authToken | text | API token used for HTTPS and WebSocket authentication. | N/A | yes | no | + +How to get the API Token. +Open the UniFi Access controller and create an API token with permissions suitable for reading doors and remote unlocking. +Then paste the token into the Bridge configuration. + +![Create API token](doc/token.png) + +![Recommended permissions](doc/permissions.png) + +### Door `unifiaccess:door` + +| Name | Type | Description | Default | Required | Advanced | +|----------|------|----------------------------------------------------------|---------|----------|----------| +| deviceId | text | Unique door identifier from the UniFi Access controller. | N/A | yes | no | + +### Device `unifiaccess:device` + +| Name | Type | Description | Default | Required | Advanced | +|----------|------|------------------------------------------------------------|---------|----------|----------| +| deviceId | text | Unique device identifier from the UniFi Access controller. | N/A | yes | no | + +## Channels + +### Door Channels + +| Channel ID | Item Type | RW | Description | +|----------------|-------------|----|-----------------------------------------------------------------------------| +| lock | Switch | RW | Lock state. ON locks the door, OFF unlocks immediately. | +| position | Contact | R | Door position sensor. OPEN when the door is open, CLOSED otherwise. | +| last-unlock | DateTime | R | Timestamp of the last unlock event. | +| last-actor | String | R | Name of the user who last unlocked the door. | +| lock-rule | String | R | Current lock rule. One of `schedule`, `custom`, `keep_unlock`, `keep_lock`. | +| keep-unlocked | Switch | W | Keep the door unlocked until changed. Send ON to apply. | +| keep-locked | Switch | W | Keep the door locked until changed. Send ON to apply. | +| unlock-minutes | Number:Time | W | Unlock for a number of minutes. Send a value in minutes. | +| door-thumbnail | Image | R | Door thumbnail. | + +### Device Channels + +Channels + +| Channel ID | Item Type | RW | Description | +|-----------------------|-----------|----|-------------------------------------------------------------| +| nfc-enabled | Switch | RW | Enable or disable NFC access on this device. | +| pin-enabled | Switch | RW | Enable or disable PIN code access. | +| pin-shuffle | Switch | RW | Shuffle keypad digits for PIN entry. | +| face-enabled | Switch | RW | Enable or disable face unlock. | +| mobile-tap-enabled | Switch | RW | Allow mobile tap to unlock. | +| mobile-button-enabled | Switch | RW | Allow mobile app unlock button. | +| mobile-shake-enabled | Switch | RW | Allow mobile shake to unlock. | +| mobile-wave-enabled | Switch | RW | Allow mobile wave gesture to unlock. | +| wave-enabled | Switch | RW | Allow hand wave gesture to unlock. | +| qr-code-enabled | Switch | RW | Allow QR-code unlock. | +| touch-pass-enabled | Switch | RW | Allow Touch Pass unlock. | +| face-anti-spoofing | String | RW | One of `high`, `medium`, `no`. | +| face-detect-distance | String | RW | One of `near`, `medium`, `far`. | +| emergency-status | String | R | Device emergency state: `normal`, `lockdown`, `evacuation`. | +| door-sensor | Contact | R | Door position sensor. | +| doorbell-contact | Contact | R | Doorbell contact. | + + +Triggers + +| Channel ID | Events / Payload | Description | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------| +| doorbell | Events: `incoming`, `incoming-ren`, `completed` | Fires on doorbell events. | +| doorbell-status | Events: `DOORBELL_TIMED_OUT`, `ADMIN_REJECTED_UNLOCK`, `ADMIN_UNLOCK_SUCCEEDED`, `VISITOR_CANCELED_DOORBELL`, `ANSWERED_BY_ANOTHER_ADMIN`, `UNKNOWN` | Fires on doorbell status changes. | +| log-insight | Payload: JSON with `logKey`, `eventType`, `message`, `published`, `result`, common refs | Fires for insights log events for this device. | + +## Full Examples (Textual Configuration) + +Replace the IDs with your own thing and item names. +Examples assume a Bridge UID of `ua` and a Door UID of `frontdoor`. + +### Things (`.things`) + +``` +Bridge unifiaccess:bridge:ua "UniFi Access" [ host="192.168.1.20", authToken="YOUR_LONG_TOKEN" ] { + Thing unifiaccess:door:frontdoor [ deviceId="60546f80e4b0abcd12345678" ] +} +``` + +### Items (`.items`) + +``` +// Door status +Switch UA_FrontDoor_Lock "Front Door Locked" { channel="unifiaccess:door:ua:frontdoor:lock" } +Contact UA_FrontDoor_Position "Front Door Position [%s]" { channel="unifiaccess:door:ua:frontdoor:position" } +DateTime UA_FrontDoor_LastUnlock "Last Unlock [%1$ta %1$tF %1$tR]" { channel="unifiaccess:door:ua:frontdoor:last-unlock" } +String UA_FrontDoor_LastActor "Last Actor [%s]" { channel="unifiaccess:door:ua:frontdoor:last-actor" } +String UA_FrontDoor_LockRule "Lock Rule [%s]" { channel="unifiaccess:door:ua:frontdoor:lock-rule" } + +// Door controls +Switch UA_FrontDoor_KeepUnlocked "Keep Unlocked" { channel="unifiaccess:door:ua:frontdoor:keep-unlocked" } +Switch UA_FrontDoor_KeepLocked "Keep Locked" { channel="unifiaccess:door:ua:frontdoor:keep-locked" } +Number:Time UA_FrontDoor_UnlockMins "Unlock Minutes [%.0f min]" { channel="unifiaccess:door:ua:frontdoor:unlock-minutes" } +``` + +### Sitemap (`.sitemap`) + +``` +sitemap home label="Home" { + Frame label="Front Door" { + Switch item=UA_FrontDoor_Lock + Text item=UA_FrontDoor_Position + Text item=UA_FrontDoor_LastActor + Text item=UA_FrontDoor_LastUnlock + Text item=UA_FrontDoor_LockRule + Switch item=UA_FrontDoor_KeepUnlocked + Switch item=UA_FrontDoor_KeepLocked + Setpoint item=UA_FrontDoor_UnlockMins minValue=1 maxValue=120 step=1 + } +} +``` + +### Rules + +```javascript +rules.when().channel('unifiaccess:door:ua:frontdoor:access-attempt-success').triggered().then( e => { + const jsonData = JSON.parse(e.payload.event); + console.log("Door Access Attempt Success: ", jsonData); +}).build('Unifi Access Door Access Attempt Success'); + +rules.when().channel('unifiaccess:door:ua:frontdoor:access-attempt-failure').triggered().then( e => { + const jsonData = JSON.parse(e.payload.event); + console.log("Door Access Attempt Failure: ", jsonData); +}).build('Unifi Access Door Access Attempt Failure'); + +rules.when().channel('unifiaccess:door:ua:frontdoor:remote-unlock').triggered().then( e => { + const jsonData = JSON.parse(e.payload.event); + console.log("Door Remote Unlock: ", jsonData); +}).build('Unifi Access Door Remote Unlock'); + +rules.when().channel('unifiaccess:door:ua:frontdoor:doorbell-status').triggered().then( e => { + const data = e.payload.event; + console.log("Doorbell Status: ", e); +}).build('Unifi Access Doorbell Status'); + +rules.when().channel('unifiaccess:bridge:ua:log-insight').triggered().then( e => { + const jsonData = JSON.parse(e.payload.event); + console.log("Bridge Log Insight: ", jsonData); +}).build('Unifi Access Bridge Log Insight'); + +rules.when().channel('unifiaccess:bridge:ua:log').triggered().then( e => { + const jsonData = JSON.parse(e.payload.event); + console.log("Bridge Log: ", jsonData); +}).build('Unifi Access Bridge Log'); +``` diff --git a/bundles/org.openhab.binding.unifiaccess/doc/logo.png b/bundles/org.openhab.binding.unifiaccess/doc/logo.png new file mode 100644 index 0000000000000..3ccbb3a1df647 Binary files /dev/null and b/bundles/org.openhab.binding.unifiaccess/doc/logo.png differ diff --git a/bundles/org.openhab.binding.unifiaccess/doc/permissions.png b/bundles/org.openhab.binding.unifiaccess/doc/permissions.png new file mode 100644 index 0000000000000..b900d3eb9c483 Binary files /dev/null and b/bundles/org.openhab.binding.unifiaccess/doc/permissions.png differ diff --git a/bundles/org.openhab.binding.unifiaccess/doc/token.png b/bundles/org.openhab.binding.unifiaccess/doc/token.png new file mode 100644 index 0000000000000..8366df80507ff Binary files /dev/null and b/bundles/org.openhab.binding.unifiaccess/doc/token.png differ diff --git a/bundles/org.openhab.binding.unifiaccess/pom.xml b/bundles/org.openhab.binding.unifiaccess/pom.xml new file mode 100644 index 0000000000000..1cefeed2ea4f9 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.1.0-SNAPSHOT + + + org.openhab.binding.unifiaccess + + openHAB Add-ons :: Bundles :: UnifiAccess Binding + + diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/feature/feature.xml b/bundles/org.openhab.binding.unifiaccess/src/main/feature/feature.xml new file mode 100644 index 0000000000000..ecffe31fd388c --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.unifiaccess/${project.version} + + diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessBindingConstants.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessBindingConstants.java new file mode 100644 index 0000000000000..64c0e102c8c39 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessBindingConstants.java @@ -0,0 +1,75 @@ +/* + * 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; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link UnifiAccessBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Dan Cunningham - Initial contribution + */ +@NonNullByDefault +public class UnifiAccessBindingConstants { + + public static final String BINDING_ID = "unifiaccess"; + + public static final ThingTypeUID BRIDGE_THING_TYPE = new ThingTypeUID(BINDING_ID, "bridge"); + public static final ThingTypeUID DOOR_THING_TYPE = new ThingTypeUID(BINDING_ID, "door"); + public static final ThingTypeUID DEVICE_THING_TYPE = new ThingTypeUID(BINDING_ID, "device"); + + public static final String CONFIG_DEVICE_ID = "deviceId"; + + public static final String CHANNEL_LOCK = "lock"; + public static final String CHANNEL_DOOR_POSITION = "position"; + public static final String CHANNEL_LAST_UNLOCK = "last-unlock"; + public static final String CHANNEL_LAST_ACTOR = "last-actor"; + public static final String CHANNEL_LOCK_RULE = "lock-rule"; + + public static final String CHANNEL_KEEP_UNLOCKED = "keep-unlocked"; + public static final String CHANNEL_KEEP_LOCKED = "keep-locked"; + public static final String CHANNEL_UNLOCK_MINUTES = "unlock-minutes"; + public static final String CHANNEL_UNLOCK_UNTIL = "unlock-until"; + public static final String CHANNEL_DOOR_THUMBNAIL = "thumbnail"; + + // Door trigger channels + public static final String CHANNEL_DOOR_ACCESS_ATTEMPT_SUCCESS = "access-attempt-success"; + public static final String CHANNEL_DOOR_ACCESS_ATTEMPT_FAILURE = "access-attempt-failure"; + public static final String CHANNEL_DOOR_REMOTE_UNLOCK = "remote-unlock"; + public static final String CHANNEL_DOORBELL_STATUS = "doorbell-status"; + + // Device channels + public static final String CHANNEL_DEVICE_NFC_ENABLED = "nfc-enabled"; + public static final String CHANNEL_DEVICE_PIN_ENABLED = "pin-enabled"; + public static final String CHANNEL_DEVICE_PIN_SHUFFLE = "pin-shuffle"; + public static final String CHANNEL_DEVICE_FACE_ENABLED = "face-enabled"; + public static final String CHANNEL_DEVICE_MOBILE_TAP_ENABLED = "mobile-tap-enabled"; + public static final String CHANNEL_DEVICE_MOBILE_BUTTON_ENABLED = "mobile-button-enabled"; + public static final String CHANNEL_DEVICE_MOBILE_SHAKE_ENABLED = "mobile-shake-enabled"; + public static final String CHANNEL_DEVICE_MOBILE_WAVE_ENABLED = "mobile-wave-enabled"; + public static final String CHANNEL_DEVICE_WAVE_ENABLED = "wave-enabled"; + public static final String CHANNEL_DEVICE_EMERGENCY_STATUS = "emergency-status"; + public static final String CHANNEL_DEVICE_DOOR_SENSOR = "door-sensor"; + public static final String CHANNEL_DEVICE_DOORBELL_CONTACT = "doorbell-contact"; + public static final String CHANNEL_DEVICE_DOORBELL_TRIGGER = "doorbell"; + public static final String CHANNEL_DEVICE_QR_CODE_ENABLED = "qr-code-enabled"; + public static final String CHANNEL_DEVICE_TOUCH_PASS_ENABLED = "touch-pass-enabled"; + public static final String CHANNEL_DEVICE_FACE_ANTI_SPOOFING = "face-anti-spoofing"; + public static final String CHANNEL_DEVICE_FACE_DETECT_DISTANCE = "face-detect-distance"; + + // Bridge trigger channels + public static final String CHANNEL_BRIDGE_LOG_INSIGHT = "log-insight"; + public static final String CHANNEL_BRIDGE_LOG = "log"; +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessDiscoveryService.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessDiscoveryService.java new file mode 100644 index 0000000000000..87dfdca4a41a7 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessDiscoveryService.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; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.unifiaccess.internal.api.UniFiAccessApiClient; +import org.openhab.binding.unifiaccess.internal.dto.Device; +import org.openhab.binding.unifiaccess.internal.dto.Door; +import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessBridgeHandler; +import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ServiceScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Discovery service for UniFi Access Door things. + * + * @author Dan Cunningham - Initial contribution + */ +@Component(scope = ServiceScope.PROTOTYPE, service = UnifiAccessDiscoveryService.class) +@NonNullByDefault +public class UnifiAccessDiscoveryService extends AbstractThingHandlerDiscoveryService { + + private final Logger logger = LoggerFactory.getLogger(UnifiAccessDiscoveryService.class); + + public UnifiAccessDiscoveryService() { + super(UnifiAccessBridgeHandler.class, + Set.of(UnifiAccessBindingConstants.DOOR_THING_TYPE, UnifiAccessBindingConstants.DEVICE_THING_TYPE), 30, + false); + } + + @Override + public void setThingHandler(ThingHandler handler) { + if (handler instanceof UnifiAccessBridgeHandler childDiscoveryHandler) { + childDiscoveryHandler.setDiscoveryService(this); + this.thingHandler = childDiscoveryHandler; + } + } + + @Override + protected void startScan() { + removeOlderResults(getTimestampOfLastScan()); + final UniFiAccessApiClient client = thingHandler.getApiClient(); + if (client == null) { + return; + } + try { + List doors = client.getDoors(); + discoverDoors(doors); + List devices = client.getDevices(); + discoverDevices(devices); + } catch (Exception e) { + logger.debug("Error discovering doors: {}", e.getMessage()); + } + } + + public void discoverDoors(List doors) { + for (Door d : doors) { + ThingUID uid = new ThingUID(UnifiAccessBindingConstants.DOOR_THING_TYPE, thingHandler.getThing().getUID(), + d.id); + Map props = Map.of(UnifiAccessBindingConstants.CONFIG_DEVICE_ID, d.id); + DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(thingHandler.getThing().getUID()) + .withThingType(UnifiAccessBindingConstants.DOOR_THING_TYPE).withProperties(props) + .withRepresentationProperty(UnifiAccessBindingConstants.CONFIG_DEVICE_ID) + .withLabel("UniFi Access Door: " + d.name).build(); + thingDiscovered(result); + } + } + + public void discoverDevices(List devices) { + for (Device d : devices) { + ThingUID uid = new ThingUID(UnifiAccessBindingConstants.DEVICE_THING_TYPE, thingHandler.getThing().getUID(), + d.id); + Map props = Map.of(UnifiAccessBindingConstants.CONFIG_DEVICE_ID, d.id); + DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(thingHandler.getThing().getUID()) + .withThingType(UnifiAccessBindingConstants.DEVICE_THING_TYPE).withProperties(props) + .withRepresentationProperty(UnifiAccessBindingConstants.CONFIG_DEVICE_ID) + .withLabel("UniFi Access Device: " + (d.alias != null ? d.alias : d.name)).build(); + thingDiscovered(result); + } + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessHandlerFactory.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessHandlerFactory.java new file mode 100644 index 0000000000000..5424b712f6bde --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/UnifiAccessHandlerFactory.java @@ -0,0 +1,87 @@ +/* + * 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; + +import static org.openhab.binding.unifiaccess.internal.UnifiAccessBindingConstants.*; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessBridgeHandler; +import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessDeviceHandler; +import org.openhab.binding.unifiaccess.internal.handler.UnifiAccessDoorHandler; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link UnifiAccessHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Dan Cunningham - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.unifiaccess", service = ThingHandlerFactory.class) +public class UnifiAccessHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(BRIDGE_THING_TYPE, DOOR_THING_TYPE, + DEVICE_THING_TYPE); + + private @Nullable HttpClientFactory httpClientFactory; + + @Reference + protected void setHttpClientFactory(HttpClientFactory factory) { + this.httpClientFactory = factory; + } + + protected void unsetHttpClientFactory(HttpClientFactory factory) { + if (factory.equals(this.httpClientFactory)) { + this.httpClientFactory = null; + } + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (BRIDGE_THING_TYPE.equals(thingTypeUID)) { + HttpClientFactory httpClientFactory = this.httpClientFactory; + if (httpClientFactory == null) { + return null; + } + return new UnifiAccessBridgeHandler((Bridge) thing, httpClientFactory); + } + + if (DOOR_THING_TYPE.equals(thingTypeUID)) { + return new UnifiAccessDoorHandler(thing); + } + + if (DEVICE_THING_TYPE.equals(thingTypeUID)) { + return new UnifiAccessDeviceHandler(thing); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/api/UniFiAccessApiClient.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/api/UniFiAccessApiClient.java new file mode 100644 index 0000000000000..7d67a83121812 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/api/UniFiAccessApiClient.java @@ -0,0 +1,828 @@ +/* + * 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.api; + +import java.io.Closeable; +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.unifiaccess.internal.dto.AccessPolicy; +import org.openhab.binding.unifiaccess.internal.dto.AccessPolicyHolidayGroup; +import org.openhab.binding.unifiaccess.internal.dto.AccessPolicySchedule; +import org.openhab.binding.unifiaccess.internal.dto.ApiResponse; +import org.openhab.binding.unifiaccess.internal.dto.Device; +import org.openhab.binding.unifiaccess.internal.dto.DeviceAccessMethodSettings; +import org.openhab.binding.unifiaccess.internal.dto.Door; +import org.openhab.binding.unifiaccess.internal.dto.DoorEmergencySettings; +import org.openhab.binding.unifiaccess.internal.dto.DoorLockRule; +import org.openhab.binding.unifiaccess.internal.dto.DoorUnlockRequest; +import org.openhab.binding.unifiaccess.internal.dto.Image; +import org.openhab.binding.unifiaccess.internal.dto.NfcEnrollSession; +import org.openhab.binding.unifiaccess.internal.dto.NfcEnrollStatus; +import org.openhab.binding.unifiaccess.internal.dto.Notification; +import org.openhab.binding.unifiaccess.internal.dto.UniFiAccessApiException; +import org.openhab.binding.unifiaccess.internal.dto.User; +import org.openhab.binding.unifiaccess.internal.dto.Visitor; +import org.openhab.binding.unifiaccess.internal.dto.WebhookEndpoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; + +/** + * UniFi Access API client + * + * + * @author Dan Cunningham - Initial contribution + */ +@NonNullByDefault +public final class UniFiAccessApiClient implements Closeable { + + private Logger logger = LoggerFactory.getLogger(UniFiAccessApiClient.class); + + private static final String STATIC_BASE = "//api/v1/developer/system/static"; // double slashes intentional + private final HttpClient httpClient; + private final URI base; + private final Gson gson; + private final Map defaultHeaders; + private final WebSocketClient wsClient; + private @Nullable Session wsSession; + private long lastHeartbeatEpochMs; + private @Nullable ScheduledFuture wsMonitorFuture; + private boolean closed = false; + private final ScheduledExecutorService executorService; + + public UniFiAccessApiClient(HttpClient httpClient, URI base, Gson gson, String token, + ScheduledExecutorService executorService) { + this.httpClient = httpClient; + this.base = ensureTrailingSlash(base); + this.gson = gson; + this.defaultHeaders = Map.of("Authorization", "Bearer " + token, "Accept", "application/json"); + this.wsClient = new WebSocketClient(httpClient); + this.wsClient.unmanage(this.httpClient); + try { + wsClient.start(); + } catch (Exception e) { + throw new IllegalStateException("Failed to start Jetty ws client", e); + } + this.executorService = executorService; + } + + @Override + public synchronized void close() { + closed = true; + try { + Session s = wsSession; + if (s != null) { + try { + s.close(); + } finally { + wsSession = null; + } + } + } catch (Exception e) { + logger.debug("Error closing notifications WebSocket: {}", e.getMessage()); + } + try { + wsClient.stop(); + } catch (Exception e) { + logger.debug("Error stopping WebSocket client: {}", e.getMessage()); + } + stopWsMonitor(); + } + + public List getUsers() throws UniFiAccessApiException { + Type wrapped = TypeToken + .getParameterized(ApiResponse.class, TypeToken.getParameterized(List.class, User.class).getType()) + .getType(); + Type raw = TypeToken.getParameterized(List.class, User.class).getType(); + ContentResponse resp = execGet("/users"); + ensure2xx(resp, "getUsers"); + return requireData(parseMaybeWrapped(resp.getContentAsString(), wrapped, raw, "getUsers"), "getUsers"); + } + + public User getUser(String userId) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, User.class).getType(); + ContentResponse resp = execGet("/users/" + userId); + ensure2xx(resp, "getUser"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "getUser"); + } + + public Visitor createVisitor(Visitor payload) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, Visitor.class).getType(); + ContentResponse resp = execPost("/visitors", payload); + ensure2xx(resp, "createVisitor"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "createVisitor"); + } + + public Visitor updateVisitor(String visitorId, Visitor payload) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, Visitor.class).getType(); + ContentResponse resp = execPut("/visitors/" + visitorId, payload); + ensure2xx(resp, "updateVisitor"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "updateVisitor"); + } + + public void deleteVisitor(String visitorId) throws UniFiAccessApiException { + ContentResponse resp = execDelete("/visitors/" + visitorId); + ensure2xx(resp, "deleteVisitor"); + } + + public List getAccessPolicies() throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, + TypeToken.getParameterized(List.class, AccessPolicy.class).getType()).getType(); + Type raw = TypeToken.getParameterized(List.class, AccessPolicy.class).getType(); + ContentResponse resp = execGet("/access-policies"); + ensure2xx(resp, "getAccessPolicies"); + return requireData(parseMaybeWrapped(resp.getContentAsString(), wrapped, raw, "getAccessPolicies"), + "getAccessPolicies"); + } + + public AccessPolicy createAccessPolicy(AccessPolicy policy) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, AccessPolicy.class).getType(); + ContentResponse resp = execPost("/access-policies", policy); + ensure2xx(resp, "createAccessPolicy"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "createAccessPolicy"); + } + + public AccessPolicy updateAccessPolicy(String policyId, AccessPolicy policy) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, AccessPolicy.class).getType(); + ContentResponse resp = execPut("/access-policies/" + policyId, policy); + ensure2xx(resp, "updateAccessPolicy"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "updateAccessPolicy"); + } + + public AccessPolicySchedule createSchedule(AccessPolicySchedule schedule) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, AccessPolicySchedule.class).getType(); + ContentResponse resp = execPost("/schedules", schedule); + ensure2xx(resp, "createSchedule"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "createSchedule"); + } + + public AccessPolicySchedule updateSchedule(String scheduleId, AccessPolicySchedule schedule) + throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, AccessPolicySchedule.class).getType(); + ContentResponse resp = execPut("/schedules/" + scheduleId, schedule); + ensure2xx(resp, "updateSchedule"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "updateSchedule"); + } + + public AccessPolicyHolidayGroup createHolidayGroup(AccessPolicyHolidayGroup hg) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, AccessPolicyHolidayGroup.class).getType(); + ContentResponse resp = execPost("/holiday-groups", hg); + ensure2xx(resp, "createHolidayGroup"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "createHolidayGroup"); + } + + public List getDevices() throws UniFiAccessApiException { + Type wrapped = TypeToken + .getParameterized(ApiResponse.class, TypeToken.getParameterized(List.class, Device.class).getType()) + .getType(); + Type raw = TypeToken.getParameterized(List.class, Device.class).getType(); + ContentResponse resp = execGet("/devices"); + ensure2xx(resp, "getDevices"); + String json = resp.getContentAsString(); + return parseListMaybeWrapped(json, wrapped, raw, "getDevices", "devices"); + } + + public DeviceAccessMethodSettings getDeviceAccessMethodSettings(String deviceId) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, DeviceAccessMethodSettings.class).getType(); + ContentResponse resp = execGet("/devices/" + deviceId + "/settings"); + ensure2xx(resp, "getDeviceAccessMethodSettings"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "getDeviceAccessMethodSettings"); + } + + /** + * Update Access Device's Access Method Settings. + */ + public DeviceAccessMethodSettings updateDeviceAccessMethodSettings(String deviceId, + DeviceAccessMethodSettings settings) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, DeviceAccessMethodSettings.class).getType(); + ContentResponse resp = execPut("/devices/" + deviceId + "/settings", settings); + ensure2xx(resp, "updateDeviceAccessMethodSettings"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "updateDeviceAccessMethodSettings"); + } + + public DoorEmergencySettings getDoorEmergencySettings(String doorId) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, DoorEmergencySettings.class).getType(); + ContentResponse resp = execGet("/doors/" + doorId + "/settings/emergency"); + ensure2xx(resp, "getDoorEmergencySettings"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "getDoorEmergencySettings"); + } + + public void setDoorEmergencySettings(String doorId, DoorEmergencySettings settings) throws UniFiAccessApiException { + ContentResponse resp = execPut("/doors/" + doorId + "/settings/emergency", settings); + ensure2xx(resp, "setDoorEmergencySettings"); + } + + /** + * Starts a card enrollment session; returns session id/metadata. + */ + public NfcEnrollSession createNfcEnrollSession() throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, NfcEnrollSession.class).getType(); + ContentResponse resp = execPost("/nfc/enroll/session", Map.of()); + ensure2xx(resp, "createNfcEnrollSession"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "createNfcEnrollSession"); + } + + public NfcEnrollStatus getNfcEnrollStatus(String sessionId) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, NfcEnrollStatus.class).getType(); + ContentResponse resp = execGet("/nfc/enroll/session/" + sessionId); + ensure2xx(resp, "getNfcEnrollStatus"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "getNfcEnrollStatus"); + } + + public void deleteNfcEnrollSession(String sessionId) throws UniFiAccessApiException { + ContentResponse resp = execDelete("/nfc/enroll/session/" + sessionId); + ensure2xx(resp, "deleteNfcEnrollSession"); + } + + public List listWebhooks() throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, + TypeToken.getParameterized(List.class, WebhookEndpoint.class).getType()).getType(); + Type raw = TypeToken.getParameterized(List.class, WebhookEndpoint.class).getType(); + ContentResponse resp = execGet("/webhooks"); + ensure2xx(resp, "listWebhooks"); + return requireData(parseMaybeWrapped(resp.getContentAsString(), wrapped, raw, "listWebhooks"), "listWebhooks"); + } + + public WebhookEndpoint createWebhook(WebhookEndpoint endpoint) throws UniFiAccessApiException { + Type wrapped = TypeToken.getParameterized(ApiResponse.class, WebhookEndpoint.class).getType(); + ContentResponse resp = execPost("/webhooks", endpoint); + ensure2xx(resp, "createWebhook"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "createWebhook"); + } + + public void deleteWebhook(String webhookId) throws UniFiAccessApiException { + ContentResponse resp = execDelete("/webhooks/" + webhookId); + ensure2xx(resp, "deleteWebhook"); + } + + public List getDoors() throws UniFiAccessApiException { + var wrapped = com.google.gson.reflect.TypeToken + .getParameterized(ApiResponse.class, TypeToken.getParameterized(List.class, Door.class).getType()) + .getType(); + var raw = TypeToken.getParameterized(List.class, Door.class).getType(); + var resp = execGet("/doors"); + ensure2xx(resp, "getDoors"); + String json = resp.getContentAsString(); + return parseListMaybeWrapped(json, wrapped, raw, "getDoors", "doors"); + } + + public Door getDoor(String doorId) throws UniFiAccessApiException { + var wrapped = TypeToken.getParameterized(ApiResponse.class, Door.class).getType(); + var resp = execGet("/doors/" + doorId); + ensure2xx(resp, "getDoor"); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "getDoor"); + } + + /** + * Remote unlock: optionally provide actor id/name and arbitrary passthrough + * "extra". + */ + public boolean unlockDoor(String doorId, DoorUnlockRequest body) throws UniFiAccessApiException { + var resp = execPut("/doors/" + doorId + "/unlock", body); + ensure2xx(resp, "unlockDoor"); + var wrapped = TypeToken.getParameterized(ApiResponse.class, Object.class).getType(); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + if (ar == null) { + throw new UniFiAccessApiException("Missing or null response data for unlockDoor"); + } + return ar.isSuccess(); + } + + public boolean unlockDoor(String doorId, @Nullable String actorId, @Nullable String actorName, + @Nullable Map extra) throws UniFiAccessApiException { + return unlockDoor(doorId, new DoorUnlockRequest(actorId, actorName, extra)); + } + + public boolean setDoorLockRule(String doorId, DoorLockRule rule) throws UniFiAccessApiException { + var resp = execPut("/doors/" + doorId + "/lock_rule", rule); + ensure2xx(resp, "setDoorLockRule"); + var wrapped = TypeToken.getParameterized(ApiResponse.class, Object.class).getType(); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + if (ar == null) { + throw new UniFiAccessApiException("Missing or null response data for setDoorLockRule"); + } + return ar.isSuccess(); + } + + public DoorLockRule getDoorLockRule(String doorId) throws UniFiAccessApiException { + var resp = execGet("/doors/" + doorId + "/lock_rule"); + ensure2xx(resp, "getDoorLockRule"); + var wrapped = TypeToken.getParameterized(ApiResponse.class, DoorLockRule.class).getType(); + ApiResponse ar = gson.fromJson(resp.getContentAsString(), wrapped); + return requireData(ar == null ? null : ar.data, "getDoorLockRule"); + } + + public boolean keepDoorUnlocked(String doorId) throws UniFiAccessApiException { + return setDoorLockRule(doorId, DoorLockRule.keepUnlock()); + } + + public boolean keepDoorLocked(String doorId) throws UniFiAccessApiException { + return setDoorLockRule(doorId, DoorLockRule.keepLock()); + } + + public boolean unlockForMinutes(String doorId, int minutes) throws UniFiAccessApiException { + if (minutes <= 0) { + throw new IllegalArgumentException("minutes must be > 0"); + } + return setDoorLockRule(doorId, DoorLockRule.customMinutes(minutes)); + } + + public boolean resetDoorLockRule(String doorId) throws UniFiAccessApiException { + return setDoorLockRule(doorId, DoorLockRule.reset()); + } + + /** End an active keep-unlock/custom early (lock immediately). */ + public boolean lockEarly(String doorId) throws UniFiAccessApiException { + return setDoorLockRule(doorId, DoorLockRule.lockEarly()); + } + + public Image getDoorThumbnail(String path) throws UniFiAccessApiException { + var resp = execGet(STATIC_BASE + path); + ensure2xx(resp, "getDoorThumbnail"); + Image image = new Image(); + image.mediaType = resp.getMediaType(); + image.data = resp.getContent(); + return image; + } + + public synchronized void openNotifications(Runnable onOpen, Consumer onMessage, + Consumer onError, BiConsumer onClosed) throws UniFiAccessApiException { + Session session = wsSession; + if (session != null && session.isOpen()) { + return; + } + try { + URI wsUri = toWebSocketUri("devices/notifications"); + logger.debug("Notifications WebSocket URI: {}", wsUri); + ClientUpgradeRequest req = new ClientUpgradeRequest(); + defaultHeaders.forEach(req::setHeader); + req.setHeader("Upgrade", "websocket"); + req.setHeader("Connection", "Upgrade"); + + WebSocketAdapter socket = new WebSocketAdapter() { + @Override + @NonNullByDefault({}) + public void onWebSocketConnect(Session sess) { + super.onWebSocketConnect(sess); + logger.info("Notifications WebSocket connected: {}", wsUri); + wsSession = sess; + try { + onOpen.run(); + } catch (Exception ignored) { + } + lastHeartbeatEpochMs = System.currentTimeMillis(); + } + + @Override + @NonNullByDefault({}) + public void onWebSocketText(String message) { + try { + if (message != null && !message.isEmpty()) { + if (message.charAt(0) == '"') { + String normalized = message.trim(); + lastHeartbeatEpochMs = System.currentTimeMillis(); + logger.trace("Notifications heartbeat received: {}", normalized); + return; + } else { + Notification note = gson.fromJson(message, Notification.class); + if (note != null) { + onMessage.accept(note); + } + } + } + } catch (Exception e) { + logger.debug("Notifications handler failed: {}", e.getMessage()); + try { + if (!closed) { + onError.accept(e); + } + } catch (Exception ignored) { + } + } + } + + @Override + @NonNullByDefault({}) + public void onWebSocketError(Throwable cause) { + logger.debug("Notifications WebSocket error: {}", cause.getMessage(), cause); + try { + onError.accept(cause); + } catch (Exception ignored) { + } + } + + @Override + @NonNullByDefault({}) + public void onWebSocketClose(int statusCode, String reason) { + logger.debug("Notifications WebSocket closed: {} - {}", statusCode, reason); + try { + onClosed.accept(statusCode, reason); + } catch (Exception ignored) { + } + } + }; + + wsClient.connect(socket, wsUri, req); + startWsMonitor(); + } catch (IOException e) { + throw new UniFiAccessApiException("WebSocket connect failed: " + e.getMessage(), e); + } + } + + private Request newRequest(HttpMethod method, String path, @Nullable Consumer customizer) { + URI uri; + if (path.startsWith("//")) { + uri = base.resolve(path.substring(1)); + } else { + uri = base.resolve(path.startsWith("/") ? path.substring(1) : path); + } + Request req = httpClient.newRequest(uri).method(method).header(HttpHeader.ACCEPT, "application/json"); + defaultHeaders.forEach(req::header); + logger.debug("path: {} base: {} uri: {}", path, base, uri); + req.getHeaders().forEach(header -> logger.debug("header {}: {}", header.getName(), header.getValue())); + if (customizer != null) { + customizer.accept(req); + } + return req; + } + + private static URI ensureTrailingSlash(URI uri) { + String s = uri.toString(); + return s.endsWith("/") ? uri : URI.create(s + "/"); + } + + private void ensure2xx(ContentResponse resp, String action) throws UniFiAccessApiException { + if (logger.isTraceEnabled()) { + String mediaType = resp.getMediaType(); + if ("image/jpeg".equalsIgnoreCase(mediaType) || "image/png".equalsIgnoreCase(mediaType)) { + logger.trace("ensure2xx status: {} mediaType: {} resp: image data", resp.getStatus(), mediaType); + } else { + logger.trace("ensure2xx status: {} mediaType: {} resp: {}", resp.getStatus(), mediaType, + resp.getContentAsString()); + } + } + int sc = resp.getStatus(); + if (sc < 200 || sc >= 300) { + String msg = resp.getContentAsString(); + throw new UniFiAccessApiException("Non 2xx response for " + action + ": " + sc + " - " + msg); + } + } + + /** + * Parse responses that might be either: + * - Wrapped: {"code":0,"data":...,"msg":"ok"} + * - Raw: {...} or [...] + */ + private T parseMaybeWrapped(String json, Type wrappedType, Type rawType, String action) + throws UniFiAccessApiException { + if (json.isBlank()) { + throw new UniFiAccessApiException("Failed to parse response for " + action + ": null or blank JSON"); + } + try { + // Try wrapped first + ApiResponse wrapped = gson.fromJson(json, wrappedType); + if (wrapped != null && wrapped.data != null) { + return Objects.requireNonNull(wrapped.data); + } + } catch (Exception ignored) { + } + try { + @Nullable + T raw = gson.fromJson(json, rawType); + if (raw == null) { + throw new UniFiAccessApiException("Empty Data"); + } + return raw; + } catch (Exception e) { + throw new UniFiAccessApiException("Failed to parse response for " + action + ": " + e.getMessage(), e); + } + } + + private String toJson(Object body) { + return gson.toJson(body); + } + + /** + * Ensure parsed response data is non-null or throw a parse exception. + */ + private T requireData(@Nullable T data, String action) throws UniFiAccessApiException { + if (data == null) { + throw new UniFiAccessApiException("Missing or null response data for " + action); + } + return data; + } + + /** + * For endpoints that may return a list but may be wrapped or nested + * inside an object: + * - raw JSON array + * - { "data": [ ... ] } + * - { "data": { "list"|"items"|"records": [ ... ] } } + * - { altArrayKeys[0]|altArrayKeys[1]|...: [ ... ] } + */ + private List parseListMaybeWrapped(String json, Type wrappedListType, Type rawListType, String action, + String... altArrayKeys) throws UniFiAccessApiException { + if (json.isBlank()) { + return Collections.emptyList(); + } + + // Try standard wrapped format + try { + ApiResponse> wrapped = gson.fromJson(json, wrappedListType); + if (wrapped != null && wrapped.data != null && !wrapped.data.isEmpty()) { + return Objects.requireNonNull(wrapped.data); + } + // If code/msg present but empty data, still accept empty list + if (wrapped != null && (wrapped.msg != null || wrapped.code != null) && wrapped.data == null) { + return Collections.emptyList(); + } + } catch (Exception ignored) { + } + + // If it's already a raw array (possibly nested arrays) + try { + JsonElement root = JsonParser.parseString(json); + if (root.isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(root.getAsJsonArray()); + return nullToEmptyList(gson.fromJson(flat, rawListType)); + } + + if (root.isJsonObject()) { + JsonObject obj = root.getAsJsonObject(); + + Function> parseArray = je -> gson.fromJson(je, rawListType); + String[] arrayKeys = new String[] { "list", "items", "records", "rows" }; + + // Containers such as data/result/payload + for (String container : new String[] { "data", "result", "payload" }) { + if (!obj.has(container)) { + continue; + } + JsonElement data = obj.get(container); + if (data.isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(data.getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + if (data.isJsonObject()) { + JsonObject dObj = data.getAsJsonObject(); + // common array fields directly under container + for (String k : arrayKeys) { + if (dObj.has(k) && dObj.get(k).isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(dObj.get(k).getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + } + // altArrayKeys under container (e.g., devices) + for (String key : altArrayKeys) { + if (!dObj.has(key)) { + continue; + } + JsonElement alt = dObj.get(key); + if (alt.isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(alt.getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + if (alt.isJsonObject()) { + JsonObject aObj = alt.getAsJsonObject(); + for (String k : arrayKeys) { + if (aObj.has(k) && aObj.get(k).isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(aObj.get(k).getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + } + } + } + // as a last resort: first array value under container + for (var entry : dObj.entrySet()) { + if (entry.getValue().isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(entry.getValue().getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + } + } + } + + for (String key : altArrayKeys) { + if (!obj.has(key)) { + continue; + } + JsonElement alt = obj.get(key); + if (alt.isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(alt.getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + if (alt.isJsonObject()) { + JsonObject aObj = alt.getAsJsonObject(); + for (String k : arrayKeys) { + if (aObj.has(k) && aObj.get(k).isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(aObj.get(k).getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + } + for (var entry : aObj.entrySet()) { + if (entry.getValue().isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(entry.getValue().getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + } + } + } + // any direct array at root + for (var entry : obj.entrySet()) { + if (entry.getValue().isJsonArray()) { + JsonElement flat = flattenArrayIfNeeded(entry.getValue().getAsJsonArray()); + return nullToEmptyList(parseArray.apply(flat)); + } + } + } + } catch (Exception ignored) { + } + + throw new UniFiAccessApiException("Failed to parse list response for " + action + ": unexpected JSON"); + } + + private static JsonElement flattenArrayIfNeeded(JsonArray array) { + boolean hasNested = false; + for (int i = 0; i < array.size(); i++) { + if (array.get(i).isJsonArray()) { + hasNested = true; + break; + } + } + if (!hasNested) { + return array; + } + JsonArray flat = new JsonArray(); + for (int i = 0; i < array.size(); i++) { + JsonElement el = array.get(i); + if (el.isJsonArray()) { + JsonArray inner = el.getAsJsonArray(); + for (int j = 0; j < inner.size(); j++) { + flat.add(inner.get(j)); + } + } else if (el.isJsonObject()) { + flat.add(el); + } + } + return flat; + } + + private static List nullToEmptyList(@Nullable List list) { + return list == null ? Collections.emptyList() : list; + } + + private ContentResponse execGet(String path) throws UniFiAccessApiException { + try { + return newRequest(HttpMethod.GET, path, null).send(); + } catch (Exception e) { + throw new UniFiAccessApiException("GET failed for " + path + ": " + e.getMessage(), e); + } + } + + private ContentResponse execDelete(String path) throws UniFiAccessApiException { + try { + return newRequest(HttpMethod.DELETE, path, null).send(); + } catch (Exception e) { + throw new UniFiAccessApiException("DELETE failed for " + path + ": " + e.getMessage(), e); + } + } + + private ContentResponse execPost(String path, Object body) throws UniFiAccessApiException { + try { + return newRequest(HttpMethod.POST, path, req -> { + String json = toJson(body); + req.header(HttpHeader.CONTENT_TYPE, "application/json"); + req.content(new StringContentProvider(json, StandardCharsets.UTF_8)); + }).send(); + } catch (Exception e) { + throw new UniFiAccessApiException("POST failed for " + path + ": " + e.getMessage(), e); + } + } + + private ContentResponse execPut(String path, Object body) throws UniFiAccessApiException { + try { + return newRequest(HttpMethod.PUT, path, req -> { + String json = toJson(body); + req.header(HttpHeader.CONTENT_TYPE, "application/json"); + req.content(new StringContentProvider(json, StandardCharsets.UTF_8)); + }).send(); + } catch (Exception e) { + throw new UniFiAccessApiException("PUT failed for " + path + ": " + e.getMessage(), e); + } + } + + private URI toWebSocketUri(String relativePath) { + String scheme = "wss"; + if ("http".equalsIgnoreCase(base.getScheme())) { + scheme = "ws"; + } + String path = base.getPath(); + if (!path.endsWith("/")) { + path = path + "/"; + } + if (relativePath.startsWith("/")) { + relativePath = relativePath.substring(1); + } + String fullPath = path + relativePath; + return URI + .create(scheme + "://" + base.getHost() + (base.getPort() > 0 ? ":" + base.getPort() : "") + fullPath); + } + + private synchronized void startWsMonitor() { + logger.debug("Starting WS monitor"); + ScheduledFuture wsMonitorFuture = this.wsMonitorFuture; + if (wsMonitorFuture == null || wsMonitorFuture.isCancelled()) { + this.wsMonitorFuture = executorService.scheduleWithFixedDelay(() -> { + try { + Session s = wsSession; + if (s != null && s.isOpen()) { + long sinceMs = System.currentTimeMillis() - lastHeartbeatEpochMs; + if (sinceMs > 10_000L) { + logger.debug("Notifications heartbeat missing ({} ms). Reconnecting...", sinceMs); + try { + s.close(); + } catch (Exception e) { + logger.debug("Error closing notifications WebSocket", e); + } finally { + wsSession = null; + } + } + } else { + logger.debug("Notifications WebSocket not open"); + } + } catch (Exception e) { + logger.debug("WS monitor error: ", e); + } + }, 5, 5, TimeUnit.SECONDS); + } else { + logger.debug("WS monitor already running!"); + } + } + + private synchronized void stopWsMonitor() { + try { + ScheduledFuture f = wsMonitorFuture; + if (f != null) { + f.cancel(true); + wsMonitorFuture = null; + } + } catch (Exception ignored) { + } + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/config/UnifiAccessBridgeConfiguration.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/config/UnifiAccessBridgeConfiguration.java new file mode 100644 index 0000000000000..1ab8ecd538390 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/config/UnifiAccessBridgeConfiguration.java @@ -0,0 +1,26 @@ +/* + * 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link UnifiAccessBridgeConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Dan Cunningham - Initial contribution + */ +@NonNullByDefault +public class UnifiAccessBridgeConfiguration { + public String host = ""; + public String authToken = ""; +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/config/UnifiAccessDeviceConfiguration.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/config/UnifiAccessDeviceConfiguration.java new file mode 100644 index 0000000000000..b0d0a71bfd452 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/config/UnifiAccessDeviceConfiguration.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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link UnifiAccessDeviceConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Dan Cunningham - Initial contribution + */ +@NonNullByDefault +public class UnifiAccessDeviceConfiguration { + public String deviceId = ""; +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicy.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicy.java new file mode 100644 index 0000000000000..15bcffd9b23f6 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicy.java @@ -0,0 +1,42 @@ +/* + * 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; + +import com.google.gson.annotations.SerializedName; + +/** + * Access Policy model for UniFi Access API (Section 5.1). + * + * @author Dan Cunningham - Initial contribution + */ +public class AccessPolicy { + public String id; + public String name; + @SerializedName(value = "resources", alternate = { "resource" }) + public List resources; + public String scheduleId; + + public static class Resource { + public String id; + public ResourceType type; + } + + public enum ResourceType { + @SerializedName("door") + DOOR, + @SerializedName("door_group") + DOOR_GROUP + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicyHolidayGroup.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicyHolidayGroup.java new file mode 100644 index 0000000000000..b5befcacff718 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicyHolidayGroup.java @@ -0,0 +1,77 @@ +/* + * 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.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +/** + * Holiday Group for Access Policy Schedules. + * + * @author Dan Cunningham - Initial contribution + */ +public class AccessPolicyHolidayGroup { + public String id; + public String name; + public Boolean isDefault; + public String description; + public String templateName; + public List holidays; + + public boolean isHoliday(ZonedDateTime when) { + return activeHolidayAt(when) != null; + } + + public Holiday activeHolidayAt(ZonedDateTime when) { + Objects.requireNonNull(when, "when"); + if (holidays == null || holidays.isEmpty()) { + return null; + } + for (Holiday h : holidays) { + if (h != null && h.contains(when)) { + return h; + } + } + return null; + } + + public static class Holiday { + public String id; + public String name; + public String description; + public Boolean repeat; + public Boolean isTemplate; + public String startTime; + public String endTime; + + public Instant parsedStart() { + return UaTime.parseInstant(startTime); + } + + public Instant parsedEnd() { + return UaTime.parseInstant(endTime); + } + + public boolean contains(ZonedDateTime when) { + final Instant s = parsedStart(); + final Instant e = parsedEnd(); + if (s == null || e == null || when == null) { + return false; + } + final Instant x = when.toInstant(); + return !x.isBefore(s) && x.isBefore(e); // [start, end) + } + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicySchedule.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicySchedule.java new file mode 100644 index 0000000000000..2a1e7d526adb4 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/AccessPolicySchedule.java @@ -0,0 +1,104 @@ +/* + * 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.DayOfWeek; +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +/** + * Door Access Schedule for Access Policies. + * + * @author Dan Cunningham - Initial contribution + */ +public class AccessPolicySchedule { + public String id; + public Boolean isDefault; + public String name; + public String type; + @SerializedName(value = "weekly", alternate = { "week_schedule" }) + public WeekSchedule weekly; + public String holidayGroupId; + public AccessPolicyHolidayGroup holidayGroup; + public List holidaySchedule; + + public boolean allowsAt(ZonedDateTime when) { + Objects.requireNonNull(when, "when"); + // Holiday override + if (holidayGroup != null && holidayGroup.isHoliday(when)) { + if (holidaySchedule == null || holidaySchedule.isEmpty()) { + return false; + } + final LocalTime t = when.toLocalTime(); + for (TimeRange r : holidaySchedule) { + if (r != null && r.contains(t)) { + return true; + } + } + return false; + } + final WeekSchedule ws = weekly; + if (ws == null) { + return false; + } + final var ranges = ws.rangesFor(when.getDayOfWeek()); + if (ranges == null || ranges.isEmpty()) { + return false; + } + final LocalTime t = when.toLocalTime(); + for (TimeRange r : ranges) { + if (r != null && r.contains(t)) { + return true; + } + } + return false; + } + + 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 List rangesFor(DayOfWeek dow) { + return switch (dow) { + case MONDAY -> monday; + case TUESDAY -> tuesday; + case WEDNESDAY -> wednesday; + case THURSDAY -> thursday; + case FRIDAY -> friday; + case SATURDAY -> saturday; + case SUNDAY -> sunday; + }; + } + } + + public static class TimeRange { + public String startTime; + public String endTime; + + /** LocalTime membership test using {@link UaTime#within}. */ + public boolean contains(LocalTime t) { + final LocalTime s = UaTime.parseHhmmss(startTime); + final LocalTime e = UaTime.parseHhmmss(endTime); + return UaTime.within(t, s, e); + } + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/ApiResponse.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/ApiResponse.java new file mode 100644 index 0000000000000..0aa9d0aa1fd59 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/ApiResponse.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.Nullable; + +/** + * Generic API response wrapper. + * + * @author Dan Cunningham - Initial contribution + */ +public class ApiResponse { + public ApiResponseCode code; + public @Nullable T data; + public String msg; + + public boolean isSuccess() { + return code == ApiResponseCode.SUCCESS; + } + + public String messageOrEmpty() { + return msg == null ? "" : msg; + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/ApiResponseCode.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/ApiResponseCode.java new file mode 100644 index 0000000000000..7a10fcc9bbcb7 --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/ApiResponseCode.java @@ -0,0 +1,127 @@ +/* + * 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 com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +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; + +/** + * String enum for UniFi Access API response codes. + * + * @author Dan Cunningham - Initial contribution + */ +@JsonAdapter(ApiResponseCode.Adapter.class) +public enum ApiResponseCode { + SUCCESS("Success"), + + // Common errors + CODE_PARAMS_INVALID("The provided parameters are invalid."), + CODE_SYSTEM_ERROR("An error occurred on the server's end."), + CODE_RESOURCE_NOT_FOUND("The requested resource was not found."), + CODE_OPERATION_FORBIDDEN("The requested operation is not allowed."), + CODE_AUTH_FAILED("Authentication failed."), + CODE_ACCESS_TOKEN_INVALID("The provided access token is invalid."), + CODE_UNAUTHORIZED("You are not allowed to perform this action."), + CODE_NOT_EXISTS("The requested item does not exist."), + + // User and account related + CODE_USER_EMAIL_ERROR("The provided email format is invalid."), + CODE_USER_ACCOUNT_NOT_EXIST("The requested user account does not exist."), + CODE_USER_WORKER_NOT_EXISTS("The requested user does not exist."), + CODE_USER_NAME_DUPLICATED("The provided name already exists."), + CODE_USER_CSV_IMPORT_INCOMPLETE_PROP("Please provide both first name and last name."), + + // Access policy and schedule related + CODE_ACCESS_POLICY_USER_TIMEZONE_NOT_FOUND("The requested workday schedule could not be found."), + CODE_ACCESS_POLICY_HOLIDAY_TIMEZONE_NOT_FOUND("The requested holiday schedule could not be found."), + CODE_ACCESS_POLICY_HOLIDAY_GROUP_NOT_FOUND("The requested holiday group could not be found."), + CODE_ACCESS_POLICY_HOLIDAY_NOT_FOUND("The requested holiday could not be found."), + CODE_ACCESS_POLICY_SCHEDULE_NOT_FOUND("The requested schedule could not be found."), + CODE_ACCESS_POLICY_HOLIDAY_NAME_EXIST("The provided holiday name already exists."), + CODE_ACCESS_POLICY_HOLIDAY_GROUP_NAME_EXIST("The provided holiday group name already exists."), + CODE_ACCESS_POLICY_SCHEDULE_NAME_EXIST("The provided schedule name already exists."), + CODE_ACCESS_POLICY_SCHEDULE_CAN_NOT_DELETE("The schedule could not be deleted."), + CODE_ACCESS_POLICY_HOLIDAY_GROUP_CAN_NOT_DELETE("The holiday group could not be deleted."), + + // Credentials / NFC / PIN related + CODE_CREDS_NFC_HAS_BIND_USER("The NFC card is already registered and assigned to another user."), + CODE_CREDS_DISABLE_TRANSFER_UID_USER_NFC("The UniFi Identity Enterprise user's NFC card is not transferrable."), + CODE_CREDS_NFC_READ_SESSION_NOT_FOUND("Failed to obtain the NFC read session."), + CODE_CREDS_NFC_READ_POLL_TOKEN_EMPTY("The NFC token is empty."), + CODE_CREDS_NFC_CARD_IS_PROVISION("The NFC card is already registered at another site."), + CODE_CREDS_NFC_CARD_PROVISION_FAILED("Please hold the NFC card against the reader for more than 5 seconds."), + CODE_CREDS_NFC_CARD_INVALID("The card type is not supported. Please use a UA Card."), + CODE_CREDS_NFC_CARD_CANNOT_BE_DELETE("The NFC card could not be deleted."), + CODE_CREDS_PIN_CODE_CREDS_ALREADY_EXIST("The PIN code already exists."), + CODE_CREDS_PIN_CODE_CREDS_LENGTH_INVALID("The PIN code length does not meet the preset requirements."), + + // Space / Location / Device related + CODE_SPACE_DEVICE_BOUND_LOCATION_NOT_FOUND("The device's location was not found."), + CODE_DEVICE_DEVICE_VERSION_NOT_FOUND("The firmware version is up to date."), + CODE_DEVICE_DEVICE_VERSION_TOO_OLD("The firmware version is too old. Please update to the latest version."), + CODE_DEVICE_DEVICE_BUSY("The camera is currently in use."), + CODE_DEVICE_DEVICE_NOT_FOUND("The device was not found."), + CODE_DEVICE_DEVICE_OFFLINE("The device is currently offline."), + CODE_DEVICE_WEBHOOK_ENDPOINT_DUPLICATED("The provided endpoint already exists."), + CODE_DEVICE_API_NOT_SUPPORTED("The API is currently not available for this device."), + + // Others / migration related + CODE_OTHERS_UID_ADOPTED_NOT_SUPPORTED("The API is not available after upgrading to Identity Enterprise."), + + /** Unknown/Unrecognized code */ + UNKNOWN("Unknown"); + + private final String description; + + ApiResponseCode(String description) { + this.description = description; + } + + public String code() { + return name(); + } + + public String description() { + return description; + } + + public static final class Adapter implements JsonDeserializer, JsonSerializer { + @Override + public ApiResponseCode deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + try { + String value = json.getAsString(); + for (ApiResponseCode c : ApiResponseCode.values()) { + if (c.name().equalsIgnoreCase(value)) { + return c; + } + } + } catch (Exception ignored) { + } + return ApiResponseCode.UNKNOWN; + } + + @Override + public JsonElement serialize(ApiResponseCode src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.name()); + } + } +} diff --git a/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Device.java b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Device.java new file mode 100644 index 0000000000000..76b4dffda370a --- /dev/null +++ b/bundles/org.openhab.binding.unifiaccess/src/main/java/org/openhab/binding/unifiaccess/internal/dto/Device.java @@ -0,0 +1,33 @@ +/* + * 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; + +/** + * Device details + * + * @author Dan Cunningham - Initial contribution + */ +public class Device { + public String alias; + public String id; + public String name; + public String type; // e.g., "UAH", "UDA-LITE", "UA-G2-PRO" + public String locationId; // associated door/location id (for filtering) + public Boolean isAdopted; + public Boolean isConnected; + public Boolean isManaged; + public Boolean isOnline; + public List 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