Skip to content

Commit 6796bd8

Browse files
authored
[unifi] Handle Protect/Access Thing Lifecycle correctly (#21346)
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
1 parent 2e64b55 commit 6796bd8

16 files changed

Lines changed: 441 additions & 64 deletions

bundles/org.openhab.binding.unifi/src/main/java/org/openhab/binding/unifi/internal/access/api/UnifiAccessApiClient.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,23 @@ public synchronized void close() {
158158
private volatile long bootstrapCacheTimeMs;
159159
private static final long BOOTSTRAP_CACHE_TTL_MS = 30_000; // 30 seconds
160160

161+
/**
162+
* True when the topology contains the floors section the doors/devices lists derive from;
163+
* an absent section is an incomplete response, not an empty site.
164+
*/
165+
public boolean isTopologyAuthoritative() throws UnifiAccessApiException {
166+
JsonElement floors = getBootstrap().get("floors");
167+
return floors != null && floors.isJsonArray();
168+
}
169+
170+
/**
171+
* Drop the cached bootstrap so the next sync fetches fresh topology, e.g. after a WebSocket
172+
* event reports a device was deleted.
173+
*/
174+
public synchronized void invalidateBootstrapCache() {
175+
this.cachedBootstrap = null;
176+
}
177+
161178
/**
162179
* Fetches the bootstrap topology from the v2 API.
163180
* Caches the result for 30 seconds to avoid redundant calls during a sync cycle.

bundles/org.openhab.binding.unifi/src/main/java/org/openhab/binding/unifi/internal/access/handler/UnifiAccessBaseHandler.java

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ void setOnline() {
7373
updateStatus(ThingStatus.ONLINE);
7474
}
7575

76+
/**
77+
* Clears a GONE status when an authoritative topology proves the device exists again;
78+
* connectivity handling then refines UNKNOWN to ONLINE/OFFLINE as information arrives.
79+
*/
80+
void clearGone() {
81+
if (getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.GONE) {
82+
updateStatus(ThingStatus.UNKNOWN);
83+
}
84+
}
85+
7686
protected void refreshState(String channelId) {
7787
State state = stateCache.get(channelId);
7888
if (state != null) {
@@ -97,17 +107,28 @@ protected void refreshState(String channelId) {
97107
protected void handleDeviceUpdate(DeviceUpdateData updateData) {
98108
updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_ONLINE,
99109
updateData.isConnected ? OnOffType.ON : OnOffType.OFF);
100-
if (!updateData.isConnected) {
101-
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
102-
"@text/offline.device-offline");
103-
}
110+
applyOnlineState(updateData.isConnected);
104111
}
105112

106113
protected void handleDeviceUpdateV2(Notification.DeviceUpdateV2Data updateData) {
114+
// A partial update without the online field says nothing about connectivity
107115
Boolean online = updateData.online;
108-
updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_ONLINE,
109-
Boolean.TRUE.equals(online) ? OnOffType.ON : OnOffType.OFF);
110-
if (!Boolean.TRUE.equals(online)) {
116+
if (online == null) {
117+
return;
118+
}
119+
updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_ONLINE, online ? OnOffType.ON : OnOffType.OFF);
120+
applyOnlineState(online);
121+
}
122+
123+
private void applyOnlineState(boolean online) {
124+
if (getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.GONE) {
125+
return;
126+
}
127+
if (online) {
128+
if (getThing().getStatus() != ThingStatus.ONLINE) {
129+
updateStatus(ThingStatus.ONLINE);
130+
}
131+
} else {
111132
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
112133
"@text/offline.device-offline");
113134
}

bundles/org.openhab.binding.unifi/src/main/java/org/openhab/binding/unifi/internal/access/handler/UnifiAccessBridgeHandler.java

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
package org.openhab.binding.unifi.internal.access.handler;
1414

1515
import java.util.Collection;
16+
import java.util.HashMap;
17+
import java.util.HashSet;
1618
import java.util.List;
1719
import java.util.Map;
20+
import java.util.Set;
1821
import java.util.concurrent.ConcurrentHashMap;
1922
import java.util.concurrent.ExecutionException;
2023
import java.util.concurrent.RejectedExecutionException;
@@ -363,11 +366,21 @@ synchronized void syncDevices() {
363366
discoveryService.discoverDevices(discoveryDevices);
364367
}
365368
logger.trace("Polled UniFi Access: {} doors, {} devices", doors.size(), devices.size());
369+
Map<String, Device> devicesById = new HashMap<>();
370+
for (Device device : devices) {
371+
String id = device.id;
372+
if (id != null) {
373+
devicesById.put(id, device);
374+
}
375+
}
366376
for (Door door : doors) {
367377
logger.trace("Checking door: {}", door.id);
368378
UnifiAccessDoorHandler dh = getDoorHandler(door.id);
369379
if (dh != null) {
370380
logger.trace("Updating door: {}", dh.deviceId);
381+
// Presence in the topology clears GONE; connectivity is judged separately
382+
dh.clearGone();
383+
setDoorStatus(dh, door, devices, devicesById);
371384
dh.updateFromDoor(door);
372385
}
373386
}
@@ -380,15 +393,20 @@ synchronized void syncDevices() {
380393
if (dh != null) {
381394
logger.debug("Syncing device {} (type={}, online={}, locationId={})", device.id, device.type,
382395
device.isOnline, device.locationId);
383-
// Set online/offline based on device status, independent of settings
384-
boolean online = !Boolean.FALSE.equals(device.isOnline);
385-
dh.updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_ONLINE,
386-
online ? OnOffType.ON : OnOffType.OFF);
387-
if (!online) {
388-
dh.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
389-
"@text/offline.device-offline");
390-
} else if (dh.getThing().getStatus() != ThingStatus.ONLINE) {
391-
dh.setOnline();
396+
// Presence in the topology clears GONE; connectivity is judged separately
397+
dh.clearGone();
398+
// Set online/offline based on device status, independent of settings;
399+
// null means the API reported nothing, so the status is left unchanged
400+
Boolean online = device.isOnline;
401+
if (online != null) {
402+
dh.updateState(UnifiAccessBindingConstants.CHANNEL_DEVICE_ONLINE,
403+
online ? OnOffType.ON : OnOffType.OFF);
404+
if (!online) {
405+
dh.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
406+
"@text/offline.device-offline");
407+
} else if (dh.getThing().getStatus() != ThingStatus.ONLINE) {
408+
dh.setOnline();
409+
}
392410
}
393411
// Update thing properties from device metadata
394412
dh.updateDeviceProperties(device);
@@ -405,6 +423,12 @@ synchronized void syncDevices() {
405423
}
406424
}
407425
}
426+
// An incomplete topology response must not mark things GONE
427+
if (client.isTopologyAuthoritative()) {
428+
markMissingChildrenGone(doors, devices);
429+
} else {
430+
logger.debug("Topology response incomplete, skipping gone reconciliation");
431+
}
408432
} catch (UnifiAccessApiException e) {
409433
logger.debug("Polling error: {}", e.getMessage());
410434
if (e.getAuthState() == AuthState.REJECTED) {
@@ -416,6 +440,82 @@ synchronized void syncDevices() {
416440
}
417441
}
418442

443+
/**
444+
* A door is only readable and commandable through its hub, so its status follows the hub:
445+
* hub offline -> door offline, no hub bound at all -> the door can neither report state nor
446+
* take commands, and a hub we cannot resolve -> leave the status alone.
447+
*/
448+
private void setDoorStatus(UnifiAccessDoorHandler dh, Door door, List<Device> devices,
449+
Map<String, Device> devicesById) {
450+
Device hub = door.hubDeviceId != null ? devicesById.get(door.hubDeviceId) : null;
451+
if (hub == null) {
452+
hub = devices.stream().filter(d -> d.isHub() && door.id != null && door.id.equals(d.locationId)).findFirst()
453+
.orElse(null);
454+
}
455+
var info = dh.getThing().getStatusInfo();
456+
if (hub != null) {
457+
if (Boolean.FALSE.equals(hub.isOnline)) {
458+
// Compare detail too, so a stale GONE or no hub message is corrected
459+
if (info.getStatus() != ThingStatus.OFFLINE
460+
|| info.getStatusDetail() != ThingStatusDetail.COMMUNICATION_ERROR) {
461+
dh.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
462+
"@text/offline.hub-offline");
463+
}
464+
} else if (Boolean.TRUE.equals(hub.isOnline)) {
465+
if (info.getStatus() != ThingStatus.ONLINE) {
466+
dh.setOnline();
467+
}
468+
}
469+
// isOnline == null: the topology omitted the hub's connectivity, say nothing
470+
} else if (door.doorLockRelayStatus != null || door.doorPositionStatus != null) {
471+
// No resolvable hub, but lock/position state is populated so something serves the
472+
// door (e.g. a building level hub extension we can't link to a device)
473+
if (info.getStatus() != ThingStatus.ONLINE) {
474+
dh.setOnline();
475+
}
476+
} else if (Boolean.TRUE.equals(door.isBindHub)) {
477+
// Devices exist (isBindHub is derived from a non-empty device group) but none
478+
// resolve as the hub and no state is reported; don't guess either way
479+
logger.debug("Door {} has devices but no resolvable hub; leaving status unchanged", door.id);
480+
} else if (info.getStatusDetail() != ThingStatusDetail.CONFIGURATION_ERROR) {
481+
// No devices and no state at all so the door can't report anything or take commands
482+
dh.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/offline.no-hub");
483+
}
484+
}
485+
486+
/**
487+
* Mark child things GONE when their door/device is absent from the topology — removed from
488+
* Access, so no update will ever arrive for it again.
489+
*/
490+
private void markMissingChildrenGone(List<Door> doors, List<Device> devices) {
491+
Set<String> doorIds = new HashSet<>();
492+
doors.forEach(d -> {
493+
String id = d.id;
494+
if (id != null) {
495+
doorIds.add(id);
496+
}
497+
});
498+
Set<String> deviceIds = new HashSet<>();
499+
devices.forEach(d -> {
500+
String id = d.id;
501+
if (id != null) {
502+
deviceIds.add(id);
503+
}
504+
});
505+
for (Thing child : getThing().getThings()) {
506+
if (!(child.getHandler() instanceof UnifiAccessBaseHandler handler) || handler.deviceId.isEmpty()) {
507+
continue;
508+
}
509+
Set<String> knownIds = handler instanceof UnifiAccessDoorHandler ? doorIds : deviceIds;
510+
if (knownIds.contains(handler.deviceId)
511+
|| child.getStatusInfo().getStatusDetail() == ThingStatusDetail.GONE) {
512+
continue;
513+
}
514+
logger.debug("Device {} not present on controller, marking {} gone", handler.deviceId, child.getUID());
515+
handler.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/offline.access-gone");
516+
}
517+
}
518+
419519
public @Nullable UnifiAccessApiClient getApiClient() {
420520
return apiClient;
421521
}

