Skip to content

Commit 399d3f7

Browse files
markus7017Ciprian Pascu
authored andcommitted
[shelly] Re-arm status updates after WebSocket reconnect (openhab#21393)
An always-on Gen2+ device's periodic NotifyStatus push is only requested once, during the very first connect. A reconnect gets a new WebSocket session, so the device silently stops pushing status updates until this is re-requested. Signed-off-by: Markus Michels <markus7017@gmail.com> Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
1 parent 1a8282d commit 399d3f7

2 files changed

Lines changed: 174 additions & 0 deletions

File tree

bundles/org.openhab.binding.shelly/src/main/java/org/openhab/binding/shelly/internal/api2/Shelly2ApiRpc.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,18 @@ private boolean deleteScript(int id) {
438438
public void onConnect(InetSocketAddress deviceSocketAddr, boolean connected) {
439439
thing = thingTable.getThing(deviceSocketAddr);
440440
logger.debug("{}: Get thing from thingTable for {}", thingName, deviceSocketAddr);
441+
442+
if (profile.initialized && alwaysOn) {
443+
// The periodic-status-push request is only sent once, during the initial getDeviceProfile() call, and
444+
// is tied to that WebSocket session. A reconnect gets a new session, so the device stops pushing
445+
// NotifyStatus updates until this is re-armed here; also nudge an immediate poll to close the gap.
446+
try {
447+
asyncApiRequest(SHELLYRPC_METHOD_GETSTATUS);
448+
getThing().requestUpdates(1, false);
449+
} catch (ShellyApiException e) {
450+
logger.debug("{}: Unable to re-arm status updates after reconnect", thingName, e);
451+
}
452+
}
441453
}
442454

443455
@Override
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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.shelly.internal.api2;
14+
15+
import static org.mockito.Mockito.*;
16+
import static org.openhab.binding.shelly.internal.ShellyDevices.THING_TYPE_SHELLYPLUS1PM;
17+
18+
import java.lang.reflect.Field;
19+
import java.net.InetSocketAddress;
20+
import java.util.Map;
21+
import java.util.concurrent.ScheduledExecutorService;
22+
23+
import org.eclipse.jdt.annotation.NonNullByDefault;
24+
import org.eclipse.jdt.annotation.Nullable;
25+
import org.eclipse.jetty.client.HttpClient;
26+
import org.eclipse.jetty.websocket.client.WebSocketClient;
27+
import org.junit.jupiter.api.Test;
28+
import org.openhab.binding.shelly.internal.api.ShellyDeviceProfile;
29+
import org.openhab.binding.shelly.internal.config.ShellyApiConfiguration;
30+
import org.openhab.binding.shelly.internal.config.ShellyBindingConfiguration;
31+
import org.openhab.binding.shelly.internal.config.ShellyBindingRuntimeConfig;
32+
import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
33+
import org.openhab.binding.shelly.internal.handler.ShellyThingTable;
34+
import org.openhab.core.net.NetworkAddressChangeListener;
35+
import org.openhab.core.net.NetworkAddressService;
36+
import org.openhab.core.thing.Thing;
37+
import org.openhab.core.thing.ThingUID;
38+
39+
/**
40+
* Unit tests for {@link Shelly2ApiRpc#onConnect}: a WebSocket reconnect on an always-on device must
41+
* re-arm the periodic status push and trigger an immediate poll, since that subscription is tied to
42+
* the WebSocket session and is otherwise only ever requested once, during the very first connect.
43+
*
44+
* @author Markus Michels - Initial contribution
45+
*/
46+
@NonNullByDefault
47+
@SuppressWarnings("null")
48+
public class Shelly2ApiRpcOnConnectTest {
49+
50+
@Test
51+
void reconnectReArmsStatusUpdatesAndTriggersPoll() throws Exception {
52+
ShellyThingInterface thing = mock(ShellyThingInterface.class);
53+
ShellyThingTable thingTable = mock(ShellyThingTable.class);
54+
Shelly2RpcSocket rpcSocket = mock(Shelly2RpcSocket.class);
55+
when(rpcSocket.isConnected()).thenReturn(true);
56+
57+
Shelly2ApiRpc api = buildApi(thing, thingTable, true, true);
58+
setField(api, "rpcSocket", rpcSocket);
59+
when(thingTable.getThing(any(InetSocketAddress.class))).thenReturn(thing);
60+
61+
api.onConnect(new InetSocketAddress("127.0.0.1", 80), true);
62+
63+
verify(rpcSocket).sendMessage(contains(Shelly2ApiJsonDTO.SHELLYRPC_METHOD_GETSTATUS));
64+
verify(thing).requestUpdates(1, false);
65+
}
66+
67+
@Test
68+
void firstConnectDoesNotReArmStatusUpdates() throws Exception {
69+
ShellyThingInterface thing = mock(ShellyThingInterface.class);
70+
ShellyThingTable thingTable = mock(ShellyThingTable.class);
71+
Shelly2RpcSocket rpcSocket = mock(Shelly2RpcSocket.class);
72+
73+
// profile.initialized is false: this is the very first connect, already handled explicitly
74+
// by getDeviceProfile()'s own firstInit branch, so onConnect must not duplicate it.
75+
Shelly2ApiRpc api = buildApi(thing, thingTable, false, true);
76+
setField(api, "rpcSocket", rpcSocket);
77+
when(thingTable.getThing(any(InetSocketAddress.class))).thenReturn(thing);
78+
79+
api.onConnect(new InetSocketAddress("127.0.0.1", 80), true);
80+
81+
verify(rpcSocket, never()).sendMessage(anyString());
82+
verify(thing, never()).requestUpdates(anyInt(), anyBoolean());
83+
}
84+
85+
@Test
86+
void batteryDeviceReconnectDoesNotReArmStatusUpdates() throws Exception {
87+
ShellyThingInterface thing = mock(ShellyThingInterface.class);
88+
ShellyThingTable thingTable = mock(ShellyThingTable.class);
89+
Shelly2RpcSocket rpcSocket = mock(Shelly2RpcSocket.class);
90+
91+
// Battery/sleeping devices are not alwaysOn and have no persistent WebSocket to re-arm.
92+
Shelly2ApiRpc api = buildApi(thing, thingTable, true, false);
93+
setField(api, "rpcSocket", rpcSocket);
94+
when(thingTable.getThing(any(InetSocketAddress.class))).thenReturn(thing);
95+
96+
api.onConnect(new InetSocketAddress("127.0.0.1", 80), true);
97+
98+
verify(rpcSocket, never()).sendMessage(anyString());
99+
verify(thing, never()).requestUpdates(anyInt(), anyBoolean());
100+
}
101+
102+
private Shelly2ApiRpc buildApi(ShellyThingInterface thing, ShellyThingTable thingTable, boolean initialized,
103+
boolean alwaysOn) throws Exception {
104+
ShellyDeviceProfile profile = new ShellyDeviceProfile(THING_TYPE_SHELLYPLUS1PM);
105+
profile.initialized = initialized;
106+
profile.alwaysOn = alwaysOn;
107+
108+
Thing ohThing = mock(Thing.class);
109+
when(ohThing.getUID()).thenReturn(new ThingUID(THING_TYPE_SHELLYPLUS1PM, "test"));
110+
111+
HttpClient httpClient = mock(HttpClient.class);
112+
when(thing.getThing()).thenReturn(ohThing);
113+
when(thing.getHttpClient()).thenReturn(httpClient);
114+
when(thing.getProfile()).thenReturn(profile);
115+
116+
ShellyBindingConfiguration raw = ShellyBindingConfiguration
117+
.fromProperties(Map.of(ShellyBindingConfiguration.CONFIG_LOCAL_IP, "192.168.1.1"));
118+
ShellyBindingRuntimeConfig bindingConfig = new ShellyBindingRuntimeConfig(raw, 8080, nullNas());
119+
ShellyApiConfiguration config = new ShellyApiConfiguration(bindingConfig, "test-rpc", "");
120+
121+
return new Shelly2ApiRpc("test-rpc", thingTable, thing, config, mock(WebSocketClient.class),
122+
mock(ScheduledExecutorService.class));
123+
}
124+
125+
private static void setField(Object target, String fieldName, Object value) throws Exception {
126+
Field f = target.getClass().getDeclaredField(fieldName);
127+
f.setAccessible(true);
128+
f.set(target, value);
129+
}
130+
131+
private static NetworkAddressService nullNas() {
132+
return new NetworkAddressService() {
133+
@Override
134+
public @Nullable String getPrimaryIpv4HostAddress() {
135+
return null;
136+
}
137+
138+
@Override
139+
public @Nullable String getConfiguredBroadcastAddress() {
140+
return null;
141+
}
142+
143+
@Override
144+
public boolean isUseOnlyOneAddress() {
145+
return false;
146+
}
147+
148+
@Override
149+
public boolean isUseIPv6() {
150+
return false;
151+
}
152+
153+
@Override
154+
public void addNetworkAddressChangeListener(NetworkAddressChangeListener listener) {
155+
}
156+
157+
@Override
158+
public void removeNetworkAddressChangeListener(NetworkAddressChangeListener listener) {
159+
}
160+
};
161+
}
162+
}

0 commit comments

Comments
 (0)