[mqtt] Support Ruuvitags via Ruuvi Gateway (#13315)

Signed-off-by: Sami Salonen <ssalonen@gmail.com>
This commit is contained in:
Sami Salonen
2023-03-27 15:11:07 +03:00
committed by GitHub
parent 210aff461d
commit 18e7d81e4d
26 changed files with 2692 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.mqtt.ruuvigateway-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-mqtt-ruuvigateway" description="MQTT Binding Ruuvi Gateway" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-mqtt</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version}</bundle>
<bundle start-level="81">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}</bundle>
<bundle start-level="82">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.ruuvigateway/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,56 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal;
import java.time.Instant;
import java.time.ZoneId;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.generic.ChannelConfig;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.binding.mqtt.generic.values.DateTimeValue;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.thing.ChannelUID;
/**
* Simplified state cache for purposes of caching DateTime values
*
* Unlike parent class {@link ChannelState}, this class by definition is not interacting with MQTT subscriptions nor
* does it update any channels
*
* @author Sami Salonen - Initial contribution
*/
@NonNullByDefault
public class RuuviCachedDateTimeState extends ChannelState {
private static final ZoneId UTC = ZoneId.of("UTC");
/**
* Construct cache for DateTime values
*
* @param channelUID associated channel UID
*
*/
public RuuviCachedDateTimeState(ChannelUID channelUID) {
super(new ChannelConfig(), channelUID, new DateTimeValue(), null);
}
/**
* Update cached state with given value
*
* @param value instant representing value
*/
public void update(Instant value) {
cachedValue.update(new DateTimeType(value.atZone(UTC)));
}
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal;
import java.util.Optional;
import javax.measure.Quantity;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.generic.ChannelConfig;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.binding.mqtt.generic.values.NumberValue;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.ChannelUID;
/**
* Simplified state cache for purposes of caching QuantityType and DecimalType values
*
* Unlike parent class {@link ChannelState}, this class by definition is not interacting with MQTT subscriptions nor
* does it update any channels
*
* @author Sami Salonen - Initial contribution
*/
@NonNullByDefault
public class RuuviCachedNumberState<T extends Quantity<T>> extends ChannelState {
private final Optional<Unit<T>> unit;
/**
* Construct cache for numbers with unit
*
* @param channelUID associated channel UID
* @param unit unit associated with updated numbers
*
*/
public RuuviCachedNumberState(ChannelUID channelUID, Unit<T> unit) {
super(new ChannelConfig(), channelUID, new NumberValue(null, null, null, unit), null);
this.unit = Optional.of(unit);
}
/**
* Construct cache for numbers without unit
*
* @param channelUID associated channeld UID
*/
public RuuviCachedNumberState(ChannelUID channelUID) {
super(new ChannelConfig(), channelUID, new NumberValue(null, null, null, null), null);
this.unit = Optional.empty();
}
/**
* Update cached state with given value
*
* @param value value. Specified as plain number with unit given in constructor
*/
public void update(Number value) {
unit.ifPresentOrElse(unit -> cachedValue.update(new QuantityType<>(value, unit)),
() -> cachedValue.update(new DecimalType(value)));
}
/**
* Get associated unit with this cache
*
* @return unit associated with this (if applicable)
*/
public Optional<Unit<T>> getUnit() {
return unit;
}
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.generic.ChannelConfig;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.ChannelUID;
/**
* Simplified state cache for purposes of caching StringType values
*
* Unlike parent class {@link ChannelState}, this class by definition is not interacting with MQTT subscriptions nor
* does it update any channels
*
* @author Sami Salonen - Initial contribution
*/
@NonNullByDefault
public class RuuviCachedStringState extends ChannelState {
/**
* Construct cache for Strings
*
* @param channelUID associated channel UID
*
*/
public RuuviCachedStringState(ChannelUID channelUID) {
super(new ChannelConfig(), channelUID, new TextValue(), null);
}
/**
* Update cached state with given value
*
* @param value value. Specified as plain number with unit given in constructor
*/
public void update(String value) {
cachedValue.update(new StringType(value));
}
}

View File

@@ -0,0 +1,62 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal;
import static org.openhab.binding.mqtt.MqttBindingConstants.BINDING_ID;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link RuuviGatewayBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Matthew Skinner - Initial contribution
*/
@NonNullByDefault
public class RuuviGatewayBindingConstants {
public static final String BASE_TOPIC = "ruuvi/";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_BEACON = new ThingTypeUID(BINDING_ID, "ruuvitag_beacon");
// Channel IDs
public static final String CHANNEL_ID_BATTERY = "batteryVoltage";
public static final String CHANNEL_ID_DATA_FORMAT = "dataFormat";
public static final String CHANNEL_ID_TEMPERATURE = "temperature";
public static final String CHANNEL_ID_HUMIDITY = "humidity";
public static final String CHANNEL_ID_PRESSURE = "pressure";
public static final String CHANNEL_ID_TX_POWER = "txPower";
public static final String CHANNEL_ID_ACCELERATIONX = "accelerationx";
public static final String CHANNEL_ID_ACCELERATIONY = "accelerationy";
public static final String CHANNEL_ID_ACCELERATIONZ = "accelerationz";
public static final String CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER = "measurementSequenceNumber";
public static final String CHANNEL_ID_MOVEMENT_COUNTER = "movementCounter";
public static final String CHANNEL_ID_RSSI = "rssi";
public static final String CHANNEL_ID_TS = "ts";
public static final String CHANNEL_ID_GWTS = "gwts";
public static final String CHANNEL_ID_GWMAC = "gwmac";
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BEACON);
public static final int RUUVI_GATEWAY_SUBSCRIBE_TIMEOUT_MS = 30000;
// Thing properties
public static final String PROPERTY_TAG_ID = "tagID";
public static final String CONFIGURATION_PROPERTY_TOPIC = "topic";
public static final String CONFIGURATION_PROPERTY_TIMEOUT = "timeout"; // only for tests
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal;
import static org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.ruuvigateway.internal.handler.RuuviTagHandler;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Component;
/**
* The {@link RuuviTagHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Matthew Skinner - Initial contribution
*/
@Component(service = ThingHandlerFactory.class)
@NonNullByDefault
public class RuuviTagHandlerFactory extends BaseThingHandlerFactory {
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
return new RuuviTagHandler(thing, RUUVI_GATEWAY_SUBSCRIBE_TIMEOUT_MS);
}
return null;
}
}

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal.discovery;
import static org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants.*;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.discovery.AbstractMQTTDiscovery;
import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link RuuviGatewayDiscoveryService} is responsible for finding Ruuvi Tag Sensors
* and setting them up for the handlers.
*
* @author Matthew Skinner - Initial contribution
* @author Sami Salonen - Adaptation to Ruuvi Gateway
*/
@Component(service = DiscoveryService.class, configurationPid = "discovery.mqttruuvigateway")
@NonNullByDefault
public class RuuviGatewayDiscoveryService extends AbstractMQTTDiscovery {
protected final MQTTTopicDiscoveryService discoveryService;
private static final Predicate<String> HEX_PATTERN_CHECKER = Pattern
.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$").asMatchPredicate();
@Activate
public RuuviGatewayDiscoveryService(@Reference MQTTTopicDiscoveryService discoveryService) {
super(SUPPORTED_THING_TYPES_UIDS, 3, true, BASE_TOPIC + "#");
this.discoveryService = discoveryService;
}
@Override
protected MQTTTopicDiscoveryService getDiscoveryService() {
return discoveryService;
}
@Override
public void receivedMessage(ThingUID connectionBridge, MqttBrokerConnection connection, String topic,
byte[] payload) {
resetTimeout();
if (topic.startsWith(BASE_TOPIC)) {
String cutTopic = topic.replace(BASE_TOPIC, "");
int index = cutTopic.lastIndexOf("/");
if (index != -1) // -1 means "not found"
{
String tagMacAddress = cutTopic.substring(index + 1);
if (looksLikeMac(tagMacAddress)) {
publishDevice(connectionBridge, connection, topic, tagMacAddress);
}
}
}
}
void publishDevice(ThingUID connectionBridge, MqttBrokerConnection connection, String topic, String tagMacAddress) {
Map<String, Object> properties = new HashMap<>();
String thingID = tagMacAddress.toLowerCase().replaceAll("[:-]", "");
String normalizedTagID = normalizedTagID(tagMacAddress);
properties.put(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC, topic);
properties.put(RuuviGatewayBindingConstants.PROPERTY_TAG_ID, normalizedTagID);
properties.put(Thing.PROPERTY_VENDOR, "Ruuvi Innovations Ltd (Oy)");
// Discovered things are identified with their topic name, in case of having pathological case
// where we find multiple tags with same mac address (e.g. ruuvi/gw1/mac1 and ruuvi/gw2/mac1)
thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_BEACON, connectionBridge, thingID))
.withProperties(properties)
.withRepresentationProperty(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC)
.withBridge(connectionBridge).withLabel("MQTT Ruuvi Tag " + normalizedTagID).build());
}
@Override
public void topicVanished(ThingUID connectionBridge, MqttBrokerConnection connection, String topic) {
}
private boolean looksLikeMac(String topic) {
return HEX_PATTERN_CHECKER.test(topic);
}
private static String normalizedTagID(String mac) {
String nondelimited = mac.toUpperCase().replaceAll("[:-]", "");
assert nondelimited.length() == 12; // Invariant: method to be used only with valid Ruuvi MACs
return nondelimited.subSequence(0, 2) + ":" + nondelimited.subSequence(2, 4) + ":"
+ nondelimited.subSequence(4, 6) + ":" + nondelimited.subSequence(6, 8) + ":"
+ nondelimited.subSequence(8, 10) + ":" + nondelimited.subSequence(10, 12);
}
}