bundles/org.openhab.binding.unifi/src/main/java/org/openhab/binding/unifi/internal/access/handler/UnifiAccessDeviceHandler.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,8 @@ protected void handleLocationState(LocationState locationState) {
127127
}
128128

129129
protected void updateFromSettings(DeviceAccessMethodSettings settings) {
130-
if (getThing().getStatus() != ThingStatus.ONLINE) {
131-
updateStatus(ThingStatus.ONLINE);
132-
}
130+
// No status change here: the bridge poll sets ONLINE/OFFLINE from the device's reported
131+
// isOnline, and settings arrive for offline devices too
133132
updateEnabledChannel(CHANNEL_DEVICE_NFC_ENABLED, settings.nfc);
134133
updateEnabledChannel(CHANNEL_DEVICE_PIN_ENABLED, settings.pinCode);
135134
updateEnabledChannel(CHANNEL_DEVICE_FACE_ENABLED, settings.face);

bundles/org.openhab.binding.unifi/src/main/java/org/openhab/binding/unifi/internal/access/handler/UnifiAccessDoorHandler.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,16 +226,19 @@ protected void handleDeviceUpdateV2(Notification.DeviceUpdateV2Data updateData)
226226
updateData.locationStates.stream().filter(locationState -> locationState.locationId.equals(door.id))
227227
.findFirst().ifPresent(this::handleLocationState);
228228
}
229-
super.handleDeviceUpdateV2(updateData);
229+
// A door's online status follows its hub only; these updates are routed by location, so
230+
// another device at this door (e.g. a reader) must not flip the door's status
231+
String hubDeviceId = door.hubDeviceId;
232+
if (hubDeviceId != null && hubDeviceId.equals(updateData.id)) {
233+
super.handleDeviceUpdateV2(updateData);
234+
}
230235
}
231236

