[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 <vkolotoff@pm.me>
This commit is contained in:
Vlad Kolotov
2026-06-08 17:47:32 +02:00
committed by GitHub
parent f09e4a114f
commit 6839c31fcd
7 changed files with 240 additions and 1 deletions
@@ -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.BlueZEventListener;
import org.openhab.binding.bluetooth.bluez.internal.events.CharacteristicUpdateEvent; 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.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.ManufacturerDataEvent;
import org.openhab.binding.bluetooth.bluez.internal.events.NameEvent; import org.openhab.binding.bluetooth.bluez.internal.events.NameEvent;
import org.openhab.binding.bluetooth.bluez.internal.events.RssiEvent; 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())) { if (Boolean.TRUE.equals(blueZDevice.isConnected())) {
setConnectionState(ConnectionState.CONNECTED); 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(); 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 @Override
public boolean connect() { public boolean connect() {
logger.debug("Connect({})", device); logger.debug("Connect({})", device);
@@ -350,7 +386,14 @@ public class BlueZBluetoothDevice extends BaseBluetoothDevice implements BlueZEv
@Override @Override
public void onServicesResolved(ServicesResolvedEvent event) { public void onServicesResolved(ServicesResolvedEvent event) {
if (event.isResolved()) { 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();
} }
} }
@@ -18,6 +18,7 @@ import java.util.Map;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.bluez.exceptions.BluezFailedException; import org.bluez.exceptions.BluezFailedException;
import org.bluez.exceptions.BluezInvalidArgumentsException; 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.BluetoothAddress;
import org.openhab.binding.bluetooth.bluez.internal.events.AdapterDiscoveringChangedEvent; 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.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.BlueZEvent;
import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEventListener; 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.Bridge;
import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.ThingStatusDetail;
@@ -68,6 +71,11 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
private boolean lazyScan; private boolean lazyScan;
private final Consumer<String> deviceRemovedListener = this::onDeviceRemoved;
private final Consumer<String> adapterRemovedListener = this::onAdapterRemoved;
private boolean removalListenersRegistered;
private @Nullable ScheduledFuture<?> discoveryJob; private @Nullable ScheduledFuture<?> discoveryJob;
private final DeviceManagerFactory deviceManagerFactory; private final DeviceManagerFactory deviceManagerFactory;
@@ -107,6 +115,12 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
@Override @Override
public void dispose() { public void dispose() {
deviceManagerFactory.getPropertiesChangedHandler().removeListener(this); deviceManagerFactory.getPropertiesChangedHandler().removeListener(this);
DeviceManagerWrapper deviceManager = deviceManagerFactory.getDeviceManager();
if (deviceManager != null && removalListenersRegistered) {
deviceManager.unregisterDeviceRemovedListener(deviceRemovedListener);
deviceManager.unregisterAdapterRemovedListener(adapterRemovedListener);
removalListenersRegistered = false;
}
logger.debug("Termination of DBus BlueZ handler"); logger.debug("Termination of DBus BlueZ handler");
Future<?> job = discoveryJob; Future<?> job = discoveryJob;
@@ -185,6 +199,12 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
deviceManager.setLazyScan(this.lazyScan); deviceManager.setLazyScan(this.lazyScan);
if (!removalListenersRegistered) {
deviceManager.registerDeviceRemovedListener(deviceRemovedListener);
deviceManager.registerAdapterRemovedListener(adapterRemovedListener);
removalListenersRegistered = true;
}
BluetoothAdapter localAdapter = prepareAdapter(deviceManager); BluetoothAdapter localAdapter = prepareAdapter(deviceManager);
if (localAdapter == null) { if (localAdapter == null) {
// adapter isn't prepared yet // adapter isn't prepared yet
@@ -232,6 +252,33 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
} }
} }
/**
* Invoked (via {@link DeviceManagerWrapper#registerDeviceRemovedListener}) when BlueZ removes a
* device object, with that device's DBus object path. A removal means any connection to the
* device is gone and its cached GATT state is invalid — but the {@code Connected=false}
* PropertiesChanged signal can be missed under continuous discovery, leaving the device's handler
* stuck believing it is still connected. Wrap it as a {@link DeviceRemovedEvent} and route it
* through the same adapter-filtered dispatch as every other BlueZ event, so the matching
* {@link BlueZBluetoothDevice} resets its state and the reconnect job can recover.
*
* @param dbusPath the removed device's DBus object path, e.g. {@code /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF}
*/
private void onDeviceRemoved(String dbusPath) {
onDBusBlueZEvent(new DeviceRemovedEvent(dbusPath));
}
/**
* Invoked (via {@link DeviceManagerWrapper#registerAdapterRemovedListener}) when BlueZ removes an
* adapter object, with that adapter's DBus object path (e.g. {@code /org/bluez/hci0}) — typically
* a USB BT dongle being unplugged. Wrap it as an {@link AdapterRemovedEvent} and route it through
* the same adapter-filtered dispatch as every other BlueZ event.
*
* @param dbusPath the removed adapter's DBus object path
*/
private void onAdapterRemoved(String dbusPath) {
onDBusBlueZEvent(new AdapterRemovedEvent(dbusPath));
}
@Override @Override
public @Nullable BluetoothAddress getAddress() { public @Nullable BluetoothAddress getAddress() {
return adapterAddress; return adapterAddress;
@@ -264,9 +311,25 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
// now lets forward the event to the corresponding bluetooth device // now lets forward the event to the corresponding bluetooth device
BlueZBluetoothDevice device = getDevice(address); BlueZBluetoothDevice device = getDevice(address);
event.dispatch(device); event.dispatch(device);
} else {
// adapter-scoped event (no device in the path), e.g. adapter removed - handle it here
event.dispatch(this);
} }
} }
@Override
public void onAdapterRemoved(AdapterRemovedEvent event) {
logger.debug("BlueZ removed our adapter {}; resetting adapter and all of its devices", adapterAddress);
// Drop the cached adapter proxy so the next refresh re-resolves a live one when the adapter
// (e.g. a USB dongle) reappears - reusing a stale proxy is the core adapter-staleness bug.
this.adapter = null;
// Cascade the reset to every device under this adapter. BlueZ does not reliably emit a
// per-device InterfacesRemoved before removing the adapter, so the devices would otherwise
// stay stuck believing they are still connected.
forEachDevice(BlueZBluetoothDevice::resetForRemoval);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Adapter removed");
}
@Override @Override
public void onDiscoveringChanged(AdapterDiscoveringChangedEvent event) { public void onDiscoveringChanged(AdapterDiscoveringChangedEvent event) {
// do nothing for now // do nothing for now
@@ -15,6 +15,7 @@ package org.openhab.binding.bluetooth.bluez.internal;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
@@ -80,4 +81,42 @@ public class DeviceManagerWrapper {
deviceManager.setLazyScan(lazyScan); deviceManager.setLazyScan(lazyScan);
} }
} }
/**
* Registers a listener invoked with a device's DBus object path when BlueZ removes that device
* object (ObjectManager InterfacesRemoved). Lets the binding invalidate its own cached state for
* a device when the underlying BlueZ object disappears.
*/
public synchronized void registerDeviceRemovedListener(Consumer<String> 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<String> listener) {
DeviceManager devMgr = deviceManager;
if (devMgr != null) {
devMgr.registerAdapterRemovedListener(listener);
}
}
public synchronized void unregisterDeviceRemovedListener(Consumer<String> listener) {
DeviceManager devMgr = deviceManager;
if (devMgr != null) {
devMgr.unregisterDeviceRemovedListener(listener);
}
}
public synchronized void unregisterAdapterRemovedListener(Consumer<String> listener) {
DeviceManager devMgr = deviceManager;
if (devMgr != null) {
devMgr.unregisterAdapterRemovedListener(listener);
}
}
} }
@@ -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);
}
}
@@ -64,4 +64,12 @@ public interface BlueZEventListener {
default void onServicesResolved(ServicesResolvedEvent event) { default void onServicesResolved(ServicesResolvedEvent event) {
onDBusBlueZEvent(event); onDBusBlueZEvent(event);
} }
default void onDeviceRemoved(DeviceRemovedEvent event) {
onDBusBlueZEvent(event);
}
default void onAdapterRemoved(AdapterRemovedEvent event) {
onDBusBlueZEvent(event);
}
} }
@@ -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);
}
}
@@ -20,6 +20,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
@@ -133,6 +134,20 @@ public abstract class AbstractBluetoothBridgeHandler<BD extends BaseBluetoothDev
discoveryListeners.forEach(listener -> listener.deviceRemoved(device)); discoveryListeners.forEach(listener -> 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<BD> action) {
synchronized (devices) {
devices.values().forEach(action);
}
}
private boolean shouldRemove(BD device) { private boolean shouldRemove(BD device) {
// we can't remove devices with listeners since that means they have a handler. // we can't remove devices with listeners since that means they have a handler.
if (device.hasListeners()) { if (device.hasListeners()) {