View File

@@ -0,0 +1,431 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal.handler;
import static org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants.*;
import java.lang.reflect.InvocationTargetException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import javax.measure.Quantity;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.AbstractMQTTThingHandler;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviCachedDateTimeState;
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviCachedNumberState;
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviCachedStringState;
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants;
import org.openhab.binding.mqtt.ruuvigateway.internal.parser.GatewayPayloadParser;
import org.openhab.binding.mqtt.ruuvigateway.internal.parser.GatewayPayloadParser.GatewayPayload;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.UnDefType;
import org.openhab.core.util.HexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonSyntaxException;
/**
* The {@link RuuviTagHandler} is responsible updating RuuviTag Sensor data received from
* Ruuvi Gateway via MQTT.
*
* @author Sami Salonen - Initial contribution
*/
@NonNullByDefault
public class RuuviTagHandler extends AbstractMQTTThingHandler implements MqttMessageSubscriber {
// Ruuvitag sends an update every 10 seconds. So we keep a heartbeat to give it some slack
private int heartbeatTimeoutMillisecs = 60_000;
// This map is used to initialize channel caches.
// Key is channel ID.
// Value is one of the following
// - null (plain number), uses RuuviCachedNumberState
// - Unit (QuantityType Number), uses RuuviCachedNumberState with unit
// - Class object, uses given class object with String constructor
private static final Map<String, @Nullable Object> unitByChannelUID = new HashMap<>(11);
static {
unitByChannelUID.put(CHANNEL_ID_ACCELERATIONX, Units.STANDARD_GRAVITY);
unitByChannelUID.put(CHANNEL_ID_ACCELERATIONY, Units.STANDARD_GRAVITY);
unitByChannelUID.put(CHANNEL_ID_ACCELERATIONZ, Units.STANDARD_GRAVITY);
unitByChannelUID.put(CHANNEL_ID_BATTERY, Units.VOLT);
unitByChannelUID.put(CHANNEL_ID_DATA_FORMAT, null);
unitByChannelUID.put(CHANNEL_ID_HUMIDITY, Units.PERCENT);
unitByChannelUID.put(CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER, Units.ONE);
unitByChannelUID.put(CHANNEL_ID_MOVEMENT_COUNTER, Units.ONE);
unitByChannelUID.put(CHANNEL_ID_PRESSURE, SIUnits.PASCAL);
unitByChannelUID.put(CHANNEL_ID_TEMPERATURE, SIUnits.CELSIUS);
unitByChannelUID.put(CHANNEL_ID_TX_POWER, Units.DECIBEL_MILLIWATTS);
unitByChannelUID.put(CHANNEL_ID_RSSI, Units.DECIBEL_MILLIWATTS);
unitByChannelUID.put(CHANNEL_ID_TS, RuuviCachedDateTimeState.class);
unitByChannelUID.put(CHANNEL_ID_GWTS, RuuviCachedDateTimeState.class);
unitByChannelUID.put(CHANNEL_ID_GWMAC, RuuviCachedStringState.class);
}
private final Logger logger = LoggerFactory.getLogger(RuuviTagHandler.class);
/**
* Indicator whether we have received data recently
*/
private final AtomicBoolean receivedData = new AtomicBoolean();
private final Map<ChannelUID, ChannelState> channelStateByChannelUID = new HashMap<>();
private @NonNullByDefault({}) ScheduledFuture<?> heartbeatFuture;
/**
* Topic with data for this particular Ruuvi Tag. Set in initialize (when configuration is valid).
*/
private @NonNullByDefault({}) String topic;
public RuuviTagHandler(Thing thing, int subscribeTimeout) {
super(thing, subscribeTimeout);
}
@Override
public void initialize() {
initializeChannelCaches();
Configuration configuration = getThing().getConfiguration();
String topic = (String) configuration.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC);
if (topic == null || topic.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.configuration-error.missing-topic");
return;
}
Object timeout = configuration.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TIMEOUT);
if (timeout != null) {
// Note: only in tests
heartbeatTimeoutMillisecs = Integer.parseInt(timeout.toString());
logger.warn("Using overridden timeout: {}", heartbeatTimeoutMillisecs);
}
this.topic = topic;
super.initialize();
}
private void initializeChannelCaches() {
for (Channel channel : thing.getChannels()) {
ChannelUID channelUID = channel.getUID();
String channelID = channelUID.getId();
assert unitByChannelUID.containsKey(channelID); // Invariant as all channels should exist in the static map
Object cacheHint = unitByChannelUID.get(channelID);
if (cacheHint == null || cacheHint instanceof Unit<?>) {
Unit<?> unit = (Unit<?>) cacheHint;
initNumberStateCache(channelUID, unit);
} else {
Class<?> cacheType = (Class<?>) cacheHint;
initCacheWithClass(channelUID, cacheType);
}
}
}
private <T extends Quantity<T>> RuuviCachedNumberState<?> initNumberStateCache(ChannelUID channelUID,
@Nullable Unit<T> unit) {
final RuuviCachedNumberState<?> cached;
if (unit == null) {
cached = new RuuviCachedNumberState<>(channelUID);
channelStateByChannelUID.put(channelUID, cached);
} else {
cached = new RuuviCachedNumberState<>(channelUID, unit);
channelStateByChannelUID.put(channelUID, cached);
}
return cached;
}
private ChannelState initCacheWithClass(ChannelUID channelUID, Class<?> clazz) {
try {
ChannelState cached = (ChannelState) clazz.getConstructor(ChannelUID.class).newInstance(channelUID);
Objects.requireNonNull(cached); // to make compiler happy
channelStateByChannelUID.put(channelUID, cached);
return cached;
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException
| NoSuchMethodException | SecurityException e) {
throw new IllegalStateException(e);
}
}
@Override
protected CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection) {
if (topic == null) {
// Initialization has not been completed successfully, return early without changing
// thing status
return CompletableFuture.completedFuture(null);
}
updateStatus(ThingStatus.UNKNOWN);
return connection.subscribe(topic, this).handle((subscriptionSuccess, subscriptionException) -> {
if (subscriptionSuccess) {
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/online.waiting-initial-data");
heartbeatFuture = scheduler.scheduleWithFixedDelay(this::heartbeat, heartbeatTimeoutMillisecs,
heartbeatTimeoutMillisecs, TimeUnit.MILLISECONDS);
} else {
if (subscriptionException == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.communication-error.mqtt-subscription-failed");
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.communication-error.mqtt-subscription-failed-details [\""
+ subscriptionException.getClass().getSimpleName() + "\", \""
+ subscriptionException.getMessage() + "\"]");
}
}
return null;
});
}
@Override
public CompletableFuture<Void> unsubscribeAll() {
MqttBrokerConnection localConnection = connection;
String localTopic = topic;
if (localConnection != null && localTopic != null) {
return localConnection.unsubscribe(localTopic, this).thenCompose(unsubscribeSuccessful -> null);
} else {
return CompletableFuture.completedFuture(null);
}
}
@Override
protected void stop() {
ScheduledFuture<?> localHeartbeatFuture = heartbeatFuture;
if (localHeartbeatFuture != null) {
localHeartbeatFuture.cancel(true);
heartbeatFuture = null;
}
channelStateByChannelUID.values().forEach(c -> c.getCache().resetState());
super.stop();
}
@Override
public void dispose() {
super.dispose();
channelStateByChannelUID.clear();
}
/**
* Called regularly. Tries to set receivedData to false. If it was already false and thing is ONLINE,
* update thing as OFFLINE with COMMUNICATION_ERROR.
*/
private void heartbeat() {
synchronized (receivedData) {
if (!receivedData.getAndSet(false) && getThing().getStatus() == ThingStatus.ONLINE) {
getThing().getChannels().stream().map(Channel::getUID).filter(this::isLinked)
.forEach(c -> updateChannelState(c, UnDefType.UNDEF));
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.communication-error.timeout");
}
}
}
@Override
public void processMessage(String topic, byte[] payload) {
receivedData.set(true);
final GatewayPayload parsed;
try {
parsed = GatewayPayloadParser.parse(payload);
} catch (JsonSyntaxException | IllegalArgumentException e) {
// Perhaps thing has been configured with wrong topic. Logging extra details with trace
// Thing status change will be visible in logs with higher log level
logger.trace("Received invalid data which could not be parsed to any known Ruuvi Tag data formats ({}): {}",
e.getMessage(), new String(payload, StandardCharsets.UTF_8));
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.communication-error.parse-error [\"" + e.getMessage() + "\"]");
return;
}
var ruuvitagData = parsed.measurement;
boolean atLeastOneRuuviFieldPresent = false;
for (Channel channel : thing.getChannels()) {
ChannelUID channelUID = channel.getUID();
switch (channelUID.getId()) {
case CHANNEL_ID_ACCELERATIONX:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getAccelerationX());
break;
case CHANNEL_ID_ACCELERATIONY:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getAccelerationY());
break;
case CHANNEL_ID_ACCELERATIONZ:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getAccelerationZ());
break;
case CHANNEL_ID_BATTERY:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getBatteryVoltage());
break;
case CHANNEL_ID_DATA_FORMAT:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getDataFormat());
break;
case CHANNEL_ID_HUMIDITY:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getHumidity());
break;
case CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID,
ruuvitagData.getMeasurementSequenceNumber());
break;
case CHANNEL_ID_MOVEMENT_COUNTER:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getMovementCounter());
break;
case CHANNEL_ID_PRESSURE:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getPressure());
break;
case CHANNEL_ID_TEMPERATURE:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getTemperature());
break;
case CHANNEL_ID_TX_POWER:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getTxPower());
break;
//
// Auxiliary channels, not part of bluetooth advertisement
//
case CHANNEL_ID_RSSI:
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, parsed.rssi);
break;
case CHANNEL_ID_TS:
atLeastOneRuuviFieldPresent |= updateDateTimeStateIfLinked(channelUID, parsed.ts);
break;
case CHANNEL_ID_GWTS:
atLeastOneRuuviFieldPresent |= updateDateTimeStateIfLinked(channelUID, parsed.gwts);
break;
case CHANNEL_ID_GWMAC:
atLeastOneRuuviFieldPresent |= updateStringStateIfLinked(channelUID, parsed.gwMac);
break;
default:
logger.warn("BUG: We have unhandled channel: {}",
thing.getChannels().stream().map(Channel::getUID).collect(Collectors.toList()));
}
}
if (atLeastOneRuuviFieldPresent) {
String thingStatusDescription = getThing().getStatusInfo().getDescription();
if (getThing().getStatus() != ThingStatus.ONLINE
|| (thingStatusDescription != null && !thingStatusDescription.isBlank())) {
// Update thing as ONLINE and possibly clear the thing detail status
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
}
} else {
if (logger.isTraceEnabled()) {
logger.trace("Received Ruuvi Tag data but no fields could be parsed: {}", HexUtils.bytesToHex(payload));
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.communication-error.parse-error-no-fields");
}
}
@Override
public @Nullable ChannelState getChannelState(ChannelUID channelUID) {
return channelStateByChannelUID.get(channelUID);
}
@Override
protected void updateThingStatus(boolean messageReceived, Optional<Boolean> availabilityTopicsSeen) {
// Not used here
}
/**
* Update number channel state
*
* Update is not done when value is null.
*
* @param channelUID channel UID
* @param value value to update
* @return whether the value was present
*/
private boolean updateStateIfLinked(ChannelUID channelUID, @Nullable Number value) {
RuuviCachedNumberState<?> cache = (RuuviCachedNumberState<?>) channelStateByChannelUID.get(channelUID);
if (cache == null) {
// Invariant as channels should be initialized already
logger.warn("Channel {} not initialized. BUG", channelUID);
return false;
}
if (value == null) {
return false;
} else {
cache.update(value);
if (isLinked(channelUID)) {
updateChannelState(channelUID, cache.getCache().getChannelState());
}
return true;
}
}
/**
* Update string channel state
*
* Update is not done when value is null.
*
* @param channelUID channel UID
* @param value value to update
* @return whether the value was present
*/
private <T extends Quantity<T>> boolean updateStringStateIfLinked(ChannelUID channelUID, Optional<String> value) {
RuuviCachedStringState cache = (RuuviCachedStringState) channelStateByChannelUID.get(channelUID);
if (cache == null) {
// Invariant as channels should be initialized already
logger.error("Channel {} not initialized. BUG", channelUID);
return false;
}
if (value.isEmpty()) {
return false;
} else {
cache.update(value.get());
if (isLinked(channelUID)) {
updateChannelState(channelUID, cache.getCache().getChannelState());
}
return true;
}
}
/**
* Update date time channel state
*
* Update is not done when value is null.
*
* @param channelUID channel UID
* @param value value to update
* @return whether the value was present
*/
private boolean updateDateTimeStateIfLinked(ChannelUID channelUID, Optional<Instant> value) {
RuuviCachedDateTimeState cache = (RuuviCachedDateTimeState) channelStateByChannelUID.get(channelUID);
if (cache == null) {
// Invariant as channels should be initialized already
logger.error("Channel {} not initialized. BUG", channelUID);
return false;
}
if (value.isEmpty()) {
return false;
} else {
cache.update(value.get());
if (isLinked(channelUID)) {
updateChannelState(channelUID, cache.getCache().getChannelState());
}
return true;
}
}
}