232237
// Door specific update methods
233238
protected void updateFromDoor(Door door) {
234239
logger.debug("Updating door state from door: {}", door);
235240
this.door = door;
236-
if (getThing().getStatus() != ThingStatus.ONLINE) {
237-
updateStatus(ThingStatus.ONLINE);
238-
}
241+
// No status change here: the bridge poll sets ONLINE/OFFLINE from the door's hub state
239242
// Enrich thing properties
240243
Map<String, String> properties = new HashMap<>(editProperties());
241244
if (door.fullName != null) {

bundles/org.openhab.binding.unifi/src/main/java/org/openhab/binding/unifi/internal/access/handler/UnifiAccessNotificationRouter.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,16 @@ private void handleDeviceUpdateV2(DeviceUpdateV2Data updateData) {
298298
private void handleDeviceDeleteEvent(Notification notification) {
299299
String deviceId = notification.eventObjectId;
300300
if (deviceId != null) {
301+
// The topology changed; a cached bootstrap would resurrect the deleted device on
302+
// the next sync within the cache TTL
303+
var apiClient = bridgeHandler.getApiClient();
304+
if (apiClient != null) {
305+
apiClient.invalidateBootstrapCache();
306+
}
301307
UnifiAccessBaseHandler bh = bridgeHandler.getBaseHandler(deviceId);
302308
if (bh != null) {
303309
bh.updateStatus(org.openhab.core.thing.ThingStatus.OFFLINE,
304-
org.openhab.core.thing.ThingStatusDetail.GONE, "Device removed from controller");
310+
org.openhab.core.thing.ThingStatusDetail.GONE, "@text/offline.access-gone");
305311
}
306312
}
307313
}

bundles/org.openhab.binding.unifi/src/main/java/org/openhab/binding/unifi/internal/protect/api/priv/client/UniFiProtectPrivateClient.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ public CompletableFuture<Bootstrap> getBootstrap() {
185185
});
186186
}
187187

188+
/**
189+
* Drop the cached bootstrap so the next fetch is fresh — used when a WebSocket remove
190+
* reports a topology change the cached copy cannot reflect.
191+
*/
192+
public void invalidateBootstrap() {
193+
lastBootstrapRefresh = null;
194+
}
195+
188196
/**
189197
* Force refresh of bootstrap
190198
*/

0 commit comments

Comments
 (0)