diff --git a/CODEOWNERS b/CODEOWNERS
index 6d685d130f3..6e6af974705 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -216,6 +216,7 @@
/bundles/org.openhab.binding.mqtt.generic/ @davidgraeff
/bundles/org.openhab.binding.mqtt.homeassistant/ @davidgraeff @antroids
/bundles/org.openhab.binding.mqtt.homie/ @davidgraeff
+/bundles/org.openhab.binding.mqtt.ruuvigateway/ @ssalonen
/bundles/org.openhab.binding.mycroft/ @dalgwen
/bundles/org.openhab.binding.mybmw/ @weymann @ntruchsess
/bundles/org.openhab.binding.mynice/ @clinique
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/NOTICE b/bundles/org.openhab.binding.mqtt.ruuvigateway/NOTICE
new file mode 100644
index 00000000000..2733452284a
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/NOTICE
@@ -0,0 +1,21 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+[ruuvitag-common-java]
+* License: MIT License
+* Project: https://github.com/Scrin/ruuvitag-common-java
+* Source: https://github.com/Scrin/ruuvitag-common-java
+
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/README.md b/bundles/org.openhab.binding.mqtt.ruuvigateway/README.md
new file mode 100644
index 00000000000..f663be586ff
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/README.md
@@ -0,0 +1,90 @@
+# Ruuvi Gateway MQTT Binding
+
+This binding allows integration of Ruuvi Tags via MQTT data, as collected by [Ruuvi Gateway](https://ruuvi.com/gateway/).
+Ruuvi gateway is listening for Bluetooth advertisements and publishing that data over MQTT.
+Ruuvi Cloud Subscription is not needed at all as the integration is local.
+
+Compared to Ruuvi Tag Bluetooth binding, this binding has the benefit of relying on strong and reliable antenna of Ruuvi Gateway, as opposed to e.g. usually much weaker antenna integrated onto computer motherboard.
+Obvious downside compared to the bluetooth binding is the requirement of having Ruuvi Gateway device.
+
+Both RuuviTag and RuuviTag Pro are supported.
+
+## Setup the Gateway
+
+Before using this binding, Ruuvi Gateway needs to configured to publish the sensor data via MQTT.
+
+For further instructions, refer to relevant section in [Ruuvi Gateway documentation](https://ruuvi.com/gateway-config/).
+For most convenient usage of this binding, please ensure that "Use 'ruuvi' on the prefix' MQTT setting is enabled on Ruuvi Gateway.
+
+## Discovery
+
+First install the MQTT binding and setup a `broker` thing and make sure it is ONLINE, as this binding uses the MQTT binding to talk to your broker and hence that binding must be setup first.
+
+This binding discovers the Ruuvi Tags via the MQTT bridge; the discovered things should appear in your thing Inbox.
+
+## Thing Configuration
+
+
+There is only thing type supported by this binding, `ruuvitag_beacon`.
+No manual configuration is needed, and discovery function can be used instead.
+
+For users that prefer manual configuration, we list here the configurable parameters.
+
+| Parameter | Description | Required | Default |
+|-----------|-------------------------------------------|----------|---------|
+| `topic` | MQTT topic containing the gateway payload | Y | (N/A) |
+
+## Channels
+
+| Channel ID | Item Type | Description |
+|---------------------------|--------------------------|--------------------------------------------------------------------------|
+| temperature | Number:Temperature | The measured temperature |
+| humidity | Number:Dimensionless | The measured humidity |
+| pressure | Number:Pressure | The measured air pressure |
+| batteryVoltage | Number:ElectricPotential | The measured battery voltage |
+| accelerationx | Number:Acceleration | The measured acceleration of X |
+| accelerationy | Number:Acceleration | The measured acceleration of Y |
+| accelerationz | Number:Acceleration | The measured acceleration of Z |
+| txPower | Number:Power | TX power |
+| dataFormat | Number | Data format version |
+| measurementSequenceNumber | Number:Dimensionless | Measurement sequence number |
+| movementCounter | Number:Dimensionless | Movement counter |
+| rssi | Number | Received signal (between the Gateway and the sensor) strength indicator |
+| ts | DateTime | Timestamp when the message from Bluetooth-sensor was received by Gateway |
+| gwts | DateTime | Timestamp when the message from Bluetooth-sensor was relayed by Gateway |
+| gwmac | String | MAC-address of Ruuvi Gateway |
+
+Note: not all channels are always updated.
+Available fields depend on [Ruuvi Data Format](https://github.com/ruuvi/ruuvi-sensor-protocols).
+At the time of writing (2022-09), most Ruuvi Tags use Ruuvi Data Format 5 out of box.
+
+Some measurements might not make any sense.
+For example, Ruuvi Tag Pro 2in1 does not have a humidity measurement and thus, the humidity data advertised by the sensor is garbage.
+
+## Example
+
+Please note that Thing and Item configuration can be done fully in the UI.
+For those who prefer textual configuration, we share this example here.
+
+To use these examples for textual configuration, you must already have a configured a MQTT `broker` thing and know its unique ID.
+This UID will be used in the things file and will replace the text `myBroker`.
+The first line in the things file will create a `broker` thing and this can be removed if you have already setup a broker in another file or via the UI already.
+
+### *.things
+
+```java
+Bridge mqtt:broker:myBroker [ host="localhost", secure=false, password="*******", qos=1, username="user"]
+mqtt:ruuvitag_beacon:myTag1 "RuuviTag Sensor Beacon 9ABC" (mqtt:broker:myBroker) [ topic="ruuvi/mygw/DE:AD:BE:EF:AA:01" ]
+```
+
+### *.items
+
+```java
+Number:Temperature temperature "Room Temperature [%.1f %unit%]" { channel="mqtt:ruuvitag_beacon:myTag1:temperature" }
+Number:Dimensionless humidity "Humidity [%.0f %unit%]" { channel="mqtt:ruuvitag_beacon:myTag1:humidity" }
+Number:Pressure pressure "Air Pressure [%.0f %unit%]" { channel="mqtt:ruuvitag_beacon:myTag1:pressure" }
+
+// Examples of converting units
+Number:Acceleration acceleration_ms "Acceleration z [%.2f m/s²]" { channel="mqtt:ruuvitag_beacon:myTag1:accelerationz" }
+Number:Acceleration acceleration_g "Acceleration z (g-force) [%.2f gₙ]" { channel="mqtt:ruuvitag_beacon:myTag1:accelerationz" }
+```
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/pom.xml b/bundles/org.openhab.binding.mqtt.ruuvigateway/pom.xml
new file mode 100644
index 00000000000..4a3c49c324e
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/pom.xml
@@ -0,0 +1,36 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.0.0-SNAPSHOT
+
+
+ org.openhab.binding.mqtt.ruuvigateway
+ openHAB Add-ons :: Bundles :: MQTT Ruuvi Gateway
+
+
+
+ org.openhab.addons.bundles
+ org.openhab.binding.mqtt
+ ${project.version}
+ provided
+
+
+ org.openhab.addons.bundles
+ org.openhab.binding.mqtt.generic
+ ${project.version}
+ provided
+
+
+ fi.tkgwf.ruuvi
+ ruuvitag-common
+ 1.0.2
+ compile
+
+
+
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/feature/feature.xml b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/feature/feature.xml
new file mode 100644
index 00000000000..1668b960b81
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/feature/feature.xml
@@ -0,0 +1,13 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ openhab-transport-mqtt
+ mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version}
+ mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}
+ mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.ruuvigateway/${project.version}
+
+
+
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedDateTimeState.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedDateTimeState.java
new file mode 100644
index 00000000000..0cc5f56fcb8
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedDateTimeState.java
@@ -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)));
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedNumberState.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedNumberState.java
new file mode 100644
index 00000000000..ab5d494e8da
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedNumberState.java
@@ -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> extends ChannelState {
+
+ private final Optional> unit;
+
+ /**
+ * Construct cache for numbers with unit
+ *
+ * @param channelUID associated channel UID
+ * @param unit unit associated with updated numbers
+ *
+ */
+ public RuuviCachedNumberState(ChannelUID channelUID, Unit 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> getUnit() {
+ return unit;
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedStringState.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedStringState.java
new file mode 100644
index 00000000000..5ff6b5b110e
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviCachedStringState.java
@@ -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));
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviGatewayBindingConstants.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviGatewayBindingConstants.java
new file mode 100644
index 00000000000..1b31429fec6
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviGatewayBindingConstants.java
@@ -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 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
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviTagHandlerFactory.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviTagHandlerFactory.java
new file mode 100644
index 00000000000..0b1f8711c26
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/RuuviTagHandlerFactory.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/discovery/RuuviGatewayDiscoveryService.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/discovery/RuuviGatewayDiscoveryService.java
new file mode 100644
index 00000000000..8d9d09bad13
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/discovery/RuuviGatewayDiscoveryService.java
@@ -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 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 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);
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/handler/RuuviTagHandler.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/handler/RuuviTagHandler.java
new file mode 100644
index 00000000000..a925738e325
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/handler/RuuviTagHandler.java
@@ -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 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 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 > RuuviCachedNumberState> initNumberStateCache(ChannelUID channelUID,
+ @Nullable Unit 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 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 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 > boolean updateStringStateIfLinked(ChannelUID channelUID, Optional 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 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;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/parser/GatewayPayloadParser.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/parser/GatewayPayloadParser.java
new file mode 100644
index 00000000000..d19f1c37d8d
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/java/org/openhab/binding/mqtt/ruuvigateway/internal/parser/GatewayPayloadParser.java
@@ -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 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 gwMac = Optional.empty();
+ /**
+ * RSSI
+ */
+ public int rssi;
+ /**
+ * Timestamp when the message from Bluetooth-sensor was relayed by Gateway
+ *
+ */
+ public Optional gwts = Optional.empty();
+
+ /**
+ * Timestamp (Unix-time) when the message from Bluetooth-sensor was received by Gateway
+ *
+ */
+ public Optional 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;
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/resources/OH-INF/i18n/mqtt.properties b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/resources/OH-INF/i18n/mqtt.properties
new file mode 100644
index 00000000000..6a1f38dccc8
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/resources/OH-INF/i18n/mqtt.properties
@@ -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
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 00000000000..91bf6536f3e
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+ A RuuviTag SmartBeacon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MQTT topic containing the payload
+
+
+
+
+
+
+
+ DateTime
+
+ Timestamp when the message from Bluetooth sensor was received by Gateway (ts)
+ Time
+
+
+
+ DateTime
+
+ Timestamp when the message from Bluetooth sensor was relayed by Gateway (gwts)
+ Time
+
+
+
+ String
+
+ MAC-address of Ruuvi Gateway
+
+
+
+
+ Number:Acceleration
+
+
+
+
+ Number:Acceleration
+
+
+
+
+ Number:Acceleration
+
+
+
+
+ Number:ElectricPotential
+
+
+
+
+ Number
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+ Number:Pressure
+
+
+
+
+ Number:Temperature
+
+
+
+
+ Number:Power
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/test/java/org/openhab/binding/mqtt/ruuvigateway/internal/discovery/RuuviGatewayDiscoveryTests.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/test/java/org/openhab/binding/mqtt/ruuvigateway/internal/discovery/RuuviGatewayDiscoveryTests.java
new file mode 100644
index 00000000000..fdc5d8cfe15
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/test/java/org/openhab/binding/mqtt/ruuvigateway/internal/discovery/RuuviGatewayDiscoveryTests.java
@@ -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 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 removeOlderResults(DiscoveryService source, long timestamp,
+ @Nullable Collection thingTypeUIDs, @Nullable ThingUID bridgeUID) {
+ return Collections.emptyList();
+ }
+
+ public CopyOnWriteArrayList getDiscoveryResults() {
+ return discoveryResults;
+ }
+
+ public CountDownLatch createWaitForThingsDiscoveredLatch(int count) {
+ final var newLatch = new CountDownLatch(count);
+ latch = newLatch;
+ return newLatch;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.mqtt.ruuvigateway/src/test/java/org/openhab/binding/mqtt/ruuvigateway/internal/parser/GatewayPayloadParserTests.java b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/test/java/org/openhab/binding/mqtt/ruuvigateway/internal/parser/GatewayPayloadParserTests.java
new file mode 100644
index 00000000000..dca4c824aa2
--- /dev/null
+++ b/bundles/org.openhab.binding.mqtt.ruuvigateway/src/test/java/org/openhab/binding/mqtt/ruuvigateway/internal/parser/GatewayPayloadParserTests.java
@@ -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\": \"\"" + "}"));
+ });
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 65bb6d8345f..d87359998f7 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -250,6 +250,7 @@
org.openhab.binding.mqtt.generic
org.openhab.binding.mqtt.homeassistant
org.openhab.binding.mqtt.homie
+ org.openhab.binding.mqtt.ruuvigateway
org.openhab.binding.mybmw
org.openhab.binding.mycroft
org.openhab.binding.mynice
diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml
index 61351094f07..dd1acb32522 100644
--- a/features/openhab-addons/src/main/resources/footer.xml
+++ b/features/openhab-addons/src/main/resources/footer.xml
@@ -25,6 +25,7 @@
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homeassistant/${project.version}
mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homie/${project.version}
+ mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.ruuvigateway/${project.version}
openhab-runtime-base
diff --git a/itests/org.openhab.binding.mqtt.ruuvigateway.tests/NOTICE b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/NOTICE
new file mode 100644
index 00000000000..38d625e3492
--- /dev/null
+++ b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/itests/org.openhab.binding.mqtt.ruuvigateway.tests/itest.bndrun b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/itest.bndrun
new file mode 100644
index 00000000000..de5cf456acd
--- /dev/null
+++ b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/itest.bndrun
@@ -0,0 +1,108 @@
+-include: ../itest-common.bndrun
+
+Bundle-SymbolicName: ${project.artifactId}
+Fragment-Host: org.openhab.binding.mqtt.ruuvigateway
+
+Import-Package: \
+ com.bugsnag.*;resolution:=optional,\
+ com.librato.metrics.reporter.*;resolution:=optional,\
+ *
+
+-includeresource: \
+ moquette-broker-[0-9.]*.jar;lib:=true
+
+-runrequires: \
+ bnd.identity;id='org.openhab.binding.mqtt.ruuvigateway.tests'
+
+# We would like to use the "volatile" storage only
+-runblacklist: \
+ bnd.identity;id='org.openhab.core.storage.json'
+
+-runvm.mqtt: \
+ -Dio.netty.noUnsafe=true,\
+ -Dmqttbroker.port=${mqttbroker.port}
+
+#
+# done
+#
+-runbundles: \
+ biz.aQute.tester.junit-platform;version='[6.4.0,6.4.1)',\
+ ch.qos.logback.classic;version='[1.2.11,1.2.12)',\
+ ch.qos.logback.core;version='[1.2.11,1.2.12)',\
+ com.google.dagger;version='[2.27.0,2.27.1)',\
+ com.google.gson;version='[2.9.1,2.9.2)',\
+ com.h2database.mvstore;version='[1.4.199,1.4.200)',\
+ com.hivemq.client.mqtt;version='[1.2.2,1.2.3)',\
+ com.sun.jna;version='[5.12.1,5.12.2)',\
+ com.sun.xml.bind.jaxb-osgi;version='[2.3.3,2.3.4)',\
+ com.zaxxer.HikariCP;version='[2.4.7,2.4.8)',\
+ io.dropwizard.metrics.core;version='[3.2.2,3.2.3)',\
+ io.methvin.directory-watcher;version='[0.17.1,0.17.2)',\
+ io.netty.buffer;version='[4.1.72,4.1.73)',\
+ io.netty.codec;version='[4.1.72,4.1.73)',\
+ io.netty.codec-http;version='[4.1.59,4.1.60)',\
+ io.netty.codec-mqtt;version='[4.1.72,4.1.73)',\
+ io.netty.codec-socks;version='[4.1.72,4.1.73)',\
+ io.netty.common;version='[4.1.72,4.1.73)',\
+ io.netty.handler;version='[4.1.72,4.1.73)',\
+ io.netty.handler-proxy;version='[4.1.72,4.1.73)',\
+ io.netty.resolver;version='[4.1.72,4.1.73)',\
+ io.netty.tcnative-classes;version='[2.0.46,2.0.47)',\
+ io.netty.transport;version='[4.1.72,4.1.73)',\
+ io.netty.transport-native-epoll;version='[4.1.59,4.1.60)',\
+ io.netty.transport-native-unix-common;version='[4.1.59,4.1.60)',\
+ io.reactivex.rxjava2.rxjava;version='[2.2.19,2.2.20)',\
+ jakarta.annotation-api;version='[2.0.0,2.0.1)',\
+ jakarta.inject.jakarta.inject-api;version='[2.0.0,2.0.1)',\
+ jakarta.ws.rs-api;version='[2.1.6,2.1.7)',\
+ jakarta.xml.bind-api;version='[2.3.3,2.3.4)',\
+ javax.measure.unit-api;version='[2.1.2,2.1.3)',\
+ junit-jupiter-api;version='[5.9.2,5.9.3)',\
+ junit-jupiter-engine;version='[5.9.2,5.9.3)',\
+ junit-jupiter-params;version='[5.9.2,5.9.3)',\
+ junit-platform-commons;version='[1.9.2,1.9.3)',\
+ junit-platform-engine;version='[1.9.2,1.9.3)',\
+ junit-platform-launcher;version='[1.9.2,1.9.3)',\
+ org.apache.aries.javax.jax.rs-api;version='[1.0.1,1.0.2)',\
+ org.apache.commons.commons-codec;version='[1.15.0,1.15.1)',\
+ org.apache.felix.configadmin;version='[1.9.26,1.9.27)',\
+ org.apache.felix.http.servlet-api;version='[1.2.0,1.2.1)',\
+ org.apache.felix.scr;version='[2.2.4,2.2.5)',\
+ org.apache.servicemix.specs.activation-api-1.2.1;version='[1.2.1,1.2.2)',\
+ org.eclipse.equinox.event;version='[1.4.300,1.4.301)',\
+ org.eclipse.jetty.http;version='[9.4.50,9.4.51)',\
+ org.eclipse.jetty.io;version='[9.4.50,9.4.51)',\
+ org.eclipse.jetty.security;version='[9.4.50,9.4.51)',\
+ org.eclipse.jetty.server;version='[9.4.50,9.4.51)',\
+ org.eclipse.jetty.servlet;version='[9.4.50,9.4.51)',\
+ org.eclipse.jetty.util;version='[9.4.50,9.4.51)',\
+ org.eclipse.jetty.util.ajax;version='[9.4.50,9.4.51)',\
+ org.glassfish.hk2.external.javax.inject;version='[2.4.0,2.4.1)',\
+ org.glassfish.hk2.osgi-resource-locator;version='[1.0.3,1.0.4)',\
+ org.hamcrest;version='[2.2.0,2.2.1)',\
+ org.jctools.core;version='[2.1.2,2.1.3)',\
+ org.jsr-305;version='[3.0.2,3.0.3)',\
+ org.openhab.binding.mqtt;version='[4.0.0,4.0.1)',\
+ org.openhab.binding.mqtt.generic;version='[4.0.0,4.0.1)',\
+ org.openhab.binding.mqtt.ruuvigateway;version='[4.0.0,4.0.1)',\
+ org.openhab.binding.mqtt.ruuvigateway.tests;version='[4.0.0,4.0.1)',\
+ org.openhab.core;version='[4.0.0,4.0.1)',\
+ org.openhab.core.config.core;version='[4.0.0,4.0.1)',\
+ org.openhab.core.config.discovery;version='[4.0.0,4.0.1)',\
+ org.openhab.core.io.console;version='[4.0.0,4.0.1)',\
+ org.openhab.core.io.transport.mqtt;version='[4.0.0,4.0.1)',\
+ org.openhab.core.test;version='[4.0.0,4.0.1)',\
+ org.openhab.core.thing;version='[4.0.0,4.0.1)',\
+ org.openhab.core.transform;version='[4.0.0,4.0.1)',\
+ org.opentest4j;version='[1.2.0,1.2.1)',\
+ org.ops4j.pax.logging.pax-logging-api;version='[2.2.0,2.2.1)',\
+ org.osgi.service.component;version='[1.5.0,1.5.1)',\
+ org.osgi.service.event;version='[1.4.0,1.4.1)',\
+ org.osgi.util.function;version='[1.2.0,1.2.1)',\
+ org.osgi.util.promise;version='[1.2.0,1.2.1)',\
+ org.reactivestreams.reactive-streams;version='[1.0.3,1.0.4)',\
+ si-units;version='[2.1.0,2.1.1)',\
+ si.uom.si-quantity;version='[2.1.0,2.1.1)',\
+ tech.units.indriya;version='[2.1.2,2.1.3)',\
+ uom-lib-common;version='[2.1.0,2.1.1)',\
+ xstream;version='[1.4.20,1.4.21)'
\ No newline at end of file
diff --git a/itests/org.openhab.binding.mqtt.ruuvigateway.tests/pom.xml b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/pom.xml
new file mode 100644
index 00000000000..211e3e19e04
--- /dev/null
+++ b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/pom.xml
@@ -0,0 +1,112 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.itests
+ org.openhab.addons.reactor.itests
+ 4.0.0-SNAPSHOT
+
+
+ org.openhab.binding.mqtt.ruuvigateway.tests
+
+ openHAB Add-ons :: Integration Tests :: MQTT Ruuvi Gateway Tests
+
+
+ 1884
+
+
+
+
+ org.openhab.addons.bundles
+ org.openhab.binding.mqtt
+ ${project.version}
+
+
+ org.openhab.addons.bundles
+ org.openhab.binding.mqtt.generic
+ ${project.version}
+
+
+ org.openhab.addons.bundles
+ org.openhab.binding.mqtt.ruuvigateway
+ ${project.version}
+
+
+ com.h2database
+ h2-mvstore
+ 1.4.199
+
+
+ io.moquette
+ moquette-broker
+ 0.15
+
+
+ io.netty
+ netty-buffer
+ ${netty.version}
+
+
+ io.netty
+ netty-codec
+ ${netty.version}
+
+
+ io.netty
+ netty-codec-mqtt
+ ${netty.version}
+
+
+ io.netty
+ netty-common
+ ${netty.version}
+
+
+ io.netty
+ netty-handler
+ ${netty.version}
+
+
+ io.netty
+ netty-handler-proxy
+ ${netty.version}
+
+
+ io.netty
+ netty-resolver
+ ${netty.version}
+
+
+ io.netty
+ netty-transport
+ ${netty.version}
+
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+
+
+ reserve-mqtt-broker-port
+
+ reserve-network-port
+
+ process-resources
+
+
+ mqttbroker.port
+
+
+
+
+
+
+
+
+
diff --git a/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/MqttOSGiTest.java b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/MqttOSGiTest.java
new file mode 100644
index 00000000000..6e7addfc215
--- /dev/null
+++ b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/MqttOSGiTest.java
@@ -0,0 +1,130 @@
+/**
+ * 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;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.openhab.core.config.discovery.inbox.Inbox;
+import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
+import org.openhab.core.io.transport.mqtt.MqttConnectionState;
+import org.openhab.core.items.ItemProvider;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.items.ManagedItemProvider;
+import org.openhab.core.test.java.JavaOSGiTest;
+import org.openhab.core.thing.ManagedThingProvider;
+import org.openhab.core.thing.ThingProvider;
+import org.openhab.core.thing.link.ItemChannelLinkProvider;
+import org.openhab.core.thing.link.ManagedItemChannelLinkProvider;
+
+import io.moquette.BrokerConstants;
+import io.moquette.broker.Server;
+
+/**
+ * Creates a Moquette MQTT broker instance and a {@link MqttBrokerConnection} for testing MQTT bindings.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Sami Salonen - Copied to MQTT Ruuvi Gateway addon
+ */
+@NonNullByDefault
+public class MqttOSGiTest extends JavaOSGiTest {
+
+ private static final String BROKER_ID = "test-broker";
+ @SuppressWarnings("null")
+ private static final int BROKER_PORT = Integer.getInteger("mqttbroker.port", 1883);
+
+ protected @NonNullByDefault({}) MqttBrokerConnection brokerConnection;
+
+ private Server moquetteServer = new Server();
+ protected @NonNullByDefault({}) ManagedThingProvider thingProvider;
+ protected @NonNullByDefault({}) ManagedItemProvider itemProvider;
+ protected @NonNullByDefault({}) ItemRegistry itemRegistry;
+ protected @NonNullByDefault({}) ManagedItemChannelLinkProvider itemChannelLinkProvider;
+ protected @NonNullByDefault({}) Inbox inbox;
+
+ @BeforeEach
+ public void beforeEach() throws Exception {
+ registerVolatileStorageService();
+
+ thingProvider = getService(ThingProvider.class, ManagedThingProvider.class);
+ assertNotNull(thingProvider, "Could not get ManagedThingProvider");
+
+ itemProvider = getService(ItemProvider.class, ManagedItemProvider.class);
+ assertNotNull(itemProvider, "Could not get ManagedItemProvider");
+ itemRegistry = getService(ItemRegistry.class);
+ assertNotNull(itemProvider, "Could not get ItemRegistry");
+
+ itemChannelLinkProvider = getService(ItemChannelLinkProvider.class, ManagedItemChannelLinkProvider.class);
+ assertNotNull(itemChannelLinkProvider, "Could not get ManagedItemChannelLinkProvider");
+
+ inbox = getService(Inbox.class);
+ assertNotNull(inbox, "Could not get Inbox");
+
+ moquetteServer = new Server();
+ moquetteServer.startServer(brokerProperties());
+
+ brokerConnection = createBrokerConnection(BROKER_ID);
+ }
+
+ @AfterEach
+ public void afterEach() throws Exception {
+ brokerConnection.stop().get(5, TimeUnit.SECONDS);
+ moquetteServer.stopServer();
+ }
+
+ private Properties brokerProperties() {
+ Properties properties = new Properties();
+ properties.put(BrokerConstants.HOST_PROPERTY_NAME, BrokerConstants.HOST);
+ properties.put(BrokerConstants.PORT_PROPERTY_NAME, String.valueOf(BROKER_PORT));
+ properties.put(BrokerConstants.SSL_PORT_PROPERTY_NAME, BrokerConstants.DISABLED_PORT_BIND);
+ properties.put(BrokerConstants.WEB_SOCKET_PORT_PROPERTY_NAME, BrokerConstants.DISABLED_PORT_BIND);
+ properties.put(BrokerConstants.WSS_PORT_PROPERTY_NAME, BrokerConstants.DISABLED_PORT_BIND);
+ return properties;
+ }
+
+ protected MqttBrokerConnection createBrokerConnection(String clientId) throws Exception {
+ MqttBrokerConnection connection = new MqttBrokerConnection(BrokerConstants.HOST, BROKER_PORT, false, clientId);
+ connection.setQos(1);
+ connection.start().get(5, TimeUnit.SECONDS);
+
+ waitForAssert(() -> assertThat(connection.connectionState(), is(MqttConnectionState.CONNECTED)));
+
+ return connection;
+ }
+
+ protected CompletableFuture publish(String topic, String message) {
+ return brokerConnection.publish(topic, message.getBytes(StandardCharsets.UTF_8), 1, true);
+ }
+
+ /**
+ * Whether tests are run in Continuous Integration environment, i.e. Jenkins or Travis CI
+ *
+ * Travis CI is detected using CI environment variable, see https://docs.travis-ci.com/us>
+ * Jenkins CI is detected using JENKINS_HOME environment variable
+ *
+ * @return
+ */
+ protected boolean isRunningInCI() {
+ String jenkinsHome = System.getenv("JENKINS_HOME");
+ return "true".equals(System.getenv("CI")) || (jenkinsHome != null && !jenkinsHome.isBlank());
+ }
+}
diff --git a/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/RuuviGatewayTest.java b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/RuuviGatewayTest.java
new file mode 100644
index 00000000000..3e160230964
--- /dev/null
+++ b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/RuuviGatewayTest.java
@@ -0,0 +1,574 @@
+/**
+ * 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;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants.*;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import javax.measure.quantity.Acceleration;
+import javax.measure.quantity.Dimensionless;
+import javax.measure.quantity.ElectricPotential;
+import javax.measure.quantity.Power;
+import javax.measure.quantity.Pressure;
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
+import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants;
+import org.openhab.binding.mqtt.ruuvigateway.internal.discovery.RuuviGatewayDiscoveryService;
+import org.openhab.binding.mqtt.ruuvigateway.internal.handler.RuuviTagHandler;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
+import org.openhab.core.io.transport.mqtt.MqttConnectionObserver;
+import org.openhab.core.io.transport.mqtt.MqttConnectionState;
+import org.openhab.core.items.GenericItem;
+import org.openhab.core.library.CoreItemFactory;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.Bridge;
+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.thing.ThingStatusInfo;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.builder.BridgeBuilder;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.link.ItemChannelLink;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * A full implementation test, that starts the embedded MQTT broker and publishes test data
+ *
+ * @author David Graeff - Initial contribution
+ * @author Sami Salonen - Adapted and extended to Ruuvi Gateway tests
+ */
+@NonNullByDefault
+public class RuuviGatewayTest extends MqttOSGiTest {
+ private static final String BASE_TOPIC_RUUVI = "ruuvi";
+ private static final Map CHANNEL_TO_ITEM_TYPE = new HashMap<>();
+ static {
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_ACCELERATIONX, "Number:Acceleration");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_ACCELERATIONY, "Number:Acceleration");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_ACCELERATIONZ, "Number:Acceleration");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_BATTERY, "Number:ElectricPotential");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_DATA_FORMAT, "Number");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_HUMIDITY, "Number:Dimensionless");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER, "Number");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_MOVEMENT_COUNTER, "Number");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_PRESSURE, "Number:Pressure");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_TEMPERATURE, "Number:Temperature");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_TX_POWER, "Number:Power");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_RSSI, "Number:Power");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_TS, "DateTime");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_GWTS, "DateTime");
+ CHANNEL_TO_ITEM_TYPE.put(CHANNEL_ID_GWMAC, "String");
+ }
+
+ private ThingStatusInfoChangedSubscriber statusSubscriber = new ThingStatusInfoChangedSubscriber();
+ private @NonNullByDefault({}) MqttBrokerConnection mqttConnection;
+ private int registeredTopics = 100;
+
+ private @NonNullByDefault({}) ScheduledExecutorService scheduler;
+
+ /**
+ * Create an observer that fails the test as soon as the broker client connection changes its connection state
+ * to something else then CONNECTED.
+ */
+ private MqttConnectionObserver failIfChange = (state, error) -> assertThat(state,
+ is(MqttConnectionState.CONNECTED));
+
+ @SuppressWarnings("unused") // used indirectly with Inbox
+ private @NonNullByDefault({}) RuuviGatewayDiscoveryService ruuviDiscoveryService;
+ private Set things = new HashSet<>();
+
+ private Bridge createMqttBrokerBridge() {
+ Configuration configuration = new Configuration();
+ configuration.put("host", "127.0.0.1");
+ configuration.put("port", brokerConnection.getPort());
+ Bridge bridge = BridgeBuilder.create(new ThingTypeUID("mqtt", "broker"), "mybroker").withLabel("MQTT Broker")
+ .withConfiguration(configuration).build();
+ thingProvider.add(bridge);
+ waitForAssert(() -> assertNotNull(bridge.getHandler()));
+ assertNotNull(bridge.getConfiguration());
+ things.add(bridge);
+ return bridge;
+ }
+
+ private Thing createRuuviThing(String brokerPrefix, String topic, @Nullable Integer timeoutMillisecs) {
+ Configuration configuration = new Configuration();
+ configuration.put(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC, topic);
+ if (timeoutMillisecs != null) {
+ configuration.put(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TIMEOUT, timeoutMillisecs);
+ }
+ ThingUID bridgeThingUID = new ThingUID("mqtt", "broker", "mybroker");
+ ThingUID thingUID = new ThingUID(RuuviGatewayBindingConstants.THING_TYPE_BEACON,
+ topic.replaceAll("[:_/]", "_"));
+ ThingBuilder thingBuilder = ThingBuilder.create(RuuviGatewayBindingConstants.THING_TYPE_BEACON, thingUID)
+ .withBridge(bridgeThingUID).withLabel("Ruuvi " + topic).withConfiguration(configuration);
+
+ CHANNEL_TO_ITEM_TYPE.forEach((channelId, _itemType) -> {
+ thingBuilder.withChannel(ChannelBuilder.create(new ChannelUID(thingUID, channelId)).build());
+ });
+
+ Thing thing = thingBuilder.build();
+ thingProvider.add(thing);
+ waitForAssert(() -> assertNotNull(thing.getHandler()));
+ assertNotNull(thing.getConfiguration());
+ things.add(thing);
+ return thing;
+ }
+
+ private void triggerTimeoutHandling(Thing ruuviThing) {
+ // Simulate some time passing, so that RuuviTagHandler.heartbeat() is called twice
+ // Two heartbeat calls happens to trigger timeout handling in handler, one is not enough.
+ // (this is really implementation detail of RuuviTagHandler, making this test slightly
+ // error prone to possible changes in RuuviTagHandler implementation)
+ //
+ // 0. Assume some data received already, RuuviTagHandler.receivedData is true
+ // 1. First heartbeat sets receivedData=false; no further action is taken yet
+ // 2. Second heartbeat acts on false receivedData, e.g. updating Thing Status
+ for (int i = 0; i < 2; i++) {
+ callInternalHeartbeat(ruuviThing);
+ }
+ }
+
+ private void callInternalHeartbeat(Thing ruuviThing) {
+ ThingHandler handler = ruuviThing.getHandler();
+ Objects.requireNonNull(handler);
+ assertInstanceOf(RuuviTagHandler.class, handler);
+ RuuviTagHandler ruuviHandler = (RuuviTagHandler) handler;
+ try {
+ Method heartbeatMethod = RuuviTagHandler.class.getDeclaredMethod("heartbeat");
+ Objects.requireNonNull(heartbeatMethod);
+ heartbeatMethod.setAccessible(true);
+ heartbeatMethod.invoke(ruuviHandler);
+ } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException
+ | InvocationTargetException e) {
+ fail("Failed to call heartbeat method of thing handler via reflection. Bug in test? Details: "
+ + e.getClass().getSimpleName() + ": " + e.getMessage());
+ throw new RuntimeException(e);
+ }
+ }
+
+ private String itemName(ChannelUID channelUID) {
+ return channelUID.getAsString().replace(":", "_");
+ }
+
+ private String linkChannelToAutogeneratedItem(ChannelUID channelUID) {
+ String itemName = itemName(channelUID);
+ String itemType = CHANNEL_TO_ITEM_TYPE.get(channelUID.getId());
+ GenericItem item = new CoreItemFactory().createItem(itemType, itemName);
+ assertNotNull(item, itemType);
+ itemProvider.add(item);
+ itemChannelLinkProvider.add(new ItemChannelLink(itemName, channelUID));
+ return itemName;
+ }
+
+ @Override
+ @BeforeEach
+ public void beforeEach() throws Exception {
+ super.beforeEach();
+
+ statusSubscriber.statusUpdates.clear();
+ registerService(statusSubscriber);
+
+ MQTTTopicDiscoveryService mqttTopicDiscoveryService = getService(MQTTTopicDiscoveryService.class);
+ assertNotNull(mqttTopicDiscoveryService);
+ ruuviDiscoveryService = new RuuviGatewayDiscoveryService(mqttTopicDiscoveryService);
+
+ createMqttBrokerBridge();
+
+ mqttConnection = createBrokerConnection("myclientid");
+
+ // If the connection state changes in between -> fail
+ mqttConnection.addConnectionObserver(failIfChange);
+
+ List> futures = new ArrayList<>();
+ futures.add(publish(BASE_TOPIC_RUUVI + "/mygwid/DE:AD:BE:EF:AA:00", "{}"));
+
+ registeredTopics = futures.size();
+ CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get(5, TimeUnit.SECONDS);
+
+ scheduler = new ScheduledThreadPoolExecutor(6);
+ }
+
+ @Override
+ @AfterEach
+ public void afterEach() throws Exception {
+ if (mqttConnection != null) {
+ mqttConnection.removeConnectionObserver(failIfChange);
+ mqttConnection.stop().get(5, TimeUnit.SECONDS);
+ }
+ things.stream().map(thing -> thingProvider.remove(thing.getUID()));
+ unregisterService(statusSubscriber);
+
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ super.afterEach();
+ }
+
+ @Test
+ public void retrieveAllRuuviPrefixedTopics() throws Exception {
+ CountDownLatch c = new CountDownLatch(registeredTopics);
+ mqttConnection.subscribe(BASE_TOPIC_RUUVI + "/#", (topic, payload) -> c.countDown()).get(5, TimeUnit.SECONDS);
+ assertTrue(c.await(5, TimeUnit.SECONDS),
+ "Connection " + mqttConnection.getClientId() + " not retrieving all topics ");
+ }
+
+ private void assertThingStatus(List statusUpdates, int index, ThingStatus status,
+ @Nullable ThingStatusDetail detail, @Nullable String description) {
+ assertTrue(statusUpdates.size() > index,
+ String.format("Not enough status updates. Expected %d, but only had %d. Status updates received: %s",
+ index + 1, statusUpdates.size(),
+ statusUpdates.stream().map(ThingStatusInfo::getStatus).collect(Collectors.toList())));
+ assertEquals(status, statusUpdates.get(index).getStatus(), statusUpdates.get(index).toString());
+ assertEquals(detail, statusUpdates.get(index).getStatusDetail(), statusUpdates.get(index).toString());
+ assertEquals(description, statusUpdates.get(index).getDescription(), statusUpdates.get(index).toString());
+ }
+
+ @SuppressWarnings("null")
+ private void assertThingStatusWithDescriptionPattern(List statusUpdates, int index,
+ ThingStatus status, ThingStatusDetail detail, String descriptionPattern) {
+ assertTrue(statusUpdates.size() > index, "assert " + statusUpdates.size() + " > " + index + " failed");
+ assertEquals(status, statusUpdates.get(index).getStatus(), statusUpdates.get(index).toString());
+ assertEquals(detail, statusUpdates.get(index).getStatusDetail(), statusUpdates.get(index).toString());
+ assertTrue(statusUpdates.get(index).getDescription().matches(descriptionPattern),
+ statusUpdates.get(index).toString());
+ }
+
+ private void assertThingStatus(List statusUpdates, int index, ThingStatus status) {
+ assertThingStatus(statusUpdates, index, status, ThingStatusDetail.NONE, null);
+ }
+
+ private void assertItems(Function channelStateGetter, String temperatureCelsius,
+ String accelerationXStandardGravity, String accelerationYStandardGravity,
+ String accelerationZStandardGravity, String batteryVolt, int dataFormat, String humidityPercent,
+ int measurementSequenceNumber, int movementCounter, String pressurePascal, String txPowerDecibelMilliwatts,
+ String rssiDecibelMilliwatts, Instant ts, Instant gwts, String gwMac) {
+ assertEquals(new QuantityType(new BigDecimal(temperatureCelsius), SIUnits.CELSIUS),
+ channelStateGetter.apply(CHANNEL_ID_TEMPERATURE));
+ assertEquals(
+ new QuantityType(new BigDecimal(accelerationXStandardGravity), Units.STANDARD_GRAVITY),
+ channelStateGetter.apply(CHANNEL_ID_ACCELERATIONX));
+ assertEquals(
+ new QuantityType(new BigDecimal(accelerationYStandardGravity), Units.STANDARD_GRAVITY),
+ channelStateGetter.apply(CHANNEL_ID_ACCELERATIONY));
+ assertEquals(
+ new QuantityType(new BigDecimal(accelerationZStandardGravity), Units.STANDARD_GRAVITY),
+ channelStateGetter.apply(CHANNEL_ID_ACCELERATIONZ));
+ assertEquals(new QuantityType(new BigDecimal(batteryVolt), Units.VOLT),
+ channelStateGetter.apply(CHANNEL_ID_BATTERY));
+ assertEquals(new DecimalType(dataFormat), channelStateGetter.apply(CHANNEL_ID_DATA_FORMAT));
+ assertEquals(new QuantityType(new BigDecimal(humidityPercent), Units.PERCENT),
+ channelStateGetter.apply(CHANNEL_ID_HUMIDITY));
+ assertEquals(new DecimalType(new BigDecimal(measurementSequenceNumber)),
+ channelStateGetter.apply(CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER));
+ assertEquals(new DecimalType(new BigDecimal(movementCounter)),
+ channelStateGetter.apply(CHANNEL_ID_MOVEMENT_COUNTER));
+ assertEquals(new QuantityType(new BigDecimal(pressurePascal), SIUnits.PASCAL),
+ channelStateGetter.apply(CHANNEL_ID_PRESSURE));
+ assertEquals(new QuantityType(new BigDecimal(txPowerDecibelMilliwatts), Units.DECIBEL_MILLIWATTS),
+ channelStateGetter.apply(CHANNEL_ID_TX_POWER));
+
+ assertEquals(new QuantityType(new BigDecimal(rssiDecibelMilliwatts), Units.DECIBEL_MILLIWATTS),
+ channelStateGetter.apply(CHANNEL_ID_RSSI));
+ assertEquals(new DateTimeType(ts.atZone(ZoneId.of("UTC"))), channelStateGetter.apply(CHANNEL_ID_TS));
+ assertEquals(new DateTimeType(gwts.atZone(ZoneId.of("UTC"))), channelStateGetter.apply(CHANNEL_ID_GWTS));
+ assertEquals(new StringType(gwMac), channelStateGetter.apply(CHANNEL_ID_GWMAC));
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '@', value = { //
+ BASE_TOPIC_RUUVI + "mygwid/DE:AD:BE:EF:AA:01 @" + "{}", // empty json
+ BASE_TOPIC_RUUVI + "mygwid/DE:AD:BE:EF:BB:03 @" + "invalid json", // invalid json
+ BASE_TOPIC_RUUVI + "mygwid/DE:AD:BE:EF:BB:04 @" + "0201061BFF990405", // payload too short
+ BASE_TOPIC_RUUVI + "mygwid/DE:AD:BE:EF:BB:05 @"
+ + "0201061BFF99050512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F", // wrong manufacturer id (the
+ // two bytes after FF do not
+ // match 99 04)
+ BASE_TOPIC_RUUVI + "mygwid/DE:AD:BE:EF:BB:06 @"
+ + "0201061BFA99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F", // unexpected advertisement (no
+ // FF to indicate 'manufacturer
+ // specific' advertisement)
+ BASE_TOPIC_RUUVI + "mygwid/DE:AD:BE:EF:BB:07 @" + "{" + " \"gw_mac\": \"DE:AD:BE:EF:00\","
+ + " \"rssi\": -82," + " \"aoa\": [],"
+ // data field is number, not a string
+ + " \"gwts\": \"1659365432\"," + " \"ts\": \"1659365222\"," + " \"data\": 999,"
+ + " \"coords\": \"\" }", // wrong json data types
+ })
+ public void testInvalidCases(String topic, String val) throws Exception {
+ final String jsonPayload;
+ if (val.contains("{")) {
+ // test argument is specifiying the whole json payload
+ jsonPayload = val;
+ } else {
+ // test argument is only specifiying the data field in the json payload
+ // Fill rest of the fields with some valid values
+ jsonPayload = "{" + " \"gw_mac\": \"DE:AD:BE:EF:00\"," + " \"rssi\": -82," + " \"aoa\": [],"
+ + " \"gwts\": \"1659365432\"," + " \"ts\": \"1659365222\"," + " \"data\": \"" + val + "\","
+ + " \"coords\": \"\" }";
+ }
+
+ Thing ruuviThing = createRuuviThing("mygwid", topic, 100);
+ waitForAssert(() -> {
+ List statusUpdates = statusSubscriber.statusUpdates.get(ruuviThing.getUID());
+ assertNotNull(statusUpdates);
+ int statusUpdateIndex = 0;
+ assertThingStatus(statusUpdates, statusUpdateIndex++, ThingStatus.INITIALIZING);
+ assertThingStatus(statusUpdates, statusUpdateIndex++, ThingStatus.UNKNOWN);
+ assertThingStatus(statusUpdates, statusUpdateIndex++, ThingStatus.ONLINE, ThingStatusDetail.NONE,
+ "Waiting for initial data");
+ assertThingStatus(statusUpdates, statusUpdateIndex++, ThingStatus.OFFLINE,
+ ThingStatusDetail.COMMUNICATION_ERROR, "No valid data received for some time");
+ scheduler.execute(() -> publish(topic, jsonPayload));
+ assertThingStatusWithDescriptionPattern(statusUpdates, statusUpdateIndex++, ThingStatus.OFFLINE,
+ ThingStatusDetail.COMMUNICATION_ERROR, ".*could not be parsed.*");
+ assertEquals(statusUpdateIndex, statusUpdates.size());
+ });
+ }
+
+ @SuppressWarnings("null")
+ @Test
+ public void testDiscovery() {
+ scheduler.execute(() -> publish(BASE_TOPIC_RUUVI + "/mygwid/DE:AD:BE:EF:BB:02",
+ "{" + " \"gw_mac\": \"DE:AD:BE:EF:00\"," + " \"rssi\": -82," + " \"aoa\": [],"
+ + " \"gwts\": \"1659365432\"," + " \"ts\": \"1659365222\","
+ + " \"data\": \"0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
+ + " \"coords\": \"\" }"));
+ waitForAssert(() -> {
+ assertEquals(2, inbox.getAll().size(), inbox.getAll().toString());
+ var discovered = new HashSet();
+ discovered.addAll(inbox.getAll());
+
+ for (var result : discovered) {
+ assertEquals(THING_TYPE_BEACON, result.getThingTypeUID());
+ assertEquals("topic", result.getRepresentationProperty());
+ Object topic = result.getProperties().get("topic");
+ assertNotNull(topic);
+ assertTrue(
+ // published in this test
+ topic.equals((BASE_TOPIC_RUUVI + "/mygwid/DE:AD:BE:EF:BB:02"))
+ // published in beforeEach
+ || result.getProperties().get("topic")
+ .equals((BASE_TOPIC_RUUVI + "/mygwid/DE:AD:BE:EF:AA:00")));
+ }
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { true, false })
+ public void testHappyFlow(boolean quickTimeout) {
+ // with quickTimeout=false, heartbeat is effectively disabled. Thing will not "timeout" and go OFFLINE
+ // with quickTimeout=true, timeout happens very fast. In CI we use infinite timeout and trigger timeout manually
+
+ Thing ruuviThing = createRuuviThing("mygwid", BASE_TOPIC_RUUVI + "/mygwid/DE:AD:BE:EF:BB:02",
+ quickTimeout ? (isRunningInCI() ? 9_000_000 : 100) : 9_000_000);
+ // Link all channels to freshly created items
+ ruuviThing.getChannels().stream().map(Channel::getUID).forEach(this::linkChannelToAutogeneratedItem);
+
+ @SuppressWarnings("null")
+ Function getItemState = channelId -> itemRegistry
+ .get(itemName(ruuviThing.getChannel(channelId).getUID())).getState();
+
+ AtomicInteger statusUpdateIndex = new AtomicInteger();
+ waitForAssert(() -> {
+ List statusUpdates = statusSubscriber.statusUpdates.get(ruuviThing.getUID());
+ assertNotNull(statusUpdates);
+
+ assertThingStatus(statusUpdates, statusUpdateIndex.get(), ThingStatus.INITIALIZING);
+ assertThingStatus(statusUpdates, statusUpdateIndex.get() + 1, ThingStatus.UNKNOWN);
+ assertThingStatus(statusUpdates, statusUpdateIndex.get() + 2, ThingStatus.ONLINE, ThingStatusDetail.NONE,
+ "Waiting for initial data");
+
+ statusUpdateIndex.set(statusUpdateIndex.get() + 3);
+ });
+
+ List statusUpdates = statusSubscriber.statusUpdates.get(ruuviThing.getUID());
+ assertNotNull(statusUpdates);
+ if (quickTimeout) {
+ if (isRunningInCI()) {
+ triggerTimeoutHandling(ruuviThing);
+ }
+ waitForAssert(() -> {
+ assertThingStatus(statusUpdates, statusUpdateIndex.get(), ThingStatus.OFFLINE,
+ ThingStatusDetail.COMMUNICATION_ERROR, "No valid data received for some time");
+
+ CHANNEL_TO_ITEM_TYPE.keySet()
+ .forEach(channelId -> assertEquals(UnDefType.UNDEF, getItemState.apply(channelId)));
+ statusUpdateIndex.incrementAndGet();
+ });
+ }
+
+ // publish some valid data ("valid case" test vector from
+ // https://docs.ruuvi.com/communication/bluetooth-advertisements/data-format-5-rawv2)
+ scheduler.execute(() -> publish(BASE_TOPIC_RUUVI + "/mygwid/DE:AD:BE:EF:BB:02",
+ "{" + " \"gw_mac\": \"DE:AD:BE:EF:00\"," + " \"rssi\": -82," + " \"aoa\": [],"
+ + " \"gwts\": \"1659365432\"," + " \"ts\": \"1659365222\","
+ + " \"data\": \"0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
+ + " \"coords\": \"\" }"));
+
+ waitForAssert(() -> {
+ assertThingStatus(statusUpdates, statusUpdateIndex.get(), ThingStatus.ONLINE);
+ statusUpdateIndex.incrementAndGet();
+ });
+
+ waitForAssert(() -> {
+ assertItems(getItemState, //
+ "24.3", // temperature, Celsius
+ "0.004", // acc X, g
+ "-0.004", // acc Y, g
+ "1.036", // acc Z, g
+ "2.9770000000000003", // battery, volt
+ 5, // data format
+ "53.49", // humidity %
+ 205, // measurement seq
+ 66, // movement
+ "100044", // pressure, pascal
+ "4", // tx power, dBm
+ "-82", // RSSI, dBm
+ Instant.ofEpochSecond(1659365222), // ts
+ Instant.ofEpochSecond(1659365432), // gwts
+ "DE:AD:BE:EF:00" // gw mac
+ );
+ });
+
+ if (quickTimeout) {
+ if (isRunningInCI()) {
+ triggerTimeoutHandling(ruuviThing);
+ }
+ waitForAssert(() -> {
+ assertThingStatus(statusUpdates, statusUpdateIndex.get(), ThingStatus.OFFLINE,
+ ThingStatusDetail.COMMUNICATION_ERROR, "No valid data received for some time");
+ CHANNEL_TO_ITEM_TYPE.keySet()
+ .forEach(channelId -> assertEquals(UnDefType.UNDEF, getItemState.apply(channelId)));
+ statusUpdateIndex.incrementAndGet();
+ });
+
+ }
+
+ // Another mqtt update (("minimum values" test vector from
+ // https://docs.ruuvi.com/communication/bluetooth-advertisements/data-format-5-rawv2)
+ scheduler.execute(() -> publish(BASE_TOPIC_RUUVI + "/mygwid/DE:AD:BE:EF:BB:02",
+ "{" + " \"gw_mac\": \"DE:AD:BE:EF:00\"," + " \"rssi\": -66," + " \"aoa\": [],"
+ + " \"gwts\": \"1659365431\"," + " \"ts\": \"1659365221\","
+ + " \"data\": \"0201061BFF9904058001000000008001800180010000000000CBB8334C884F\","
+ + " \"coords\": \"\" }"));
+ if (quickTimeout) {
+ // With quick timeout we were previously offline, so now we should be back online
+ // with valid channels.
+ waitForAssert(() -> {
+ assertThingStatus(statusUpdates, statusUpdateIndex.get(), ThingStatus.ONLINE);
+ statusUpdateIndex.getAndIncrement();
+ });
+
+ // ...after a while all items are updated
+ waitForAssert(() -> {
+ assertItems(getItemState, //
+ "-163.835", // temperature, Celsius
+ "-32.767", // acc X, g
+ "-32.767", // acc Y, g
+ "-32.767", // acc Z, g
+ "1.6", // battery, volt
+ 5, // data format
+ "0.0", // humidity %
+ 0, // measurement seq
+ 0, // movement
+ "50000", // pressure, pascal
+ "-40", // tx power, dBm
+ "-66", // RSSI, dBm
+ Instant.ofEpochSecond(1659365221), // ts
+ Instant.ofEpochSecond(1659365431), // gwts
+ "DE:AD:BE:EF:00" // gw mac
+ );
+ });
+
+ // ...after which timeout will happen again
+ if (isRunningInCI()) {
+ triggerTimeoutHandling(ruuviThing);
+ }
+ waitForAssert(() -> {
+ assertThingStatus(statusUpdates, statusUpdateIndex.get(), ThingStatus.OFFLINE,
+ ThingStatusDetail.COMMUNICATION_ERROR, "No valid data received for some time");
+ CHANNEL_TO_ITEM_TYPE.keySet()
+ .forEach(channelId -> assertEquals(UnDefType.UNDEF, getItemState.apply(channelId)));
+ statusUpdateIndex.getAndIncrement();
+ });
+ } else {
+ // with non-quick timeout we are still online, and items are updated
+ waitForAssert(() -> {
+ assertItems(getItemState, //
+ "-163.835", // temperature, Celsius
+ "-32.767", // acc X, g
+ "-32.767", // acc Y, g
+ "-32.767", // acc Z, g
+ "1.6", // battery, volt
+ 5, // data format
+ "0.0", // humidity %
+ 0, // measurement seq
+ 0, // movement
+ "50000", // pressure, pascal
+ "-40", // tx power, dBm
+ "-66", // RSSI, dBm
+ Instant.ofEpochSecond(1659365221), // ts
+ Instant.ofEpochSecond(1659365431), // gwts
+ "DE:AD:BE:EF:00" // gw mac
+ );
+ });
+ }
+
+ // assert that we have processed all status updates
+ assertEquals(statusUpdateIndex.get(), statusUpdates.size());
+ }
+}
diff --git a/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/ThingStatusInfoChangedSubscriber.java b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/ThingStatusInfoChangedSubscriber.java
new file mode 100644
index 00000000000..9afa8b62b82
--- /dev/null
+++ b/itests/org.openhab.binding.mqtt.ruuvigateway.tests/src/main/java/org/openhab/binding/mqtt/ruuvigateway/ThingStatusInfoChangedSubscriber.java
@@ -0,0 +1,70 @@
+/**
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.events.Event;
+import org.openhab.core.events.EventFilter;
+import org.openhab.core.events.EventSubscriber;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.events.ThingStatusInfoChangedEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Test utility capturing thing status updates
+ *
+ * @author Sami Salonen - Initial contribution
+ */
+@NonNullByDefault
+public class ThingStatusInfoChangedSubscriber implements EventSubscriber {
+
+ private final Logger logger = LoggerFactory.getLogger(ThingStatusInfoChangedSubscriber.class);
+
+ public Map> statusUpdates = new HashMap<>();
+
+ @Override
+ public Set<@NonNull String> getSubscribedEventTypes() {
+ return Collections.singleton(ThingStatusInfoChangedEvent.TYPE);
+ }
+
+ @Override
+ public @Nullable EventFilter getEventFilter() {
+ return null;
+ }
+
+ @Override
+ public void receive(Event event) {
+ // Expecting only state updates in the tests
+ assertInstanceOf(ThingStatusInfoChangedEvent.class, event);
+ ThingStatusInfoChangedEvent statusEvent = (ThingStatusInfoChangedEvent) event;
+ logger.trace("Captured event: {} ", event);
+ List updates = statusUpdates.computeIfAbsent(statusEvent.getThingUID(),
+ item -> new CopyOnWriteArrayList<>());
+ Objects.requireNonNull(updates); // To make compiler happy
+ updates.add(statusEvent.getStatusInfo());
+ }
+}
diff --git a/itests/pom.xml b/itests/pom.xml
index a89697ce3d8..b8831d14724 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -28,6 +28,7 @@
org.openhab.binding.modbus.tests
org.openhab.binding.mqtt.homeassistant.tests
org.openhab.binding.mqtt.homie.tests
+ org.openhab.binding.mqtt.ruuvigateway.tests
org.openhab.binding.nest.tests
org.openhab.binding.ntp.tests
org.openhab.binding.systeminfo.tests