View File

@@ -0,0 +1,176 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal.parser;
import java.nio.charset.StandardCharsets;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.util.HexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import fi.tkgwf.ruuvi.common.bean.RuuviMeasurement;
import fi.tkgwf.ruuvi.common.parser.impl.AnyDataFormatParser;
/**
* The {@link GatewayPayloadParser} is responsible for parsing Ruuvi Gateway MQTT JSON payloads.
*
* @author Sami Salonen - Initial contribution
*/
@NonNullByDefault
public class GatewayPayloadParser {
private static final Logger logger = LoggerFactory.getLogger(GatewayPayloadParser.class);
private static final Gson GSON = new GsonBuilder().create();
private static final AnyDataFormatParser parser = new AnyDataFormatParser();
private static final Predicate<String> HEX_PATTERN_CHECKER = Pattern.compile("^([0-9A-Fa-f]{2})+$")
.asMatchPredicate();
/**
* JSON MQTT payload sent by Ruuvi Gateway
*
* See https://docs.ruuvi.com/gw-data-formats/mqtt-time-stamped-data-from-bluetooth-sensors
*
* @author Sami Salonen - Initial contribution
*
*/
public static class GatewayPayload {
/**
* MAC-address of Ruuvi Gateway
*/
public Optional<String> gwMac = Optional.empty();
/**
* RSSI
*/
public int rssi;
/**
* Timestamp when the message from Bluetooth-sensor was relayed by Gateway
*
*/
public Optional<Instant> gwts = Optional.empty();
/**
* Timestamp (Unix-time) when the message from Bluetooth-sensor was received by Gateway
*
*/
public Optional<Instant> ts = Optional.empty();
public RuuviMeasurement measurement;
private GatewayPayload(GatewayPayloadIntermediate intermediate) throws IllegalArgumentException {
String gwMac = intermediate.gw_mac;
if (gwMac == null) {
logger.trace("Missing mandatory field 'gw_mac', ignoring");
}
this.gwMac = Optional.ofNullable(gwMac);
rssi = intermediate.rssi;
try {
gwts = Optional.of(Instant.ofEpochSecond(intermediate.gwts));
} catch (DateTimeException e) {
logger.debug("Field 'gwts' is a not valid time (epoch second), ignoring: {}", intermediate.gwts);
}
try {
ts = Optional.of(Instant.ofEpochSecond(intermediate.ts));
} catch (DateTimeException e) {
logger.debug("Field 'ts' is a not valid time (epoch second), ignoring: {}", intermediate.ts);
}
String localData = intermediate.data;
if (localData == null) {
throw new IllegalArgumentException("Missing mandatory field 'data'");
}
if (!HEX_PATTERN_CHECKER.test(localData)) {
logger.debug(
"Data is not representing manufacturer specific bluetooth advertisement, it is not valid hex: {}",
localData);
throw new IllegalArgumentException(
"Data is not representing manufacturer specific bluetooth advertisement, it is not valid hex: "
+ localData);
}
byte[] bytes = HexUtils.hexToBytes(localData);
if (bytes.length < 6) {
// We want at least 6 bytes, ensuring bytes[5] is valid as well as Arrays.copyOfRange(bytes, 5, ...)
// below
// The payload length (might depend on format version ) is validated by parser.parse call
throw new IllegalArgumentException("Manufacturerer data is too short");
}
if ((bytes[4] & 0xff) != 0xff) {
logger.debug("Data is not representing manufacturer specific bluetooth advertisement: {}",
HexUtils.bytesToHex(bytes));
throw new IllegalArgumentException(
"Data is not representing manufacturer specific bluetooth advertisement");
}
// Manufacturer data starts after 0xFF byte, at index 5
byte[] manufacturerData = Arrays.copyOfRange(bytes, 5, bytes.length);
RuuviMeasurement localManufacturerData = parser.parse(manufacturerData);
if (localManufacturerData == null) {
logger.trace("Manufacturer data is not valid: {}", HexUtils.bytesToHex(manufacturerData));
throw new IllegalArgumentException("Manufacturer data is not valid");
}
measurement = localManufacturerData;
}
}
/**
*
* JSON MQTT payload sent by Ruuvi Gateway (intermediate representation).
*
* This intermediate representation tries to match the low level JSON, making little data validation and conversion.
*
* Fields are descibed in https://docs.ruuvi.com/gw-data-formats/mqtt-time-stamped-data-from-bluetooth-sensors
*
* Fields are marked as nullable as GSON might apply nulls at runtime.
*
* @author Sami Salonen - Initial Contribution
* @see GatewayPayload Equivalent of this class but with additional data validation and typing
*
*/
private static class GatewayPayloadIntermediate {
public @Nullable String gw_mac;
public int rssi;
public long gwts;
public long ts;
public @Nullable String data;
}
/**
* Parse MQTT JSON payload advertised by Ruuvi Gateway
*
* @param jsonPayload json payload of the Ruuvi sensor MQTT topic, as bytes
* @return parsed payload
* @throws JsonSyntaxException raised with JSON syntax exceptions and clearly invalid JSON types
* @throws IllegalArgumentException raised with invalid or unparseable data
*/
public static GatewayPayload parse(byte[] jsonPayload) throws JsonSyntaxException, IllegalArgumentException {
String jsonPayloadString = new String(jsonPayload, StandardCharsets.UTF_8);
GatewayPayloadIntermediate payloadIntermediate = GSON.fromJson(jsonPayloadString,
GatewayPayloadIntermediate.class);
if (payloadIntermediate == null) {
throw new JsonSyntaxException("JSON parsing failed");
}
GatewayPayload payload = new GatewayPayload(payloadIntermediate);
return payload;
}
}

