mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[linkplay] Stability fixes (#21025)
* [linkplay] Add self-healing reconnect loop for offline devices Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
+14
@@ -25,6 +25,8 @@ import java.util.concurrent.ScheduledExecutorService;
|
|||||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
import org.eclipse.jdt.annotation.Nullable;
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
import org.eclipse.jetty.client.HttpClient;
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
|
import org.eclipse.jetty.client.api.Request;
|
||||||
|
import org.eclipse.jetty.http.HttpHeader;
|
||||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||||
import org.jupnp.UpnpService;
|
import org.jupnp.UpnpService;
|
||||||
import org.jupnp.model.meta.LocalDevice;
|
import org.jupnp.model.meta.LocalDevice;
|
||||||
@@ -94,6 +96,18 @@ public class LinkPlayHandlerFactory extends BaseThingHandlerFactory implements R
|
|||||||
this.networkAddressService = networkAddressService;
|
this.networkAddressService = networkAddressService;
|
||||||
upnpService.getRegistry().addListener(this);
|
upnpService.getRegistry().addListener(this);
|
||||||
httpClient = httpClientFactory.createHttpClient("linkplay", new SslContextFactory.Client(true));
|
httpClient = httpClientFactory.createHttpClient("linkplay", new SslContextFactory.Client(true));
|
||||||
|
// LinkPlay devices aggressively close idle keep alive connections after a few seconds, so a pooled connection
|
||||||
|
// is frequently already dead when reused and the next request fails with an EOFException before it reaches the
|
||||||
|
// device. HTTP requests are infrequent (user commands plus periodic polling), so disable connection reuse
|
||||||
|
// entirely.
|
||||||
|
httpClient.getRequestListeners().add(new Request.Listener.Adapter() {
|
||||||
|
@Override
|
||||||
|
public void onBegin(@Nullable Request request) {
|
||||||
|
if (request != null) {
|
||||||
|
request.header(HttpHeader.CONNECTION, "close");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
httpClient.start();
|
httpClient.start();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
+28
@@ -23,6 +23,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
@@ -68,6 +69,7 @@ public class LinkPlayUpnpClient implements UpnpIOParticipant, LinkPlayUpnpComman
|
|||||||
private @Nullable RemoteDevice remoteDevice;
|
private @Nullable RemoteDevice remoteDevice;
|
||||||
private String udn = "";
|
private String udn = "";
|
||||||
private volatile boolean disposed;
|
private volatile boolean disposed;
|
||||||
|
private volatile long lastValueNanos = System.nanoTime();
|
||||||
|
|
||||||
public LinkPlayUpnpClient(LinkPlayUpnpClientHandler handler, UpnpIOService upnpIOService, UpnpService upnpService,
|
public LinkPlayUpnpClient(LinkPlayUpnpClientHandler handler, UpnpIOService upnpIOService, UpnpService upnpService,
|
||||||
ScheduledExecutorService scheduler) {
|
ScheduledExecutorService scheduler) {
|
||||||
@@ -101,6 +103,15 @@ public class LinkPlayUpnpClient implements UpnpIOParticipant, LinkPlayUpnpComman
|
|||||||
return remoteDevice;
|
return remoteDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up the current {@link RemoteDevice} for this UDN directly from the jUPnP registry.
|
||||||
|
*
|
||||||
|
* @return the registered device, or {@code null} if the device is not currently in the registry
|
||||||
|
*/
|
||||||
|
public @Nullable RemoteDevice findRegisteredDevice() {
|
||||||
|
return upnpService.getRegistry().getRemoteDevice(new UDN(udn), false);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean needsUpnpInitialization() {
|
public boolean needsUpnpInitialization() {
|
||||||
return needsUpnpInitialization.get();
|
return needsUpnpInitialization.get();
|
||||||
}
|
}
|
||||||
@@ -120,6 +131,7 @@ public class LinkPlayUpnpClient implements UpnpIOParticipant, LinkPlayUpnpComman
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
|
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
|
||||||
|
lastValueNanos = System.nanoTime();
|
||||||
if (!handler.shouldProcessUpnpEvents()) {
|
if (!handler.shouldProcessUpnpEvents()) {
|
||||||
logger.debug("{}: onValueReceived: handler is not ready to process UPnP events", udn);
|
logger.debug("{}: onValueReceived: handler is not ready to process UPnP events", udn);
|
||||||
return;
|
return;
|
||||||
@@ -261,6 +273,22 @@ public class LinkPlayUpnpClient implements UpnpIOParticipant, LinkPlayUpnpComman
|
|||||||
subscriptionState.clear();
|
subscriptionState.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces a resubscription by removing the current subscriptions and creating fresh ones. This
|
||||||
|
* works around jUPnP's renewal issues which LinkPlay devices frequently fail to honor
|
||||||
|
*/
|
||||||
|
public void resubscribe() {
|
||||||
|
removeSubscriptions();
|
||||||
|
addSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the number of seconds since the last UPnP value (GENA event) was received
|
||||||
|
*/
|
||||||
|
public long secondsSinceLastValue() {
|
||||||
|
return TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - lastValueNanos);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isFullySubscribed() {
|
public boolean isFullySubscribed() {
|
||||||
for (String s : SERVICE_SUBSCRIPTIONS) {
|
for (String s : SERVICE_SUBSCRIPTIONS) {
|
||||||
if (!subscriptionState.getOrDefault(s, false)) {
|
if (!subscriptionState.getOrDefault(s, false)) {
|
||||||
|
|||||||
+89
-8
@@ -102,10 +102,15 @@ public class LinkPlayHandler extends BaseThingHandler
|
|||||||
private final Logger logger = LoggerFactory.getLogger(LinkPlayHandler.class);
|
private final Logger logger = LoggerFactory.getLogger(LinkPlayHandler.class);
|
||||||
|
|
||||||
private static final int RECONNECT_DELAY = 30;
|
private static final int RECONNECT_DELAY = 30;
|
||||||
|
private static final int UPNP_WATCHDOG_INTERVAL_SECONDS = 30;
|
||||||
|
private static final int UPNP_RESUBSCRIBE_INTERVAL_SECONDS = 180;
|
||||||
|
private static final int UPNP_EVENT_GRACE_SECONDS = 60;
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
private @Nullable ScheduledFuture<?> upnpServiceCheck;
|
private @Nullable ScheduledFuture<?> upnpServiceCheck;
|
||||||
|
private @Nullable ScheduledFuture<?> upnpWatchdogJob;
|
||||||
private @Nullable ScheduledFuture<?> reconnectJob;
|
private @Nullable ScheduledFuture<?> reconnectJob;
|
||||||
private @Nullable ScheduledFuture<?> positionJob;
|
private @Nullable ScheduledFuture<?> positionJob;
|
||||||
|
private volatile long lastResubscribeNanos = System.nanoTime();
|
||||||
// Are we currently initializing the device?
|
// Are we currently initializing the device?
|
||||||
private final AtomicBoolean isInitializing = new AtomicBoolean(false);
|
private final AtomicBoolean isInitializing = new AtomicBoolean(false);
|
||||||
// Have we been disposed, prevent further calls to the device when shutting down
|
// Have we been disposed, prevent further calls to the device when shutting down
|
||||||
@@ -167,6 +172,11 @@ public class LinkPlayHandler extends BaseThingHandler
|
|||||||
*/
|
*/
|
||||||
cancelUpnpServiceCheckJob();
|
cancelUpnpServiceCheckJob();
|
||||||
upnpServiceCheck = scheduler.scheduleWithFixedDelay(this::initializationCheck, 0, 15, TimeUnit.SECONDS);
|
upnpServiceCheck = scheduler.scheduleWithFixedDelay(this::initializationCheck, 0, 15, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
// Watchdog that detects and recovers dead UPnP subscriptions.
|
||||||
|
cancelUpnpWatchdogJob();
|
||||||
|
upnpWatchdogJob = scheduler.scheduleWithFixedDelay(this::upnpEventWatchdog, UPNP_WATCHDOG_INTERVAL_SECONDS,
|
||||||
|
UPNP_WATCHDOG_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -176,6 +186,7 @@ public class LinkPlayHandler extends BaseThingHandler
|
|||||||
linkPlayGroupService.unregisterParticipant(this);
|
linkPlayGroupService.unregisterParticipant(this);
|
||||||
cancelReconnectJob();
|
cancelReconnectJob();
|
||||||
cancelUpnpServiceCheckJob();
|
cancelUpnpServiceCheckJob();
|
||||||
|
cancelUpnpWatchdogJob();
|
||||||
cancelPositionJob();
|
cancelPositionJob();
|
||||||
notificationHandler.dispose();
|
notificationHandler.dispose();
|
||||||
upnpClient.dispose();
|
upnpClient.dispose();
|
||||||
@@ -581,12 +592,16 @@ public class LinkPlayHandler extends BaseThingHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!allSubscriptionsSuccessful) {
|
if (!allSubscriptionsSuccessful) {
|
||||||
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Waiting for UPnP subscriptions");
|
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
||||||
|
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Waiting for UPnP subscriptions");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
||||||
updateStatus(ThingStatus.ONLINE);
|
updateStatus(ThingStatus.ONLINE);
|
||||||
}
|
}
|
||||||
|
cancelReconnectJob();
|
||||||
|
lastResubscribeNanos = System.nanoTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -814,15 +829,47 @@ public class LinkPlayHandler extends BaseThingHandler
|
|||||||
|
|
||||||
public void setOffline(String reason) {
|
public void setOffline(String reason) {
|
||||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, reason);
|
||||||
RemoteDevice device = upnpClient.getRemoteDevice();
|
|
||||||
upnpClient.clearSubscriptionState();
|
upnpClient.clearSubscriptionState();
|
||||||
if (device != null) {
|
|
||||||
cancelReconnectJob();
|
|
||||||
if (!disposed) {
|
|
||||||
reconnectJob = scheduler.schedule(() -> initFromUpnp(device), RECONNECT_DELAY, TimeUnit.SECONDS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
upnpClient.sendDeviceSearchRequest();
|
upnpClient.sendDeviceSearchRequest();
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleReconnect() {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScheduledFuture<?> job = reconnectJob;
|
||||||
|
if (job != null && !job.isDone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnectJob = scheduler.scheduleWithFixedDelay(this::reconnect, RECONNECT_DELAY, RECONNECT_DELAY,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconnect() {
|
||||||
|
if (disposed || getThing().getStatus() == ThingStatus.ONLINE) {
|
||||||
|
cancelReconnectJob();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInitializing.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug("{}: Attempting to reconnect", udn);
|
||||||
|
upnpClient.sendDeviceSearchRequest();
|
||||||
|
Slave slave = linkPlayGroupService.findSlaveByUDN(udn);
|
||||||
|
if (slave != null) {
|
||||||
|
initFromGroup(slave);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RemoteDevice device = upnpClient.findRegisteredDevice();
|
||||||
|
if (device == null) {
|
||||||
|
device = upnpClient.getRemoteDevice();
|
||||||
|
}
|
||||||
|
if (device != null) {
|
||||||
|
upnpClient.setRemoteDevice(device);
|
||||||
|
upnpClient.setNeedsUpnpInitialization(true);
|
||||||
|
initFromUpnp(device);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derives the host and port from the Upnp device and attempts to connect to the api
|
// Derives the host and port from the Upnp device and attempts to connect to the api
|
||||||
@@ -1407,6 +1454,35 @@ public class LinkPlayHandler extends BaseThingHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void upnpEventWatchdog() {
|
||||||
|
if (disposed || getThing().getStatus() != ThingStatus.ONLINE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// non-leader group members intentionally have UPnP turned off, so silence is expected
|
||||||
|
if (inGroup && !isLeader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// don't tear down the subscription mid-notification as notifications are short-lived
|
||||||
|
if (notificationHandler.isInNotification()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long staleSeconds = upnpClient.secondsSinceLastValue();
|
||||||
|
long sinceResubscribe = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - lastResubscribeNanos);
|
||||||
|
|
||||||
|
if (staleSeconds > sinceResubscribe && sinceResubscribe >= UPNP_EVENT_GRACE_SECONDS) {
|
||||||
|
logger.debug("{}: No UPnP events {}s after resubscribe; reconnecting", udn, sinceResubscribe);
|
||||||
|
setOffline("No UPnP events received " + sinceResubscribe + "s after resubscribing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sinceResubscribe >= UPNP_RESUBSCRIBE_INTERVAL_SECONDS) {
|
||||||
|
logger.debug("{}: Proactively refreshing UPnP subscription ({}s since last event)", udn, staleSeconds);
|
||||||
|
upnpClient.resubscribe();
|
||||||
|
upnpClient.sendDeviceSearchRequest();
|
||||||
|
lastResubscribeNanos = System.nanoTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void cancelReconnectJob() {
|
private void cancelReconnectJob() {
|
||||||
cancelJob(reconnectJob);
|
cancelJob(reconnectJob);
|
||||||
reconnectJob = null;
|
reconnectJob = null;
|
||||||
@@ -1417,6 +1493,11 @@ public class LinkPlayHandler extends BaseThingHandler
|
|||||||
upnpServiceCheck = null;
|
upnpServiceCheck = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void cancelUpnpWatchdogJob() {
|
||||||
|
cancelJob(upnpWatchdogJob);
|
||||||
|
upnpWatchdogJob = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void cancelJob(@Nullable ScheduledFuture<?> job) {
|
private void cancelJob(@Nullable ScheduledFuture<?> job) {
|
||||||
if (job != null) {
|
if (job != null) {
|
||||||
job.cancel(true);
|
job.cancel(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user