mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[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:
+44
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
@@ -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<BlueZBlue
|
||||
|
||||
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 final DeviceManagerFactory deviceManagerFactory;
|
||||
@@ -107,6 +115,12 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
|
||||
@Override
|
||||
public void dispose() {
|
||||
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");
|
||||
|
||||
Future<?> job = discoveryJob;
|
||||
@@ -185,6 +199,12 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
|
||||
|
||||
deviceManager.setLazyScan(this.lazyScan);
|
||||
|
||||
if (!removalListenersRegistered) {
|
||||
deviceManager.registerDeviceRemovedListener(deviceRemovedListener);
|
||||
deviceManager.registerAdapterRemovedListener(adapterRemovedListener);
|
||||
removalListenersRegistered = true;
|
||||
}
|
||||
|
||||
BluetoothAdapter localAdapter = prepareAdapter(deviceManager);
|
||||
if (localAdapter == null) {
|
||||
// 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
|
||||
public @Nullable BluetoothAddress getAddress() {
|
||||
return adapterAddress;
|
||||
@@ -264,9 +311,25 @@ public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBlue
|
||||
// now lets forward the event to the corresponding bluetooth device
|
||||
BlueZBluetoothDevice device = getDevice(address);
|
||||
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
|
||||
public void onDiscoveringChanged(AdapterDiscoveringChangedEvent event) {
|
||||
// do nothing for now
|
||||
|
||||
+39
@@ -15,6 +15,7 @@ package org.openhab.binding.bluetooth.bluez.internal;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
@@ -80,4 +81,42 @@ public class DeviceManagerWrapper {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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<BD extends BaseBluetoothDev
|
||||
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) {
|
||||
// we can't remove devices with listeners since that means they have a handler.
|
||||
if (device.hasListeners()) {
|
||||
|
||||
Reference in New Issue
Block a user