View File

@@ -0,0 +1,43 @@
# thing types
thing-type.mqtt.ruuvitag_beacon.label = RuuviTag SmartBeacon
thing-type.mqtt.ruuvitag_beacon.description = A RuuviTag SmartBeacon
# thing types config
thing-type.config.mqtt.ruuvitag_beacon.topic.label = MQTT topic
thing-type.config.mqtt.ruuvitag_beacon.topic.description = MQTT topic containing the payload
# channel types
channel-type.mqtt.ruuvitag_accelerationx.label = Acceleration X
channel-type.mqtt.ruuvitag_accelerationy.label = Acceleration Y
channel-type.mqtt.ruuvitag_accelerationz.label = Acceleration Z
channel-type.mqtt.ruuvitag_batteryVoltage.label = Battery Voltage
channel-type.mqtt.ruuvitag_dataFormat.label = Data Format Version
channel-type.mqtt.ruuvitag_gwmac.label = Gateway MAC Address
channel-type.mqtt.ruuvitag_gwmac.description = MAC-address of Ruuvi Gateway
channel-type.mqtt.ruuvitag_gwts.label = Relay Timestamp
channel-type.mqtt.ruuvitag_gwts.description = Timestamp when the message from Bluetooth sensor was relayed by Gateway (gwts)
channel-type.mqtt.ruuvitag_gwts.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS
channel-type.mqtt.ruuvitag_humidity.label = Humidity
channel-type.mqtt.ruuvitag_measurementSequenceNumber.label = Measurement Sequence Number
channel-type.mqtt.ruuvitag_movementCounter.label = Movement Counter
channel-type.mqtt.ruuvitag_pressure.label = Pressure
channel-type.mqtt.ruuvitag_rssi.label = RSSI
channel-type.mqtt.ruuvitag_rssi.description = Received signal strength indicator
channel-type.mqtt.ruuvitag_temperature.label = Temperature
channel-type.mqtt.ruuvitag_ts.label = Timestamp
channel-type.mqtt.ruuvitag_ts.description = Timestamp when the message from Bluetooth sensor was received by Gateway (ts)
channel-type.mqtt.ruuvitag_ts.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS
channel-type.mqtt.ruuvitag_txPower.label = TX Power
# Thing status messages
online.waiting-initial-data = Waiting for initial data
offline.configuration-error.missing-topic = Missing topic configuration, cannot subscribe to relevant MQTT topic
offline.communication-error.mqtt-subscription-failed = MQTT subscription failed
offline.communication-error.mqtt-subscription-failed-details = MQTT subscription failed, {0}: {1}
offline.communication-error.timeout = No valid data received for some time
offline.communication-error.parse-error = Received Bluetooth data which could not be parsed to any known Ruuvi Tag data formats ({0})
offline.communication-error.parse-error-no-fields = Received Ruuvi Tag data but no fields could be parsed

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="mqtt"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="ruuvitag_beacon">
<supported-bridge-type-refs>
<bridge-type-ref id="broker"/>
</supported-bridge-type-refs>
<label>RuuviTag SmartBeacon</label>
<description>A RuuviTag SmartBeacon</description>
<channels>
<channel id="rssi" typeId="ruuvitag_rssi"/>
<channel id="ts" typeId="ruuvitag_ts"/>
<channel id="gwts" typeId="ruuvitag_gwts"/>
<channel id="gwmac" typeId="ruuvitag_gwmac"/>
<channel id="accelerationx" typeId="ruuvitag_accelerationx"/>
<channel id="accelerationy" typeId="ruuvitag_accelerationy"/>
<channel id="accelerationz" typeId="ruuvitag_accelerationz"/>
<channel id="batteryVoltage" typeId="ruuvitag_batteryVoltage"/>
<channel id="dataFormat" typeId="ruuvitag_dataFormat"/>
<channel id="humidity" typeId="ruuvitag_humidity"/>
<channel id="measurementSequenceNumber" typeId="ruuvitag_measurementSequenceNumber"/>
<channel id="movementCounter" typeId="ruuvitag_movementCounter"/>
<channel id="pressure" typeId="ruuvitag_pressure"/>
<channel id="temperature" typeId="ruuvitag_temperature"/>
<channel id="txPower" typeId="ruuvitag_txPower"/>
</channels>
<config-description>
<parameter name="topic" type="text">
<label>MQTT Topic</label>
<description>MQTT topic containing the payload</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="ruuvitag_rssi">
<item-type>Number</item-type>
<label>RSSI</label>
<description>Received signal strength indicator</description>
<category>QualityOfService</category>
<state readOnly="true" pattern="%d dBm"/>
</channel-type>
<channel-type id="ruuvitag_ts">
<item-type>DateTime</item-type>
<label>Timestamp</label>
<description>Timestamp when the message from Bluetooth sensor was received by Gateway (ts)</description>
<category>Time</category>
<state readOnly="true" pattern="%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"/>
</channel-type>
<channel-type id="ruuvitag_gwts" advanced="true">
<item-type>DateTime</item-type>
<label>Relay Timestamp</label>
<description>Timestamp when the message from Bluetooth sensor was relayed by Gateway (gwts)</description>
<category>Time</category>
<state readOnly="true" pattern="%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"/>
</channel-type>
<channel-type id="ruuvitag_gwmac" advanced="true">
<item-type>String</item-type>
<label>Gateway MAC Address</label>
<description>MAC-address of Ruuvi Gateway</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="ruuvitag_accelerationx">
<item-type>Number:Acceleration</item-type>
<label>Acceleration X</label>
<state readOnly="true" pattern="%.3f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_accelerationy">
<item-type>Number:Acceleration</item-type>
<label>Acceleration Y</label>
<state readOnly="true" pattern="%.3f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_accelerationz">
<item-type>Number:Acceleration</item-type>
<label>Acceleration Z</label>
<state readOnly="true" pattern="%.3f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_batteryVoltage">
<item-type>Number:ElectricPotential</item-type>
<label>Battery Voltage</label>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_dataFormat" advanced="true">
<item-type>Number</item-type>
<label>Data Format Version</label>
<state readOnly="true" pattern="%.0f"/>
</channel-type>
<channel-type id="ruuvitag_humidity">
<item-type>Number:Dimensionless</item-type>
<label>Humidity</label>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_measurementSequenceNumber" advanced="true">
<item-type>Number:Dimensionless</item-type>
<label>Measurement Sequence Number</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_movementCounter" advanced="true">
<item-type>Number:Dimensionless</item-type>
<label>Movement Counter</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_pressure">
<item-type>Number:Pressure</item-type>
<label>Pressure</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_temperature">
<item-type>Number:Temperature</item-type>
<label>Temperature</label>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="ruuvitag_txPower">
<item-type>Number:Power</item-type>
<label>TX Power</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,165 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal.discovery;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Collection;
import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.mqtt.MqttBindingConstants;
import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants;
import org.openhab.core.config.discovery.DiscoveryListener;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
/**
* Tests for {@link RuuviGatewayDiscoveryService}
*
* @author Anton Kharuzhy - Initial contribution
* @author Sami Salonen - Adapted from Home Assistant to Ruuvi Gateway tests
*/
@ExtendWith(MockitoExtension.class)
@NonNullByDefault
public class RuuviGatewayDiscoveryTests {
private @NonNullByDefault({}) RuuviGatewayDiscoveryService discovery;
private static final ThingUID MQTT_BRIDGE_UID = new ThingUID(MqttBindingConstants.BRIDGE_TYPE_BROKER, "broker");
private @Mock @NonNullByDefault({}) MQTTTopicDiscoveryService mqttTopicDiscoveryService;
private @Mock @NonNullByDefault({}) MqttBrokerConnection mqttConnection;
@BeforeEach
public void beforeEach() {
discovery = new RuuviGatewayDiscoveryService(mqttTopicDiscoveryService);
}
@ParameterizedTest
@ValueSource(strings = { "de:ea:DB:be:ff:00", "de:ea:DB:be:ff-00", "de-ea-DB-be-ff-00" })
public void testDiscoveryMacFormatPermutations(String leafTopic) throws Exception {
var discoveryListener = new LatchDiscoveryListener();
var latch = discoveryListener.createWaitForThingsDiscoveredLatch(1);
// When discover one thing with two channels
discovery.addDiscoveryListener(discoveryListener);
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/" + leafTopic, "{}".getBytes());
// Then one thing found
assertTrue(latch.await(3, TimeUnit.SECONDS));
var discoveryResults = discoveryListener.getDiscoveryResults();
assertThat(discoveryResults.size(), is(1));
@Nullable
DiscoveryResult result = discoveryResults.get(0);
Objects.requireNonNull(result); // Make compiler happy
assertThat(result.getBridgeUID(), is(MQTT_BRIDGE_UID));
assertThat(result.getProperties().get(Thing.PROPERTY_VENDOR), is("Ruuvi Innovations Ltd (Oy)"));
assertThat(result.getProperties().get(RuuviGatewayBindingConstants.PROPERTY_TAG_ID), is("DE:EA:DB:BE:FF:00"));
assertThat(result.getProperties().get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC),
is("ruuvi/foo/bar/" + leafTopic));
}
@Test
public void testDiscoveryMultipleThings() throws Exception {
var discoveryListener = new LatchDiscoveryListener();
var latch = discoveryListener.createWaitForThingsDiscoveredLatch(2);
discovery.addDiscoveryListener(discoveryListener);
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "something/to/ignore/ruuvi/foo/bar/invalid:mac",
"{}".getBytes());
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/invalid:mac", "{}".getBytes());
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/aa:bb", "{}".getBytes()); // too short
// mac
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/de:ea:DB:be:ff:00", "{}".getBytes());
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/de:ea:DB:be:ff:01", "{}".getBytes());
// Then one thing found
assertTrue(latch.await(3, TimeUnit.SECONDS));
var discoveryResults = discoveryListener.getDiscoveryResults();
assertThat(discoveryResults.size(), is(2));
assertTrue(discoveryResults.stream().allMatch(result -> {
assertThat(result.getBridgeUID(), is(MQTT_BRIDGE_UID));
assertThat(result.getProperties().get(Thing.PROPERTY_VENDOR), is("Ruuvi Innovations Ltd (Oy)"));
return true;
}));
assertTrue(//
discoveryResults.stream().anyMatch(result -> {
return "DE:EA:DB:BE:FF:00"
.equals(result.getProperties().get(RuuviGatewayBindingConstants.PROPERTY_TAG_ID))
&& "ruuvi/foo/bar/de:ea:DB:be:ff:00".equals(result.getProperties()
.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC));
}) && //
discoveryResults.stream().anyMatch(result -> {
return "DE:EA:DB:BE:FF:01"
.equals(result.getProperties().get(RuuviGatewayBindingConstants.PROPERTY_TAG_ID))
&& "ruuvi/foo/bar/de:ea:DB:be:ff:01".equals(result.getProperties()
.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC));
})
, "Failed to match: " + discoveryResults.toString());
}
private static class LatchDiscoveryListener implements DiscoveryListener {
private final CopyOnWriteArrayList<DiscoveryResult> discoveryResults = new CopyOnWriteArrayList<>();
private @Nullable CountDownLatch latch;
@Override
public void thingDiscovered(DiscoveryService source, DiscoveryResult result) {
discoveryResults.add(result);
CountDownLatch localLatch = latch;
if (localLatch != null) {
localLatch.countDown();
}
}
@Override
public void thingRemoved(DiscoveryService source, ThingUID thingUID) {
}
@Override
public @Nullable Collection<ThingUID> removeOlderResults(DiscoveryService source, long timestamp,
@Nullable Collection<ThingTypeUID> thingTypeUIDs, @Nullable ThingUID bridgeUID) {
return Collections.emptyList();
}
public CopyOnWriteArrayList<DiscoveryResult> getDiscoveryResults() {
return discoveryResults;
}
public CountDownLatch createWaitForThingsDiscoveredLatch(int count) {
final var newLatch = new CountDownLatch(count);
latch = newLatch;
return newLatch;
}
}
}

