Skip to content

Commit 11d6a06

Browse files
author
Martin Littkovsky
committed
[shelly] Tolerate third-party BLE script event data in NotifyEvent frames
Gen2/Gen3 devices running a third-party BLE-proxy script (e.g. Home Assistant's) emit NotifyEvent frames with event "ble.scan_result" whose "data" member is an array. Shelly2NotifyEvent hard-typed that member to Shelly2NotifyBluEventData, so Gson failed the whole frame with "Expected BEGIN_OBJECT but was BEGIN_ARRAY", discarding every event in it (including unrelated ones such as button pushes) and logging the full payload several times per second on affected devices. The "data" member is now kept as a raw JsonElement and resolved lazily via getBluData(), which returns the BLU payload only when the element actually is a JSON object; the binding's own oh-blu.* scanner format parses exactly as before. ble.scan_result events are skipped explicitly so they are neither re-parsed per event nor logged as unhandled. Signed-off-by: Martin Littkovsky <2018turtle@proton.me> AI-assisted-by: Claude Code
1 parent 391be0d commit 11d6a06

5 files changed

Lines changed: 136 additions & 3 deletions

File tree

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import org.openhab.binding.shelly.internal.api2.dto.ShellyCoverJsonDTO.Shelly2CoverStatus;
2424
import org.openhab.binding.shelly.internal.api2.dto.ShellyCoverJsonDTO.Shelly2DevConfigCover;
2525

26+
import com.google.gson.Gson;
27+
import com.google.gson.JsonElement;
2628
import com.google.gson.annotations.SerializedName;
2729

2830
/**
@@ -160,6 +162,11 @@ public class Shelly2ApiJsonDTO {
160162
public static final String SHELLY2_EVENT_FLOOD_ALARM_OFF = "flood.alarm_off";
161163
public static final String SHELLY2_EVENT_FLOOD_CABLE_UNPLUGGED = "flood.cable_unplugged";
162164

165+
// Emitted by 3rd party BLE-proxy scripts (e.g. Home Assistant's), not the binding's own oh-blu.*
166+
// scanner script; "data" is an array of raw scan results instead of an object, so it carries no
167+
// usable BLU payload here and is only suppressed to avoid flooding the log.
168+
public static final String SHELLY2_EVENT_BLE_SCAN_RESULT = "ble.scan_result";
169+
163170
// Error Codes
164171
public static final String SHELLY2_ERROR_OVERPOWER = "overpower";
165172
public static final String SHELLY2_ERROR_OVERTEMP = "overtemp";
@@ -1223,12 +1230,20 @@ public class Shelly2NotifyEvent {
12231230
public @Nullable Double ts;
12241231
public @Nullable String component;
12251232
public @Nullable String event;
1233+
// BLU gateway scripts emit an object here, but other scripts (e.g. Home Assistant's
1234+
// BLE-proxy script for ble.scan_result) emit an array. Keep the raw element and let
1235+
// getBluData() decide, rather than letting Gson fail the whole frame on a shape mismatch.
12261236
@SerializedName("data")
1227-
public @Nullable Shelly2NotifyBluEventData blu;
1237+
public @Nullable JsonElement data;
12281238
public @Nullable String msg;
12291239
public @Nullable Integer reason;
12301240
@SerializedName("cfg_rev")
12311241
public @Nullable Integer cfgRev;
1242+
1243+
public @Nullable Shelly2NotifyBluEventData getBluData(Gson gson) {
1244+
JsonElement data = this.data;
1245+
return data != null && data.isJsonObject() ? gson.fromJson(data, Shelly2NotifyBluEventData.class) : null;
1246+
}
12321247
}
12331248

12341249
public class Shelly2NotifyEventData {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,11 @@ public void onNotifyEvent(String eventJSON) throws ShellyApiException {
604604
logger.debug("{}: Flood sensor cable unplugged", thingName);
605605
getThing().postEvent(ALARM_TYPE_SENSOR_ERROR, true);
606606
break;
607+
case SHELLY2_EVENT_BLE_SCAN_RESULT:
608+
// 3rd party BLE-proxy script (e.g. Home Assistant's), not our oh-blu.* scanner; belt-and-braces
609+
// no-op for delivery paths other than Shelly2RpcSocket.onMessage, which already skips it.
610+
logger.trace("{}: Ignoring {} event from non-BLU BLE scanner", thingName, event);
611+
break;
607612
default:
608613
logger.debug("{}: Event {} was not handled", thingName, e.event);
609614
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ public void onMessage(Session session, String receivedMessage) {
363363
} else {
364364
for (Shelly2NotifyEvent e : notifyEvents) {
365365
if (getString(e.event).startsWith(SHELLY2_EVENT_BLUPREFIX)) {
366-
Shelly2NotifyBluEventData blu = e.blu;
366+
Shelly2NotifyBluEventData blu = e.getBluData(gson);
367367
String address = getString(blu != null ? blu.addr : "").replace(":", "");
368368
ShellyThingInterface bluThing = thingTable.findThing(address);
369369
if (bluThing != null) {
@@ -387,6 +387,11 @@ public void onMessage(Session session, String receivedMessage) {
387387
message.src, e.event, blu.addr);
388388
}
389389
}
390+
} else if (SHELLY2_EVENT_BLE_SCAN_RESULT.equals(e.event)) {
391+
// 3rd party BLE-proxy script (e.g. Home Assistant's), not our oh-blu.* scanner;
392+
// "data" is an array here, not a BLU payload — skip without forwarding to avoid
393+
// re-parsing the whole frame per event and flooding the log.
394+
logger.trace("{}: Ignoring {} event from non-BLU BLE scanner", thingName, e.event);
390395
} else {
391396
// non-BLU event: always use the hub's handler, never the BLU one
392397
handler.onNotifyEvent(receivedMessage);

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ public void onNotifyEvent(String eventJSON) {
200200
}
201201
for (Shelly2NotifyEvent e : events) {
202202
String event = getString(e.event);
203-
Shelly2NotifyBluEventData blu = e.blu;
203+
Shelly2NotifyBluEventData blu = e.getBluData(gson);
204204
if (event.startsWith(SHELLY2_EVENT_BLUPREFIX)) {
205205
if (blu != null) {
206206
logger.debug("{}: BLU event {} received from address {}, pid={} (JSON={})", thingName, event,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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.hamcrest.CoreMatchers.equalTo;
16+
import static org.hamcrest.CoreMatchers.is;
17+
import static org.hamcrest.CoreMatchers.nullValue;
18+
import static org.hamcrest.MatcherAssert.assertThat;
19+
import static org.junit.jupiter.api.Assertions.assertNotNull;
20+
21+
import java.util.ArrayList;
22+
23+
import org.eclipse.jdt.annotation.NonNullByDefault;
24+
import org.junit.jupiter.api.Test;
25+
import org.openhab.binding.shelly.internal.api.ShellyApiException;
26+
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2NotifyEvent;
27+
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2NotifyEventData;
28+
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2RpcNotifyEvent;
29+
import org.openhab.binding.shelly.internal.api2.ShellyBluJsonDTO.Shelly2NotifyBluEventData;
30+
import org.openhab.binding.shelly.internal.util.ShellyUtils;
31+
32+
import com.google.gson.Gson;
33+
34+
/**
35+
* Tests for {@link Shelly2NotifyEvent#getBluData(Gson)} handling of the polymorphic {@code data} field.
36+
*
37+
* <p>
38+
* The binding's own {@code oh-blu.*} scanner script always sends {@code data} as a JSON object, but Gen2/Gen3
39+
* devices running Home Assistant's BLE-proxy script emit {@code NotifyEvent} frames with event
40+
* {@code ble.scan_result} where {@code data} is a JSON array. Before {@code data} became a raw
41+
* {@link com.google.gson.JsonElement}, Gson threw {@code Expected BEGIN_OBJECT but was BEGIN_ARRAY} while parsing
42+
* the whole frame, discarding it and flooding the log.
43+
* </p>
44+
*
45+
* @author Martin Littkovsky - Initial contribution
46+
*/
47+
@NonNullByDefault
48+
public class Shelly2NotifyEventDataShapeTest {
49+
50+
private final Gson gson = new Gson();
51+
52+
@Test
53+
void objectShapedDataParsesAndYieldsBluData() throws ShellyApiException {
54+
String json = """
55+
{"src":"shellyplusht-test","dst":"ohshelly-test-1","method":"NotifyEvent",
56+
"params":{"ts":1700000000.0,"events":[{"component":"script:1","id":1,"event":"oh-blu.data",
57+
"data":{"encryption":false,"BTHome_version":2,"pid":42,"Battery":85,
58+
"addr":"aa:bb:cc:dd:ee:01","rssi":-70},"ts":1700000000.0}]}}
59+
""";
60+
61+
Shelly2NotifyEvent event = firstEvent(json);
62+
assertThat(event.event, is(equalTo("oh-blu.data")));
63+
64+
Shelly2NotifyBluEventData blu = event.getBluData(gson);
65+
assertNotNull(blu);
66+
assertThat(blu.addr, is(equalTo("aa:bb:cc:dd:ee:01")));
67+
assertThat(blu.battery, is(equalTo(85)));
68+
assertThat(blu.pid, is(equalTo(42)));
69+
}
70+
71+
@Test
72+
void arrayShapedDataParsesWithoutExceptionAndYieldsNoBluData() throws ShellyApiException {
73+
// Home Assistant BLE-proxy script style frame: "data" is [scanType, [[addr, rssi, advData, scanRsp], ...]]
74+
String json = """
75+
{"src":"shellyplugsg3-aabbccddeeff","dst":"ohshelly-Test-1","method":"NotifyEvent",
76+
"params":{"ts":1786653121.63,"events":[{"component":"script:1","id":1,"event":"ble.scan_result",
77+
"data":[2,[["aa:bb:cc:dd:ee:01",-97,"AgEEAwMH/hT=",""],["aa:bb:cc:dd:ee:02",-86,"AgEGEQYbxdU=",""]]],
78+
"ts":1786653121.63}]}}
79+
""";
80+
81+
Shelly2NotifyEvent event = firstEvent(json);
82+
assertThat(event.event, is(equalTo("ble.scan_result")));
83+
assertThat(event.getBluData(gson), is(nullValue()));
84+
}
85+
86+
@Test
87+
void absentDataYieldsNoBluDataWithoutException() throws ShellyApiException {
88+
String json = """
89+
{"src":"shellyplusht-test","params":{"ts":1700000000.0,
90+
"events":[{"component":"sys","id":0,"event":"config_changed","ts":1700000000.0}]}}
91+
""";
92+
93+
Shelly2NotifyEvent event = firstEvent(json);
94+
assertThat(event.event, is(equalTo("config_changed")));
95+
assertThat(event.data, is(nullValue()));
96+
assertThat(event.getBluData(gson), is(nullValue()));
97+
}
98+
99+
private Shelly2NotifyEvent firstEvent(String json) throws ShellyApiException {
100+
Shelly2RpcNotifyEvent message = ShellyUtils.fromJson(gson, json, Shelly2RpcNotifyEvent.class);
101+
Shelly2NotifyEventData params = message.params;
102+
assertNotNull(params);
103+
ArrayList<Shelly2NotifyEvent> events = params.events;
104+
assertNotNull(events);
105+
assertThat(events.size(), is(equalTo(1)));
106+
return events.get(0);
107+
}
108+
}

0 commit comments

Comments
 (0)