From 6839c31fcd8fbcb5073710be854bcdaf85915518 Mon Sep 17 00:00:00 2001 From: Vlad Kolotov Date: Tue, 9 Jun 2026 03:47:32 +1200 Subject: [PATCH] [bluetooth] Recover BlueZ devices after object removal (#20903) * [bluetooth] Recover BlueZ devices after object removal Reset cached adapter and device state when BlueZ removes their DBus objects, and populate GATT services before notifying listeners. This allows always-connected devices to reconnect after device removal or adapter unplug/replug. Signed-off-by: Vlad Kolotoff --- .../bluez/internal/BlueZBluetoothDevice.java | 45 ++++++++++++- .../bluez/internal/BlueZBridgeHandler.java | 63 +++++++++++++++++++ .../bluez/internal/DeviceManagerWrapper.java | 39 ++++++++++++ .../internal/events/AdapterRemovedEvent.java | 36 +++++++++++ .../internal/events/BlueZEventListener.java | 8 +++ .../internal/events/DeviceRemovedEvent.java | 35 +++++++++++ .../AbstractBluetoothBridgeHandler.java | 15 +++++ 7 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/AdapterRemovedEvent.java create mode 100644 bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/DeviceRemovedEvent.java diff --git a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBluetoothDevice.java b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBluetoothDevice.java index 3922d38da2..9843c96aeb 100644 --- a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBluetoothDevice.java +++ b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBluetoothDevice.java @@ -36,6 +36,7 @@ import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEvent; import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEventListener; import org.openhab.binding.bluetooth.bluez.internal.events.CharacteristicUpdateEvent; import org.openhab.binding.bluetooth.bluez.internal.events.ConnectedEvent; +import org.openhab.binding.bluetooth.bluez.internal.events.DeviceRemovedEvent; import org.openhab.binding.bluetooth.bluez.internal.events.ManufacturerDataEvent; import org.openhab.binding.bluetooth.bluez.internal.events.NameEvent; import org.openhab.binding.bluetooth.bluez.internal.events.RssiEvent; @@ -127,6 +128,12 @@ public class BlueZBluetoothDevice extends BaseBluetoothDevice implements BlueZEv if (Boolean.TRUE.equals(blueZDevice.isConnected())) { setConnectionState(ConnectionState.CONNECTED); + } else { + // A fresh device object while we are not connected means the device (re)appeared and is + // available but not in a connection. Notify DISCOVERED so the handler can connect to it + // proactively (for alwaysConnected things) instead of waiting for the next reconnect poll. + // This mirrors how the BlueGiga binding signals discovery. + setConnectionState(ConnectionState.DISCOVERED); } discoverServices(); @@ -169,6 +176,35 @@ public class BlueZBluetoothDevice extends BaseBluetoothDevice implements BlueZEv } } + /** + * Called when BlueZ removes this device's object (ObjectManager InterfacesRemoved). The removal + * means the connection is gone and any cached GATT services belong to a dead object; because the + * {@code Connected=false} signal can be missed under continuous discovery, the cached state could + * otherwise stay {@code CONNECTED} and the reconnect job would never reconnect. Drop the cached + * device, clear services, and mark disconnected so a clean reconnect can happen once the device + * reappears (a fresh object then arrives via {@link #updateBlueZDevice}, which notifies + * {@code DISCOVERED} and triggers a proactive reconnect). + */ + @Override + public synchronized void onDeviceRemoved(DeviceRemovedEvent event) { + logger.debug("BlueZ removed device object for {}; clearing cached state to allow reconnect", address); + resetForRemoval(); + } + + /** + * Drops the cached BlueZ device object and GATT services and marks the device disconnected, so a + * clean reconnect can happen once it reappears (a fresh object then arrives via + * {@link #updateBlueZDevice}, which notifies {@code DISCOVERED} and triggers a proactive + * reconnect). Called both when BlueZ removes this device object directly and when the adapter it + * lives under is removed (the bridge cascades the reset to all of its devices, since BlueZ does + * not reliably emit a per-device removal before removing the adapter). + */ + synchronized void resetForRemoval() { + this.device = null; + supportedServices.clear(); + setConnectionState(ConnectionState.DISCONNECTED); + } + @Override public boolean connect() { logger.debug("Connect({})", device); @@ -350,7 +386,14 @@ public class BlueZBluetoothDevice extends BaseBluetoothDevice implements BlueZEv @Override public void onServicesResolved(ServicesResolvedEvent event) { if (event.isResolved()) { - notifyListeners(BluetoothEventType.SERVICES_DISCOVERED); + // Populate our service/characteristic list from the now-resolved GATT before notifying + // listeners. BlueZ can deliver ServicesResolved=true while our own supportedServices list + // is still empty (e.g. right after a reconnect, before discoverServices() has run); firing + // SERVICES_DISCOVERED then makes listeners (e.g. the generic handler's channel builder) run + // against an empty list and miss every characteristic. discoverServices() populates the + // list and fires SERVICES_DISCOVERED itself once it has added services, so it is the + // correct trigger here. If the services are already known it is a cheap no-op. + discoverServices(); } } diff --git a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBridgeHandler.java b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBridgeHandler.java index c323400865..461ac40409 100644 --- a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBridgeHandler.java +++ b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBridgeHandler.java @@ -18,6 +18,7 @@ import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import org.bluez.exceptions.BluezFailedException; import org.bluez.exceptions.BluezInvalidArgumentsException; @@ -31,8 +32,10 @@ import org.openhab.binding.bluetooth.AbstractBluetoothBridgeHandler; import org.openhab.binding.bluetooth.BluetoothAddress; import org.openhab.binding.bluetooth.bluez.internal.events.AdapterDiscoveringChangedEvent; import org.openhab.binding.bluetooth.bluez.internal.events.AdapterPoweredChangedEvent; +import org.openhab.binding.bluetooth.bluez.internal.events.AdapterRemovedEvent; import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEvent; import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEventListener; +import org.openhab.binding.bluetooth.bluez.internal.events.DeviceRemovedEvent; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; @@ -68,6 +71,11 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler deviceRemovedListener = this::onDeviceRemoved; + private final Consumer adapterRemovedListener = this::onAdapterRemoved; + + private boolean removalListenersRegistered; + private @Nullable ScheduledFuture discoveryJob; private final DeviceManagerFactory deviceManagerFactory; @@ -107,6 +115,12 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler job = discoveryJob; @@ -185,6 +199,12 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler listener) { + DeviceManager devMgr = deviceManager; + if (devMgr != null) { + devMgr.registerDeviceRemovedListener(listener); + } + } + + /** + * Registers a listener invoked with an adapter's DBus object path when BlueZ removes that adapter + * object (ObjectManager InterfacesRemoved), e.g. a USB dongle being unplugged. Lets the binding + * invalidate its cached adapter proxy and the devices found through it. + */ + public synchronized void registerAdapterRemovedListener(Consumer listener) { + DeviceManager devMgr = deviceManager; + if (devMgr != null) { + devMgr.registerAdapterRemovedListener(listener); + } + } + + public synchronized void unregisterDeviceRemovedListener(Consumer listener) { + DeviceManager devMgr = deviceManager; + if (devMgr != null) { + devMgr.unregisterDeviceRemovedListener(listener); + } + } + + public synchronized void unregisterAdapterRemovedListener(Consumer listener) { + DeviceManager devMgr = deviceManager; + if (devMgr != null) { + devMgr.unregisterAdapterRemovedListener(listener); + } + } } diff --git a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/AdapterRemovedEvent.java b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/AdapterRemovedEvent.java new file mode 100644 index 0000000000..671770e9c1 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/AdapterRemovedEvent.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2026 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.bluetooth.bluez.internal.events; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * This is triggered when BlueZ removes an adapter object (ObjectManager InterfacesRemoved), e.g. a + * USB Bluetooth dongle being unplugged. Any cached adapter proxy and the devices found through it + * are then stale and must be invalidated. + * + * @author Vlad Kolotov - Initial Contribution + * + */ +@NonNullByDefault +public class AdapterRemovedEvent extends BlueZEvent { + + public AdapterRemovedEvent(String dbusPath) { + super(dbusPath); + } + + @Override + public void dispatch(BlueZEventListener listener) { + listener.onAdapterRemoved(this); + } +} diff --git a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/BlueZEventListener.java b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/BlueZEventListener.java index a30d2ac807..1052813238 100644 --- a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/BlueZEventListener.java +++ b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/BlueZEventListener.java @@ -64,4 +64,12 @@ public interface BlueZEventListener { default void onServicesResolved(ServicesResolvedEvent event) { onDBusBlueZEvent(event); } + + default void onDeviceRemoved(DeviceRemovedEvent event) { + onDBusBlueZEvent(event); + } + + default void onAdapterRemoved(AdapterRemovedEvent event) { + onDBusBlueZEvent(event); + } } diff --git a/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/DeviceRemovedEvent.java b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/DeviceRemovedEvent.java new file mode 100644 index 0000000000..d960a6ebcc --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/DeviceRemovedEvent.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2010-2026 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.bluetooth.bluez.internal.events; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * This is triggered when BlueZ removes a device object (ObjectManager InterfacesRemoved), meaning + * any connection to the device is gone and its cached GATT state is no longer valid. + * + * @author Vlad Kolotov - Initial Contribution + * + */ +@NonNullByDefault +public class DeviceRemovedEvent extends BlueZEvent { + + public DeviceRemovedEvent(String dbusPath) { + super(dbusPath); + } + + @Override + public void dispatch(BlueZEventListener listener) { + listener.onDeviceRemoved(this); + } +} diff --git a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/AbstractBluetoothBridgeHandler.java b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/AbstractBluetoothBridgeHandler.java index 58d1e3394b..b4879d6072 100644 --- a/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/AbstractBluetoothBridgeHandler.java +++ b/bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/AbstractBluetoothBridgeHandler.java @@ -20,6 +20,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -133,6 +134,20 @@ public abstract class AbstractBluetoothBridgeHandler listener.deviceRemoved(device)); } + /** + * Performs the given action on every device currently known to this bridge. Subclasses use this + * to propagate adapter-wide events (e.g. the adapter going away) to all of their devices without + * needing their own copy of the device registry. The action runs while holding the device lock, + * so it should be quick and must not call back into device-registry-mutating methods. + * + * @param action the action to perform on each known device + */ + protected void forEachDevice(Consumer action) { + synchronized (devices) { + devices.values().forEach(action); + } + } + private boolean shouldRemove(BD device) { // we can't remove devices with listeners since that means they have a handler. if (device.hasListeners()) {