Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@
import java.util.ArrayList;

import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.shelly.internal.api.ShellyApiException;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DevConfigBle.Shelly2DevConfigBleObserver;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DevConfigBle.Shelly2DevConfigBleRpc;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceStatus.Shelly2DeviceStatusResult;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2RpcBaseMessage.Shelly2RpcMessageError;
import org.openhab.binding.shelly.internal.api2.ShellyBluJsonDTO.Shelly2NotifyBluEventData;
import org.openhab.binding.shelly.internal.api2.dto.ShellyCoverJsonDTO.Shelly2CoverStatus;
import org.openhab.binding.shelly.internal.api2.dto.ShellyCoverJsonDTO.Shelly2DevConfigCover;
import org.openhab.binding.shelly.internal.util.ShellyUtils;

import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.annotations.SerializedName;

/**
Expand Down Expand Up @@ -160,6 +164,11 @@ public class Shelly2ApiJsonDTO {
public static final String SHELLY2_EVENT_FLOOD_ALARM_OFF = "flood.alarm_off";
public static final String SHELLY2_EVENT_FLOOD_CABLE_UNPLUGGED = "flood.cable_unplugged";

// Emitted by 3rd party BLE-proxy scripts (e.g. Home Assistant's), not the binding's own oh-blu.*
// scanner script; "data" is an array of raw scan results instead of an object, so it carries no
// usable BLU payload here and is only suppressed to avoid flooding the log.
public static final String SHELLY2_EVENT_BLE_SCAN_RESULT = "ble.scan_result";

// Error Codes
public static final String SHELLY2_ERROR_OVERPOWER = "overpower";
public static final String SHELLY2_ERROR_OVERTEMP = "overtemp";
Expand Down Expand Up @@ -1223,12 +1232,32 @@ public class Shelly2NotifyEvent {
public @Nullable Double ts;
public @Nullable String component;
public @Nullable String event;
// BLU gateway scripts emit an object here, but other scripts (e.g. Home Assistant's
// BLE-proxy script for ble.scan_result) emit an array. Keep the raw element and let
// getBluData() decide, rather than letting Gson fail the whole frame on a shape mismatch.
@SerializedName("data")
public @Nullable Shelly2NotifyBluEventData blu;
public @Nullable JsonElement data;
public @Nullable String msg;
public @Nullable Integer reason;
@SerializedName("cfg_rev")
public @Nullable Integer cfgRev;

/**
* Returns the BLU payload, or null when {@code data} is absent or not an object at all.
* <p>
* A malformed object still raises {@link ShellyApiException}, the same checked exception the
* whole frame used to fail with while it was deserialized through
* {@link ShellyUtils#fromJson(Gson, String, Class)}: both call sites already handle it, and
* silently returning null instead would drop a BLU event that the device did send, without
* anything in the log to show for it.
*/
public @Nullable Shelly2NotifyBluEventData getBluData(Gson gson) throws ShellyApiException {
JsonElement data = this.data;
if (data == null || !data.isJsonObject()) {
return null;
}
return ShellyUtils.fromJson(gson, data.toString(), Shelly2NotifyBluEventData.class);
}
}

