Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -23,6 +23,8 @@
import org.openhab.binding.shelly.internal.api2.dto.ShellyCoverJsonDTO.Shelly2CoverStatus;
import org.openhab.binding.shelly.internal.api2.dto.ShellyCoverJsonDTO.Shelly2DevConfigCover;

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

/**
Expand Down Expand Up @@ -160,6 +162,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 +1230,20 @@ 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;

public @Nullable Shelly2NotifyBluEventData getBluData(Gson gson) {
JsonElement data = this.data;
return data != null && data.isJsonObject() ? gson.fromJson(data, Shelly2NotifyBluEventData.class) : null;
Comment thread
wborn marked this conversation as resolved.
Outdated
}
}

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 @@ -363,7 +363,7 @@ public void onMessage(Session session, String receivedMessage) {
} else {
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,6 +387,11 @@ 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);
Comment thread
wborn marked this conversation as resolved.
Outdated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ public void onNotifyEvent(String eventJSON) {
}
for (Shelly2NotifyEvent e : events) {
String event = getString(e.event);
Shelly2NotifyBluEventData blu = e.blu;
Shelly2NotifyBluEventData blu = e.getBluData(gson);
Comment thread
wborn marked this conversation as resolved.
Outdated
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,108 @@
/*
* 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 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()));
}

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);
}
}