View File

@@ -0,0 +1,169 @@
/**
* Copyright (c) 2010-2023 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.mqtt.ruuvigateway.internal.parser;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.mqtt.ruuvigateway.internal.parser.GatewayPayloadParser.GatewayPayload;
import com.google.gson.JsonSyntaxException;
/**
* Tests for {@link GatewayPayloadParser}
*
* @author Sami Salonen - Initial Contribution
*/
@NonNullByDefault
public class GatewayPayloadParserTests {
private byte[] bytes(String str) {
ByteBuffer buffer = StandardCharsets.UTF_8.encode(str);
buffer.rewind();
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
return bytes;
}
/**
* Test with valid data.
*
* See 'valid case' test vector from
* https://docs.ruuvi.com/communication/bluetooth-advertisements/data-format-5-rawv2
*/
@Test
public void testValid() {
GatewayPayload parsed = GatewayPayloadParser.parse(bytes(//
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
+ " \"rssi\": -83,"//
+ " \"aoa\": [],"//
+ " \"gwts\": \"1659365438\","//
+ " \"ts\": \"1659365439\","//
+ " \"data\": \"0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","//
+ " \"coords\": \"\"" + "}"));
assertNotNull(parsed);
assertEquals(-83, parsed.rssi);
assertEquals(Optional.of(Instant.ofEpochSecond(1659365438)), parsed.gwts);
assertEquals(Optional.of(Instant.ofEpochSecond(1659365439)), parsed.ts);
assertEquals(24.3, parsed.measurement.getTemperature());
assertEquals(100044, parsed.measurement.getPressure());
assertEquals(5, parsed.measurement.getDataFormat());
assertEquals(53.49, parsed.measurement.getHumidity());
assertEquals(0.004, parsed.measurement.getAccelerationX());
assertEquals(-0.004, parsed.measurement.getAccelerationY());
assertEquals(1.036, parsed.measurement.getAccelerationZ());
assertEquals(4, parsed.measurement.getTxPower());
assertEquals(2.9770000000000003, parsed.measurement.getBatteryVoltage());
assertEquals(66, parsed.measurement.getMovementCounter());
assertEquals(205, parsed.measurement.getMeasurementSequenceNumber());
}
@Test
public void testInvalidJSON() {
assertThrows(JsonSyntaxException.class, () -> {
GatewayPayloadParser.parse(bytes(//
"invalid json"));
});
}
@Test
public void testUnexpectedTypes() {
assertThrows(IllegalArgumentException.class, () -> {
GatewayPayloadParser.parse(bytes(//
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
+ " \"rssi\": -83,"//
+ " \"aoa\": [],"//
+ " \"gwts\": \"1659365438\","//
+ " \"ts\": \"1659365438\","//
+ " \"data\": 666," // should be hex-string of even length
+ " \"coords\": \"\"" + "}"));
});
}
@Test
public void testInvalidHex() {
assertThrows(IllegalArgumentException.class, () -> {
GatewayPayloadParser.parse(bytes(//
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
+ " \"rssi\": -83,"//
+ " \"aoa\": [],"//
+ " \"gwts\": \"1659365438\","//
+ " \"ts\": \"1659365438\","//
+ " \"data\": \"XYZZ\"," // should be hex string
+ " \"coords\": \"\"" + "}"));
});
}
@Test
public void testUnexpectedTypes3() {
assertThrows(JsonSyntaxException.class, () -> {
GatewayPayloadParser.parse(bytes(//
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
+ " \"rssi\": \"foobar\","// should be number
+ " \"aoa\": [],"//
+ " \"gwts\": \"1659365438\","//
+ " \"ts\": \"1659365438\","//
+ " \"data\": \"0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
+ " \"coords\": \"\"" + "}"));
});
}
@Test
public void testDataTooShort() {
assertThrows(IllegalArgumentException.class, () -> {
GatewayPayloadParser.parse(bytes(//
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
+ " \"rssi\": -83," + " \"aoa\": [],"//
+ " \"gwts\": \"1659365438\","//
+ " \"ts\": \"1659365438\","//
+ " \"data\": \"0201061BFF990405\"," // too short
+ " \"coords\": \"\"" + "}"));
});
}
@Test
public void testUnexpectedManufacturer() {
assertThrows(IllegalArgumentException.class, () -> {
GatewayPayloadParser.parse(bytes(//
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
+ " \"rssi\": -83,"//
+ " \"aoa\": [],"//
+ " \"gwts\": \"1659365438\","//
+ " \"ts\": \"1659365438\","//
// manufacturer is not 99 04 (Ruuvi) but 99 99
+ " \"data\": \"0201061BFF99990512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
+ " \"coords\": \"\"" + "}"));
});
}
@Test
public void testDataNotBluetoothAdvertisement() {
assertThrows(IllegalArgumentException.class, () -> {
GatewayPayloadParser.parse(bytes(//
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
+ " \"rssi\": -83,"//
+ " \"aoa\": [],"//
+ " \"gwts\": \"1659365438\","//
+ " \"ts\": \"1659365438\","//
// not advertisement (FF) but AA
+ " \"data\": \"0201061BAA99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
+ " \"coords\": \"\"" + "}"));
});
}
}