public class Shelly2NotifyEventData {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,11 @@ public void onNotifyEvent(String eventJSON) throws ShellyApiException {
logger.debug("{}: Flood sensor cable unplugged", thingName);
getThing().postEvent(ALARM_TYPE_SENSOR_ERROR, true);
break;
case SHELLY2_EVENT_BLE_SCAN_RESULT:
// 3rd party BLE-proxy script (e.g. Home Assistant's), not our oh-blu.* scanner; belt-and-braces
// no-op for delivery paths other than Shelly2RpcSocket.onMessage, which already skips it.
logger.trace("{}: Ignoring {} event from non-BLU BLE scanner", thingName, event);
break;
default:
logger.debug("{}: Event {} was not handled", thingName, e.event);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,13 @@ public void onMessage(Session session, String receivedMessage) {
if (notifyEvents == null) {
logger.debug("{}: Malformed event data: {}", thingName, receivedMessage);
} else {
// onNotifyEvent() walks the whole frame itself, so the frame is forwarded once
// after this loop rather than per event: forwarding inside the loop would make
// the hub process every regular event as often as the frame carries such events.
boolean hasRegularEvent = false;
for (Shelly2NotifyEvent e : notifyEvents) {
if (getString(e.event).startsWith(SHELLY2_EVENT_BLUPREFIX)) {
Shelly2NotifyBluEventData blu = e.blu;
Shelly2NotifyBluEventData blu = e.getBluData(gson);
String address = getString(blu != null ? blu.addr : "").replace(":", "");
ShellyThingInterface bluThing = thingTable.findThing(address);
if (bluThing != null) {
Expand All @@ -387,11 +391,19 @@ public void onMessage(Session session, String receivedMessage) {
message.src, e.event, blu.addr);
}
}
} else if (SHELLY2_EVENT_BLE_SCAN_RESULT.equals(e.event)) {
// 3rd party BLE-proxy script (e.g. Home Assistant's), not our oh-blu.* scanner;
// "data" is an array here, not a BLU payload — skip without forwarding to avoid
// re-parsing the whole frame per event and flooding the log.
logger.trace("{}: Ignoring {} event from non-BLU BLE scanner", thingName, e.event);
} else {
// non-BLU event: always use the hub's handler, never the BLU one
handler.onNotifyEvent(receivedMessage);
// non-BLU event: always the hub's handler, never the BLU one
hasRegularEvent = true;
}
}
if (hasRegularEvent) {
handler.onNotifyEvent(receivedMessage);
}
}
break;
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,10 @@ public void onNotifyEvent(String eventJSON) {
}
for (Shelly2NotifyEvent e : events) {
String event = getString(e.event);
Shelly2NotifyBluEventData blu = e.blu;
// Only oh-blu.* events carry a BLU payload. The complete hub frame reaches this
// handler, so deserializing the data of unrelated events would both misread them and
// let an unrelated malformed payload abort the entire frame.
Shelly2NotifyBluEventData blu = event.startsWith(SHELLY2_EVENT_BLUPREFIX) ? e.getBluData(gson) : null;
if (event.startsWith(SHELLY2_EVENT_BLUPREFIX)) {
if (blu != null) {
logger.debug("{}: BLU event {} received from address {}, pid={} (JSON={})", thingName, event,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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.shelly.internal.api2;

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.ArrayList;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.shelly.internal.api.ShellyApiException;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2NotifyEvent;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2NotifyEventData;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2RpcNotifyEvent;
import org.openhab.binding.shelly.internal.api2.ShellyBluJsonDTO.Shelly2NotifyBluEventData;
import org.openhab.binding.shelly.internal.util.ShellyUtils;

import com.google.gson.Gson;

/**
* Tests for {@link Shelly2NotifyEvent#getBluData(Gson)} handling of the polymorphic {@code data} field.
*
* <p>
* The binding's own {@code oh-blu.*} scanner script always sends {@code data} as a JSON object, but Gen2/Gen3
* devices running Home Assistant's BLE-proxy script emit {@code NotifyEvent} frames with event
* {@code ble.scan_result} where {@code data} is a JSON array. Before {@code data} became a raw
* {@link com.google.gson.JsonElement}, Gson threw {@code Expected BEGIN_OBJECT but was BEGIN_ARRAY} while parsing
* the whole frame, discarding it and flooding the log.
* </p>
*
* @author Martin Littkovsky - Initial contribution
*/
@NonNullByDefault
public class Shelly2NotifyEventDataShapeTest {

private final Gson gson = new Gson();

@Test
void objectShapedDataParsesAndYieldsBluData() throws ShellyApiException {
String json = """
{"src":"shellyplusht-test","dst":"ohshelly-test-1","method":"NotifyEvent",
"params":{"ts":1700000000.0,"events":[{"component":"script:1","id":1,"event":"oh-blu.data",
"data":{"encryption":false,"BTHome_version":2,"pid":42,"Battery":85,
"addr":"aa:bb:cc:dd:ee:01","rssi":-70},"ts":1700000000.0}]}}
""";

Shelly2NotifyEvent event = firstEvent(json);
assertThat(event.event, is(equalTo("oh-blu.data")));

Shelly2NotifyBluEventData blu = event.getBluData(gson);
assertNotNull(blu);
assertThat(blu.addr, is(equalTo("aa:bb:cc:dd:ee:01")));
assertThat(blu.battery, is(equalTo(85)));
assertThat(blu.pid, is(equalTo(42)));
}

@Test
void arrayShapedDataParsesWithoutExceptionAndYieldsNoBluData() throws ShellyApiException {
// Home Assistant BLE-proxy script style frame: "data" is [scanType, [[addr, rssi, advData, scanRsp], ...]]
String json = """
{"src":"shellyplugsg3-aabbccddeeff","dst":"ohshelly-Test-1","method":"NotifyEvent",
"params":{"ts":1786653121.63,"events":[{"component":"script:1","id":1,"event":"ble.scan_result",
"data":[2,[["aa:bb:cc:dd:ee:01",-97,"AgEEAwMH/hT=",""],["aa:bb:cc:dd:ee:02",-86,"AgEGEQYbxdU=",""]]],
"ts":1786653121.63}]}}
""";

Shelly2NotifyEvent event = firstEvent(json);
assertThat(event.event, is(equalTo("ble.scan_result")));
assertThat(event.getBluData(gson), is(nullValue()));
}

@Test
void absentDataYieldsNoBluDataWithoutException() throws ShellyApiException {
String json = """
{"src":"shellyplusht-test","params":{"ts":1700000000.0,
"events":[{"component":"sys","id":0,"event":"config_changed","ts":1700000000.0}]}}
""";

Shelly2NotifyEvent event = firstEvent(json);
assertThat(event.event, is(equalTo("config_changed")));
assertThat(event.data, is(nullValue()));
assertThat(event.getBluData(gson), is(nullValue()));
}

@Test
void malformedObjectDataRaisesTheCheckedApiException() throws ShellyApiException {
// "pid" is numeric in the DTO; a string there makes Gson fail on an otherwise object-shaped
// payload. This has to surface as the checked ShellyApiException, the same error boundary the
// frame used to fail with, because that is what both call sites catch - a raw
// JsonSyntaxException would escape Shelly2RpcSocket.onMessage and ShellyBluApi.onNotifyEvent.
String json = """
{"src":"shellyplusht-test","dst":"ohshelly-test-1","method":"NotifyEvent",
"params":{"ts":1700000000.0,"events":[{"component":"script:1","id":1,"event":"oh-blu.data",
"data":{"addr":"aa:bb:cc:dd:ee:01","pid":"not-a-number"},"ts":1700000000.0}]}}
""";

Shelly2NotifyEvent event = firstEvent(json);
assertThat(event.event, is(equalTo("oh-blu.data")));
assertThrows(ShellyApiException.class, () -> event.getBluData(gson));
}

private Shelly2NotifyEvent firstEvent(String json) throws ShellyApiException {
Shelly2RpcNotifyEvent message = ShellyUtils.fromJson(gson, json, Shelly2RpcNotifyEvent.class);
Shelly2NotifyEventData params = message.params;
assertNotNull(params);
ArrayList<Shelly2NotifyEvent> events = params.events;
assertNotNull(events);
assertThat(events.size(), is(equalTo(1)));
return events.get(0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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.shelly.internal.api2;

import static org.mockito.Mockito.*;

import java.util.concurrent.ScheduledExecutorService;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.openhab.binding.shelly.internal.api.ShellyApiException;
import org.openhab.binding.shelly.internal.handler.ShellyThingTable;

/**
* Verifies how a single {@code NotifyEvent} frame is routed to the hub handler.
*
* <p>
* {@code onNotifyEvent()} iterates the whole frame itself, so the frame has to be forwarded exactly once no
* matter how many regular events it carries - forwarding per event would make the hub process each of them
* repeatedly. Mixed frames, carrying a third-party {@code ble.scan_result} next to regular events, only became
* parseable at all with the polymorphic {@code data} handling, which is why they are pinned here.
* </p>
*
* @author Martin Littkovsky - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@NonNullByDefault({})

Check warning on line 46 in bundles/org.openhab.binding.shelly/src/test/java/org/openhab/binding/shelly/internal/api2/Shelly2RpcSocketMixedFrameTest.java

View workflow job for this annotation

GitHub Actions / Build (Java 21, ubuntu-24.04)

Nullness default is redundant with a default specified for the enclosing package org.openhab.binding.shelly.internal.api2
class Shelly2RpcSocketMixedFrameTest {

private @Mock ShellyThingTable thingTable;
private @Mock WebSocketClient webSocketClient;
private @Mock ScheduledExecutorService scheduler;
private @Mock Shelly2RpctInterface handler;
private @Mock Session session;

private Shelly2RpcSocket socket;

@BeforeEach
void setUp() {
socket = new Shelly2RpcSocket(thingTable, true, webSocketClient, scheduler);
socket.addMessageHandler(handler);
}

private static String frame(String events) {
return "{\"src\":\"shellyplusht-test\",\"dst\":\"ohshelly-test-1\",\"method\":\"NotifyEvent\","
+ "\"params\":{\"ts\":1700000000.0,\"events\":[" + events + "]}}";
}

private static String regularEvent(String name, int id) {
return "{\"component\":\"input:" + id + "\",\"id\":" + id + ",\"event\":\"" + name + "\",\"ts\":1700000000.0}";
}

private static final String BLE_SCAN_EVENT = "{\"component\":\"script:1\",\"id\":1,"
+ "\"event\":\"ble.scan_result\",\"data\":[2,[[\"aa:bb:cc:dd:ee:01\",-97,\"AgEEAwMH/hT=\",\"\"]]],"
+ "\"ts\":1700000000.0}";

@Test
void frameWithSeveralRegularEventsIsForwardedOnce() throws ShellyApiException {
socket.onMessage(session, frame(regularEvent("btn_down", 0) + "," + regularEvent("btn_up", 0)));

verify(handler, times(1)).onNotifyEvent(anyString());
}

@Test
void mixedFrameForwardsOnceAndKeepsTheRegularEvents() throws ShellyApiException {
socket.onMessage(session,
frame(BLE_SCAN_EVENT + "," + regularEvent("btn_down", 0) + "," + regularEvent("btn_up", 0)));

// Once - not twice for the two regular events, and not at all for the scan result.
verify(handler, times(1)).onNotifyEvent(anyString());
}

@Test
void frameWithOnlyScanResultsIsNotForwarded() throws ShellyApiException {
socket.onMessage(session, frame(BLE_SCAN_EVENT + "," + BLE_SCAN_EVENT));

verify(handler, never()).onNotifyEvent(anyString());
}

@Test
void singleRegularEventIsStillForwarded() throws ShellyApiException {
socket.onMessage(session, frame(regularEvent("btn_down", 0)));

verify(handler, times(1)).onNotifyEvent(anyString());
}
}