Skip to content

Commit 6839c31

Browse files
authored
[bluetooth] Recover BlueZ devices after object removal (openhab#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>
1 parent f09e4a1 commit 6839c31

7 files changed

Lines changed: 240 additions & 1 deletion

File tree

bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBluetoothDevice.java

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEventListener;
3737
import org.openhab.binding.bluetooth.bluez.internal.events.CharacteristicUpdateEvent;
3838
import org.openhab.binding.bluetooth.bluez.internal.events.ConnectedEvent;
39+
import org.openhab.binding.bluetooth.bluez.internal.events.DeviceRemovedEvent;
3940
import org.openhab.binding.bluetooth.bluez.internal.events.ManufacturerDataEvent;
4041
import org.openhab.binding.bluetooth.bluez.internal.events.NameEvent;
4142
import org.openhab.binding.bluetooth.bluez.internal.events.RssiEvent;
@@ -127,6 +128,12 @@ public synchronized void updateBlueZDevice(@Nullable BluetoothDevice blueZDevice
127128

128129
if (Boolean.TRUE.equals(blueZDevice.isConnected())) {
129130
setConnectionState(ConnectionState.CONNECTED);
131+
} else {
132+
// A fresh device object while we are not connected means the device (re)appeared and is
133+
// available but not in a connection. Notify DISCOVERED so the handler can connect to it
134+
// proactively (for alwaysConnected things) instead of waiting for the next reconnect poll.
135+
// This mirrors how the BlueGiga binding signals discovery.
136+
setConnectionState(ConnectionState.DISCOVERED);
130137
}
131138

132139
discoverServices();
@@ -169,6 +176,35 @@ private void setConnectionState(ConnectionState state) {
169176
}
170177
}
171178

179+
/**
180+
* Called when BlueZ removes this device's object (ObjectManager InterfacesRemoved). The removal
181+
* means the connection is gone and any cached GATT services belong to a dead object; because the
182+
* {@code Connected=false} signal can be missed under continuous discovery, the cached state could
183+
* otherwise stay {@code CONNECTED} and the reconnect job would never reconnect. Drop the cached
184+
* device, clear services, and mark disconnected so a clean reconnect can happen once the device
185+
* reappears (a fresh object then arrives via {@link #updateBlueZDevice}, which notifies
186+
* {@code DISCOVERED} and triggers a proactive reconnect).
187+
*/
188+
@Override
189+
public synchronized void onDeviceRemoved(DeviceRemovedEvent event) {
190+
logger.debug("BlueZ removed device object for {}; clearing cached state to allow reconnect", address);
191+
resetForRemoval();
192+
}
193+
194+
/**
195+
* Drops the cached BlueZ device object and GATT services and marks the device disconnected, so a
196+
* clean reconnect can happen once it reappears (a fresh object then arrives via
197+
* {@link #updateBlueZDevice}, which notifies {@code DISCOVERED} and triggers a proactive
198+
* reconnect). Called both when BlueZ removes this device object directly and when the adapter it
199+
* lives under is removed (the bridge cascades the reset to all of its devices, since BlueZ does
200+
* not reliably emit a per-device removal before removing the adapter).
201+
*/
202+
synchronized void resetForRemoval() {
203+
this.device = null;
204+
supportedServices.clear();
205+
setConnectionState(ConnectionState.DISCONNECTED);
206+
}
207+
172208
@Override
173209
public boolean connect() {
174210
logger.debug("Connect({})", device);
@@ -350,7 +386,14 @@ public void onDBusBlueZEvent(BlueZEvent event) {
350386
@Override
351387
public void onServicesResolved(ServicesResolvedEvent event) {
352388
if (event.isResolved()) {
353-
notifyListeners(BluetoothEventType.SERVICES_DISCOVERED);
389+
// Populate our service/characteristic list from the now-resolved GATT before notifying
390+
// listeners. BlueZ can deliver ServicesResolved=true while our own supportedServices list
391+
// is still empty (e.g. right after a reconnect, before discoverServices() has run); firing
392+
// SERVICES_DISCOVERED then makes listeners (e.g. the generic handler's channel builder) run
393+
// against an empty list and miss every characteristic. discoverServices() populates the
394+
// list and fires SERVICES_DISCOVERED itself once it has added services, so it is the
395+
// correct trigger here. If the services are already known it is a cheap no-op.
396+
discoverServices();
354397
}
355398
}
356399

bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBridgeHandler.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.concurrent.Future;
1919
import java.util.concurrent.ScheduledFuture;
2020
import java.util.concurrent.TimeUnit;
21+
import java.util.function.Consumer;
2122

2223
import org.bluez.exceptions.BluezFailedException;
2324
import org.bluez.exceptions.BluezInvalidArgumentsException;
@@ -31,8 +32,10 @@
3132
import org.openhab.binding.bluetooth.BluetoothAddress;
3233
import org.openhab.binding.bluetooth.bluez.internal.events.AdapterDiscoveringChangedEvent;
3334
import org.openhab.binding.bluetooth.bluez.internal.events.AdapterPoweredChangedEvent;
35+
import org.openhab.binding.bluetooth.bluez.internal.events.AdapterRemovedEvent;
3436
import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEvent;
3537
import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEventListener;
38+
import org.openhab.binding.bluetooth.bluez.internal.events.DeviceRemovedEvent;
3639
import org.openhab.core.thing.Bridge;
3740
import org.openhab.core.thing.ThingStatus;
3841
import org.openhab.core.thing.ThingStatusDetail;
@@ -68,6 +71,11 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
6871

6972
private boolean lazyScan;
7073

74+
private final Consumer<String> deviceRemovedListener = this::onDeviceRemoved;
75+
private final Consumer<String> adapterRemovedListener = this::onAdapterRemoved;
76+
77+
private boolean removalListenersRegistered;
78+
7179
private @Nullable ScheduledFuture<?> discoveryJob;
7280

7381
private final DeviceManagerFactory deviceManagerFactory;
@@ -107,6 +115,12 @@ public void initialize() {
107115
@Override
108116
public void dispose() {
109117
deviceManagerFactory.getPropertiesChangedHandler().removeListener(this);
118+
DeviceManagerWrapper deviceManager = deviceManagerFactory.getDeviceManager();
119+
if (deviceManager != null && removalListenersRegistered) {
120+
deviceManager.unregisterDeviceRemovedListener(deviceRemovedListener);
121+
deviceManager.unregisterAdapterRemovedListener(adapterRemovedListener);
122+
removalListenersRegistered = false;
123+
}
110124
logger.debug("Termination of DBus BlueZ handler");
111125

112126
Future<?> job = discoveryJob;
@@ -185,6 +199,12 @@ private void initializeAndRefreshDevices() {
185199

186200
deviceManager.setLazyScan(this.lazyScan);
187201

202+
if (!removalListenersRegistered) {
203+
deviceManager.registerDeviceRemovedListener(deviceRemovedListener);
204+
deviceManager.registerAdapterRemovedListener(adapterRemovedListener);
205+
removalListenersRegistered = true;
206+
}
207+
188208
BluetoothAdapter localAdapter = prepareAdapter(deviceManager);
189209
if (localAdapter == null) {
190210
// adapter isn't prepared yet
@@ -232,6 +252,33 @@ public void stopDiscovery() {
232252
}
233253
}
234254

255+
/**
256+
* Invoked (via {@link DeviceManagerWrapper#registerDeviceRemovedListener}) when BlueZ removes a
257+
* device object, with that device's DBus object path. A removal means any connection to the
258+
* device is gone and its cached GATT state is invalid — but the {@code Connected=false}
259+
* PropertiesChanged signal can be missed under continuous discovery, leaving the device's handler
260+
* stuck believing it is still connected. Wrap it as a {@link DeviceRemovedEvent} and route it
261+
* through the same adapter-filtered dispatch as every other BlueZ event, so the matching
262+
* {@link BlueZBluetoothDevice} resets its state and the reconnect job can recover.
263+
*
264+
* @param dbusPath the removed device's DBus object path, e.g. {@code /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF}
265+
*/
266+
private void onDeviceRemoved(String dbusPath) {
267+
onDBusBlueZEvent(new DeviceRemovedEvent(dbusPath));
268+
}
269+
270+
/**
271+
* Invoked (via {@link DeviceManagerWrapper#registerAdapterRemovedListener}) when BlueZ removes an
272+
* adapter object, with that adapter's DBus object path (e.g. {@code /org/bluez/hci0}) — typically
273+
* a USB BT dongle being unplugged. Wrap it as an {@link AdapterRemovedEvent} and route it through
274+
* the same adapter-filtered dispatch as every other BlueZ event.
275+
*
276+
* @param dbusPath the removed adapter's DBus object path
277+
*/
278+
private void onAdapterRemoved(String dbusPath) {
279+
onDBusBlueZEvent(new AdapterRemovedEvent(dbusPath));
280+
}
281+
235282
@Override
236283
public @Nullable BluetoothAddress getAddress() {
237284
return adapterAddress;
@@ -264,9 +311,25 @@ public void onDBusBlueZEvent(BlueZEvent event) {
264311
// now lets forward the event to the corresponding bluetooth device
265312
BlueZBluetoothDevice device = getDevice(address);
266313
event.dispatch(device);
314+
} else {
315+
// adapter-scoped event (no device in the path), e.g. adapter removed - handle it here
316+
event.dispatch(this);
267317
}
268318
}
269319

320+
@Override
321+
public void onAdapterRemoved(AdapterRemovedEvent event) {
322+
logger.debug("BlueZ removed our adapter {}; resetting adapter and all of its devices", adapterAddress);
323+
// Drop the cached adapter proxy so the next refresh re-resolves a live one when the adapter
324+
// (e.g. a USB dongle) reappears - reusing a stale proxy is the core adapter-staleness bug.
325+
this.adapter = null;
326+
// Cascade the reset to every device under this adapter. BlueZ does not reliably emit a
327+
// per-device InterfacesRemoved before removing the adapter, so the devices would otherwise
328+
// stay stuck believing they are still connected.
329+
forEachDevice(BlueZBluetoothDevice::resetForRemoval);
330+
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Adapter removed");
331+
}
332+
270333
@Override
271334
public void onDiscoveringChanged(AdapterDiscoveringChangedEvent event) {
272335
// do nothing for now

bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/DeviceManagerWrapper.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.util.Collection;
1616
import java.util.List;
1717
import java.util.Set;
18+
import java.util.function.Consumer;
1819

1920
import org.eclipse.jdt.annotation.NonNullByDefault;
2021
import org.eclipse.jdt.annotation.Nullable;
@@ -80,4 +81,42 @@ void setLazyScan(boolean lazyScan) {
8081
deviceManager.setLazyScan(lazyScan);
8182
}
8283
}
84+
85+
/**
86+
* Registers a listener invoked with a device's DBus object path when BlueZ removes that device
87+
* object (ObjectManager InterfacesRemoved). Lets the binding invalidate its own cached state for
88+
* a device when the underlying BlueZ object disappears.
89+
*/
90+
public synchronized void registerDeviceRemovedListener(Consumer<String> listener) {
91+
DeviceManager devMgr = deviceManager;
92+
if (devMgr != null) {
93+
devMgr.registerDeviceRemovedListener(listener);
94+
}
95+
}
96+
97+
/**
98+
* Registers a listener invoked with an adapter's DBus object path when BlueZ removes that adapter
99+
* object (ObjectManager InterfacesRemoved), e.g. a USB dongle being unplugged. Lets the binding
100+
* invalidate its cached adapter proxy and the devices found through it.
101+
*/
102+
public synchronized void registerAdapterRemovedListener(Consumer<String> listener) {
103+
DeviceManager devMgr = deviceManager;
104+
if (devMgr != null) {
105+
devMgr.registerAdapterRemovedListener(listener);
106+
}
107+
}
108+
109+
public synchronized void unregisterDeviceRemovedListener(Consumer<String> listener) {
110+
DeviceManager devMgr = deviceManager;
111+
if (devMgr != null) {
112+
devMgr.unregisterDeviceRemovedListener(listener);
113+
}
114+
}
115+
116+
public synchronized void unregisterAdapterRemovedListener(Consumer<String> listener) {
117+
DeviceManager devMgr = deviceManager;
118+
if (devMgr != null) {
119+
devMgr.unregisterAdapterRemovedListener(listener);
120+
}
121+
}
83122
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.bluetooth.bluez.internal.events;
14+
15+
import org.eclipse.jdt.annotation.NonNullByDefault;
16+
17+
/**
18+
* This is triggered when BlueZ removes an adapter object (ObjectManager InterfacesRemoved), e.g. a
19+
* USB Bluetooth dongle being unplugged. Any cached adapter proxy and the devices found through it
20+
* are then stale and must be invalidated.
21+
*
22+
* @author Vlad Kolotov - Initial Contribution
23+
*
24+
*/
25+
@NonNullByDefault
26+
public class AdapterRemovedEvent extends BlueZEvent {
27+
28+
public AdapterRemovedEvent(String dbusPath) {
29+
super(dbusPath);
30+
}
31+
32+
@Override
33+
public void dispatch(BlueZEventListener listener) {
34+
listener.onAdapterRemoved(this);
35+
}
36+
}

bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/events/BlueZEventListener.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,12 @@ default void onNameUpdate(NameEvent event) {
6464
default void onServicesResolved(ServicesResolvedEvent event) {
6565
onDBusBlueZEvent(event);
6666
}
67+
68+
default void onDeviceRemoved(DeviceRemovedEvent event) {
69+
onDBusBlueZEvent(event);
70+
}
71+
72+
default void onAdapterRemoved(AdapterRemovedEvent event) {
73+
onDBusBlueZEvent(event);
74+
}
6775
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.bluetooth.bluez.internal.events;
14+
15+
import org.eclipse.jdt.annotation.NonNullByDefault;
16+
17+
/**
18+
* This is triggered when BlueZ removes a device object (ObjectManager InterfacesRemoved), meaning
19+
* any connection to the device is gone and its cached GATT state is no longer valid.
20+
*
21+
* @author Vlad Kolotov - Initial Contribution
22+
*
23+
*/
24+
@NonNullByDefault
25+
public class DeviceRemovedEvent extends BlueZEvent {
26+
27+
public DeviceRemovedEvent(String dbusPath) {
28+
super(dbusPath);
29+
}
30+
31+
@Override
32+
public void dispatch(BlueZEventListener listener) {
33+
listener.onDeviceRemoved(this);
34+
}
35+
}

bundles/org.openhab.binding.bluetooth/src/main/java/org/openhab/binding/bluetooth/AbstractBluetoothBridgeHandler.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.concurrent.CopyOnWriteArraySet;
2121
import java.util.concurrent.ScheduledFuture;
2222
import java.util.concurrent.TimeUnit;
23+
import java.util.function.Consumer;
2324

2425
import org.eclipse.jdt.annotation.NonNullByDefault;
2526
import org.eclipse.jdt.annotation.Nullable;
@@ -133,6 +134,20 @@ protected void removeDevice(BD device) {
133134
discoveryListeners.forEach(listener -> listener.deviceRemoved(device));
134135
}
135136

137+
/**
138+
* Performs the given action on every device currently known to this bridge. Subclasses use this
139+
* to propagate adapter-wide events (e.g. the adapter going away) to all of their devices without
140+
* needing their own copy of the device registry. The action runs while holding the device lock,
141+
* so it should be quick and must not call back into device-registry-mutating methods.
142+
*
143+
* @param action the action to perform on each known device
144+
*/
145+
protected void forEachDevice(Consumer<BD> action) {
146+
synchronized (devices) {
147+
devices.values().forEach(action);
148+
}
149+
}
150+
136151
private boolean shouldRemove(BD device) {
137152
// we can't remove devices with listeners since that means they have a handler.
138153
if (device.hasListeners()) {

0 commit comments

Comments
 (0)