[mqtt] Support Ruuvitags via Ruuvi Gateway (#13315)
Signed-off-by: Sami Salonen <ssalonen@gmail.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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\": \"\"" + "}"));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user