mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 07:02:02 +01:00
[mqtt] Support Ruuvitags via Ruuvi Gateway (#13315)
Signed-off-by: Sami Salonen <ssalonen@gmail.com>
This commit is contained in:
parent
210aff461d
commit
18e7d81e4d
@ -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
|
||||
|
21
bundles/org.openhab.binding.mqtt.ruuvigateway/NOTICE
Normal file
21
bundles/org.openhab.binding.mqtt.ruuvigateway/NOTICE
Normal file
@ -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
|
||||
|
90
bundles/org.openhab.binding.mqtt.ruuvigateway/README.md
Normal file
90
bundles/org.openhab.binding.mqtt.ruuvigateway/README.md
Normal file
@ -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" }
|
||||
```
|
36
bundles/org.openhab.binding.mqtt.ruuvigateway/pom.xml
Normal file
36
bundles/org.openhab.binding.mqtt.ruuvigateway/pom.xml
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>4.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.binding.mqtt.ruuvigateway</artifactId>
|
||||
<name>openHAB Add-ons :: Bundles :: MQTT Ruuvi Gateway</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.mqtt</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.mqtt.generic</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>fi.tkgwf.ruuvi</groupId>
|
||||
<artifactId>ruuvitag-common</artifactId>
|
||||
<version>1.0.2</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.mqtt.ruuvigateway-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-mqtt-ruuvigateway" description="MQTT Binding Ruuvi Gateway" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<feature>openhab-transport-mqtt</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version}</bundle>
|
||||
<bundle start-level="81">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}</bundle>
|
||||
<bundle start-level="82">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.ruuvigateway/${project.version}</bundle>
|
||||
</feature>
|
||||
|
||||
</features>
|
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mqtt.generic.ChannelConfig;
|
||||
import org.openhab.binding.mqtt.generic.ChannelState;
|
||||
import org.openhab.binding.mqtt.generic.values.DateTimeValue;
|
||||
import org.openhab.core.library.types.DateTimeType;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
|
||||
/**
|
||||
* Simplified state cache for purposes of caching DateTime values
|
||||
*
|
||||
* Unlike parent class {@link ChannelState}, this class by definition is not interacting with MQTT subscriptions nor
|
||||
* does it update any channels
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RuuviCachedDateTimeState extends ChannelState {
|
||||
|
||||
private static final ZoneId UTC = ZoneId.of("UTC");
|
||||
|
||||
/**
|
||||
* Construct cache for DateTime values
|
||||
*
|
||||
* @param channelUID associated channel UID
|
||||
*
|
||||
*/
|
||||
public RuuviCachedDateTimeState(ChannelUID channelUID) {
|
||||
super(new ChannelConfig(), channelUID, new DateTimeValue(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cached state with given value
|
||||
*
|
||||
* @param value instant representing value
|
||||
*/
|
||||
public void update(Instant value) {
|
||||
cachedValue.update(new DateTimeType(value.atZone(UTC)));
|
||||
}
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.measure.Quantity;
|
||||
import javax.measure.Unit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mqtt.generic.ChannelConfig;
|
||||
import org.openhab.binding.mqtt.generic.ChannelState;
|
||||
import org.openhab.binding.mqtt.generic.values.NumberValue;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
|
||||
/**
|
||||
* Simplified state cache for purposes of caching QuantityType and DecimalType values
|
||||
*
|
||||
* Unlike parent class {@link ChannelState}, this class by definition is not interacting with MQTT subscriptions nor
|
||||
* does it update any channels
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RuuviCachedNumberState<T extends Quantity<T>> extends ChannelState {
|
||||
|
||||
private final Optional<Unit<T>> unit;
|
||||
|
||||
/**
|
||||
* Construct cache for numbers with unit
|
||||
*
|
||||
* @param channelUID associated channel UID
|
||||
* @param unit unit associated with updated numbers
|
||||
*
|
||||
*/
|
||||
public RuuviCachedNumberState(ChannelUID channelUID, Unit<T> unit) {
|
||||
super(new ChannelConfig(), channelUID, new NumberValue(null, null, null, unit), null);
|
||||
this.unit = Optional.of(unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct cache for numbers without unit
|
||||
*
|
||||
* @param channelUID associated channeld UID
|
||||
*/
|
||||
public RuuviCachedNumberState(ChannelUID channelUID) {
|
||||
super(new ChannelConfig(), channelUID, new NumberValue(null, null, null, null), null);
|
||||
this.unit = Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cached state with given value
|
||||
*
|
||||
* @param value value. Specified as plain number with unit given in constructor
|
||||
*/
|
||||
public void update(Number value) {
|
||||
unit.ifPresentOrElse(unit -> cachedValue.update(new QuantityType<>(value, unit)),
|
||||
() -> cachedValue.update(new DecimalType(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get associated unit with this cache
|
||||
*
|
||||
* @return unit associated with this (if applicable)
|
||||
*/
|
||||
public Optional<Unit<T>> getUnit() {
|
||||
return unit;
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mqtt.generic.ChannelConfig;
|
||||
import org.openhab.binding.mqtt.generic.ChannelState;
|
||||
import org.openhab.binding.mqtt.generic.values.TextValue;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
|
||||
/**
|
||||
* Simplified state cache for purposes of caching StringType values
|
||||
*
|
||||
* Unlike parent class {@link ChannelState}, this class by definition is not interacting with MQTT subscriptions nor
|
||||
* does it update any channels
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RuuviCachedStringState extends ChannelState {
|
||||
|
||||
/**
|
||||
* Construct cache for Strings
|
||||
*
|
||||
* @param channelUID associated channel UID
|
||||
*
|
||||
*/
|
||||
public RuuviCachedStringState(ChannelUID channelUID) {
|
||||
super(new ChannelConfig(), channelUID, new TextValue(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cached state with given value
|
||||
*
|
||||
* @param value value. Specified as plain number with unit given in constructor
|
||||
*/
|
||||
public void update(String value) {
|
||||
cachedValue.update(new StringType(value));
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal;
|
||||
|
||||
import static org.openhab.binding.mqtt.MqttBindingConstants.BINDING_ID;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link RuuviGatewayBindingConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author Matthew Skinner - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RuuviGatewayBindingConstants {
|
||||
public static final String BASE_TOPIC = "ruuvi/";
|
||||
|
||||
// List of all Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_BEACON = new ThingTypeUID(BINDING_ID, "ruuvitag_beacon");
|
||||
|
||||
// Channel IDs
|
||||
public static final String CHANNEL_ID_BATTERY = "batteryVoltage";
|
||||
public static final String CHANNEL_ID_DATA_FORMAT = "dataFormat";
|
||||
public static final String CHANNEL_ID_TEMPERATURE = "temperature";
|
||||
public static final String CHANNEL_ID_HUMIDITY = "humidity";
|
||||
public static final String CHANNEL_ID_PRESSURE = "pressure";
|
||||
public static final String CHANNEL_ID_TX_POWER = "txPower";
|
||||
public static final String CHANNEL_ID_ACCELERATIONX = "accelerationx";
|
||||
public static final String CHANNEL_ID_ACCELERATIONY = "accelerationy";
|
||||
public static final String CHANNEL_ID_ACCELERATIONZ = "accelerationz";
|
||||
public static final String CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER = "measurementSequenceNumber";
|
||||
public static final String CHANNEL_ID_MOVEMENT_COUNTER = "movementCounter";
|
||||
|
||||
public static final String CHANNEL_ID_RSSI = "rssi";
|
||||
public static final String CHANNEL_ID_TS = "ts";
|
||||
public static final String CHANNEL_ID_GWTS = "gwts";
|
||||
public static final String CHANNEL_ID_GWMAC = "gwmac";
|
||||
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BEACON);
|
||||
public static final int RUUVI_GATEWAY_SUBSCRIBE_TIMEOUT_MS = 30000;
|
||||
|
||||
// Thing properties
|
||||
public static final String PROPERTY_TAG_ID = "tagID";
|
||||
public static final String CONFIGURATION_PROPERTY_TOPIC = "topic";
|
||||
|
||||
public static final String CONFIGURATION_PROPERTY_TIMEOUT = "timeout"; // only for tests
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal;
|
||||
|
||||
import static org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants.*;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.handler.RuuviTagHandler;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
|
||||
/**
|
||||
* The {@link RuuviTagHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Matthew Skinner - Initial contribution
|
||||
*/
|
||||
@Component(service = ThingHandlerFactory.class)
|
||||
@NonNullByDefault
|
||||
public class RuuviTagHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
|
||||
return new RuuviTagHandler(thing, RUUVI_GATEWAY_SUBSCRIBE_TIMEOUT_MS);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal.discovery;
|
||||
|
||||
import static org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.mqtt.discovery.AbstractMQTTDiscovery;
|
||||
import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.config.discovery.DiscoveryService;
|
||||
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link RuuviGatewayDiscoveryService} is responsible for finding Ruuvi Tag Sensors
|
||||
* and setting them up for the handlers.
|
||||
*
|
||||
* @author Matthew Skinner - Initial contribution
|
||||
* @author Sami Salonen - Adaptation to Ruuvi Gateway
|
||||
*/
|
||||
@Component(service = DiscoveryService.class, configurationPid = "discovery.mqttruuvigateway")
|
||||
@NonNullByDefault
|
||||
public class RuuviGatewayDiscoveryService extends AbstractMQTTDiscovery {
|
||||
protected final MQTTTopicDiscoveryService discoveryService;
|
||||
|
||||
private static final Predicate<String> HEX_PATTERN_CHECKER = Pattern
|
||||
.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$").asMatchPredicate();
|
||||
|
||||
@Activate
|
||||
public RuuviGatewayDiscoveryService(@Reference MQTTTopicDiscoveryService discoveryService) {
|
||||
super(SUPPORTED_THING_TYPES_UIDS, 3, true, BASE_TOPIC + "#");
|
||||
this.discoveryService = discoveryService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MQTTTopicDiscoveryService getDiscoveryService() {
|
||||
return discoveryService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receivedMessage(ThingUID connectionBridge, MqttBrokerConnection connection, String topic,
|
||||
byte[] payload) {
|
||||
resetTimeout();
|
||||
if (topic.startsWith(BASE_TOPIC)) {
|
||||
String cutTopic = topic.replace(BASE_TOPIC, "");
|
||||
int index = cutTopic.lastIndexOf("/");
|
||||
if (index != -1) // -1 means "not found"
|
||||
{
|
||||
String tagMacAddress = cutTopic.substring(index + 1);
|
||||
if (looksLikeMac(tagMacAddress)) {
|
||||
publishDevice(connectionBridge, connection, topic, tagMacAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void publishDevice(ThingUID connectionBridge, MqttBrokerConnection connection, String topic, String tagMacAddress) {
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
String thingID = tagMacAddress.toLowerCase().replaceAll("[:-]", "");
|
||||
String normalizedTagID = normalizedTagID(tagMacAddress);
|
||||
properties.put(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC, topic);
|
||||
properties.put(RuuviGatewayBindingConstants.PROPERTY_TAG_ID, normalizedTagID);
|
||||
properties.put(Thing.PROPERTY_VENDOR, "Ruuvi Innovations Ltd (Oy)");
|
||||
|
||||
// Discovered things are identified with their topic name, in case of having pathological case
|
||||
// where we find multiple tags with same mac address (e.g. ruuvi/gw1/mac1 and ruuvi/gw2/mac1)
|
||||
thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_BEACON, connectionBridge, thingID))
|
||||
.withProperties(properties)
|
||||
.withRepresentationProperty(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC)
|
||||
.withBridge(connectionBridge).withLabel("MQTT Ruuvi Tag " + normalizedTagID).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void topicVanished(ThingUID connectionBridge, MqttBrokerConnection connection, String topic) {
|
||||
}
|
||||
|
||||
private boolean looksLikeMac(String topic) {
|
||||
return HEX_PATTERN_CHECKER.test(topic);
|
||||
}
|
||||
|
||||
private static String normalizedTagID(String mac) {
|
||||
String nondelimited = mac.toUpperCase().replaceAll("[:-]", "");
|
||||
assert nondelimited.length() == 12; // Invariant: method to be used only with valid Ruuvi MACs
|
||||
return nondelimited.subSequence(0, 2) + ":" + nondelimited.subSequence(2, 4) + ":"
|
||||
+ nondelimited.subSequence(4, 6) + ":" + nondelimited.subSequence(6, 8) + ":"
|
||||
+ nondelimited.subSequence(8, 10) + ":" + nondelimited.subSequence(10, 12);
|
||||
}
|
||||
}
|
@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal.handler;
|
||||
|
||||
import static org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants.*;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.measure.Quantity;
|
||||
import javax.measure.Unit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.mqtt.generic.AbstractMQTTThingHandler;
|
||||
import org.openhab.binding.mqtt.generic.ChannelState;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviCachedDateTimeState;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviCachedNumberState;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviCachedStringState;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.parser.GatewayPayloadParser;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.parser.GatewayPayloadParser.GatewayPayload;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
|
||||
import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
import org.openhab.core.thing.Channel;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
/**
|
||||
* The {@link RuuviTagHandler} is responsible updating RuuviTag Sensor data received from
|
||||
* Ruuvi Gateway via MQTT.
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RuuviTagHandler extends AbstractMQTTThingHandler implements MqttMessageSubscriber {
|
||||
|
||||
// Ruuvitag sends an update every 10 seconds. So we keep a heartbeat to give it some slack
|
||||
private int heartbeatTimeoutMillisecs = 60_000;
|
||||
// This map is used to initialize channel caches.
|
||||
// Key is channel ID.
|
||||
// Value is one of the following
|
||||
// - null (plain number), uses RuuviCachedNumberState
|
||||
// - Unit (QuantityType Number), uses RuuviCachedNumberState with unit
|
||||
// - Class object, uses given class object with String constructor
|
||||
|
||||
private static final Map<String, @Nullable Object> unitByChannelUID = new HashMap<>(11);
|
||||
static {
|
||||
unitByChannelUID.put(CHANNEL_ID_ACCELERATIONX, Units.STANDARD_GRAVITY);
|
||||
unitByChannelUID.put(CHANNEL_ID_ACCELERATIONY, Units.STANDARD_GRAVITY);
|
||||
unitByChannelUID.put(CHANNEL_ID_ACCELERATIONZ, Units.STANDARD_GRAVITY);
|
||||
unitByChannelUID.put(CHANNEL_ID_BATTERY, Units.VOLT);
|
||||
unitByChannelUID.put(CHANNEL_ID_DATA_FORMAT, null);
|
||||
unitByChannelUID.put(CHANNEL_ID_HUMIDITY, Units.PERCENT);
|
||||
unitByChannelUID.put(CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER, Units.ONE);
|
||||
unitByChannelUID.put(CHANNEL_ID_MOVEMENT_COUNTER, Units.ONE);
|
||||
unitByChannelUID.put(CHANNEL_ID_PRESSURE, SIUnits.PASCAL);
|
||||
unitByChannelUID.put(CHANNEL_ID_TEMPERATURE, SIUnits.CELSIUS);
|
||||
unitByChannelUID.put(CHANNEL_ID_TX_POWER, Units.DECIBEL_MILLIWATTS);
|
||||
unitByChannelUID.put(CHANNEL_ID_RSSI, Units.DECIBEL_MILLIWATTS);
|
||||
unitByChannelUID.put(CHANNEL_ID_TS, RuuviCachedDateTimeState.class);
|
||||
unitByChannelUID.put(CHANNEL_ID_GWTS, RuuviCachedDateTimeState.class);
|
||||
unitByChannelUID.put(CHANNEL_ID_GWMAC, RuuviCachedStringState.class);
|
||||
}
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(RuuviTagHandler.class);
|
||||
/**
|
||||
* Indicator whether we have received data recently
|
||||
*/
|
||||
private final AtomicBoolean receivedData = new AtomicBoolean();
|
||||
private final Map<ChannelUID, ChannelState> channelStateByChannelUID = new HashMap<>();
|
||||
private @NonNullByDefault({}) ScheduledFuture<?> heartbeatFuture;
|
||||
|
||||
/**
|
||||
* Topic with data for this particular Ruuvi Tag. Set in initialize (when configuration is valid).
|
||||
*/
|
||||
private @NonNullByDefault({}) String topic;
|
||||
|
||||
public RuuviTagHandler(Thing thing, int subscribeTimeout) {
|
||||
super(thing, subscribeTimeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
initializeChannelCaches();
|
||||
Configuration configuration = getThing().getConfiguration();
|
||||
String topic = (String) configuration.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC);
|
||||
if (topic == null || topic.isBlank()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.configuration-error.missing-topic");
|
||||
return;
|
||||
}
|
||||
Object timeout = configuration.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TIMEOUT);
|
||||
if (timeout != null) {
|
||||
// Note: only in tests
|
||||
heartbeatTimeoutMillisecs = Integer.parseInt(timeout.toString());
|
||||
logger.warn("Using overridden timeout: {}", heartbeatTimeoutMillisecs);
|
||||
}
|
||||
|
||||
this.topic = topic;
|
||||
super.initialize();
|
||||
}
|
||||
|
||||
private void initializeChannelCaches() {
|
||||
for (Channel channel : thing.getChannels()) {
|
||||
ChannelUID channelUID = channel.getUID();
|
||||
String channelID = channelUID.getId();
|
||||
assert unitByChannelUID.containsKey(channelID); // Invariant as all channels should exist in the static map
|
||||
Object cacheHint = unitByChannelUID.get(channelID);
|
||||
if (cacheHint == null || cacheHint instanceof Unit<?>) {
|
||||
Unit<?> unit = (Unit<?>) cacheHint;
|
||||
initNumberStateCache(channelUID, unit);
|
||||
} else {
|
||||
Class<?> cacheType = (Class<?>) cacheHint;
|
||||
initCacheWithClass(channelUID, cacheType);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private <T extends Quantity<T>> RuuviCachedNumberState<?> initNumberStateCache(ChannelUID channelUID,
|
||||
@Nullable Unit<T> unit) {
|
||||
final RuuviCachedNumberState<?> cached;
|
||||
if (unit == null) {
|
||||
cached = new RuuviCachedNumberState<>(channelUID);
|
||||
channelStateByChannelUID.put(channelUID, cached);
|
||||
} else {
|
||||
cached = new RuuviCachedNumberState<>(channelUID, unit);
|
||||
channelStateByChannelUID.put(channelUID, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
private ChannelState initCacheWithClass(ChannelUID channelUID, Class<?> clazz) {
|
||||
try {
|
||||
ChannelState cached = (ChannelState) clazz.getConstructor(ChannelUID.class).newInstance(channelUID);
|
||||
Objects.requireNonNull(cached); // to make compiler happy
|
||||
channelStateByChannelUID.put(channelUID, cached);
|
||||
return cached;
|
||||
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException
|
||||
| NoSuchMethodException | SecurityException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection) {
|
||||
if (topic == null) {
|
||||
// Initialization has not been completed successfully, return early without changing
|
||||
// thing status
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
return connection.subscribe(topic, this).handle((subscriptionSuccess, subscriptionException) -> {
|
||||
if (subscriptionSuccess) {
|
||||
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/online.waiting-initial-data");
|
||||
heartbeatFuture = scheduler.scheduleWithFixedDelay(this::heartbeat, heartbeatTimeoutMillisecs,
|
||||
heartbeatTimeoutMillisecs, TimeUnit.MILLISECONDS);
|
||||
} else {
|
||||
if (subscriptionException == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.communication-error.mqtt-subscription-failed");
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.communication-error.mqtt-subscription-failed-details [\""
|
||||
+ subscriptionException.getClass().getSimpleName() + "\", \""
|
||||
+ subscriptionException.getMessage() + "\"]");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> unsubscribeAll() {
|
||||
MqttBrokerConnection localConnection = connection;
|
||||
String localTopic = topic;
|
||||
if (localConnection != null && localTopic != null) {
|
||||
return localConnection.unsubscribe(localTopic, this).thenCompose(unsubscribeSuccessful -> null);
|
||||
} else {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void stop() {
|
||||
ScheduledFuture<?> localHeartbeatFuture = heartbeatFuture;
|
||||
if (localHeartbeatFuture != null) {
|
||||
localHeartbeatFuture.cancel(true);
|
||||
heartbeatFuture = null;
|
||||
}
|
||||
channelStateByChannelUID.values().forEach(c -> c.getCache().resetState());
|
||||
super.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
channelStateByChannelUID.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called regularly. Tries to set receivedData to false. If it was already false and thing is ONLINE,
|
||||
* update thing as OFFLINE with COMMUNICATION_ERROR.
|
||||
*/
|
||||
private void heartbeat() {
|
||||
synchronized (receivedData) {
|
||||
if (!receivedData.getAndSet(false) && getThing().getStatus() == ThingStatus.ONLINE) {
|
||||
getThing().getChannels().stream().map(Channel::getUID).filter(this::isLinked)
|
||||
.forEach(c -> updateChannelState(c, UnDefType.UNDEF));
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.communication-error.timeout");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processMessage(String topic, byte[] payload) {
|
||||
receivedData.set(true);
|
||||
|
||||
final GatewayPayload parsed;
|
||||
try {
|
||||
parsed = GatewayPayloadParser.parse(payload);
|
||||
} catch (JsonSyntaxException | IllegalArgumentException e) {
|
||||
// Perhaps thing has been configured with wrong topic. Logging extra details with trace
|
||||
// Thing status change will be visible in logs with higher log level
|
||||
logger.trace("Received invalid data which could not be parsed to any known Ruuvi Tag data formats ({}): {}",
|
||||
e.getMessage(), new String(payload, StandardCharsets.UTF_8));
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.communication-error.parse-error [\"" + e.getMessage() + "\"]");
|
||||
return;
|
||||
}
|
||||
var ruuvitagData = parsed.measurement;
|
||||
|
||||
boolean atLeastOneRuuviFieldPresent = false;
|
||||
for (Channel channel : thing.getChannels()) {
|
||||
ChannelUID channelUID = channel.getUID();
|
||||
switch (channelUID.getId()) {
|
||||
case CHANNEL_ID_ACCELERATIONX:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getAccelerationX());
|
||||
break;
|
||||
case CHANNEL_ID_ACCELERATIONY:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getAccelerationY());
|
||||
break;
|
||||
case CHANNEL_ID_ACCELERATIONZ:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getAccelerationZ());
|
||||
break;
|
||||
case CHANNEL_ID_BATTERY:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getBatteryVoltage());
|
||||
break;
|
||||
case CHANNEL_ID_DATA_FORMAT:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getDataFormat());
|
||||
break;
|
||||
case CHANNEL_ID_HUMIDITY:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getHumidity());
|
||||
break;
|
||||
case CHANNEL_ID_MEASUREMENT_SEQUENCE_NUMBER:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID,
|
||||
ruuvitagData.getMeasurementSequenceNumber());
|
||||
break;
|
||||
case CHANNEL_ID_MOVEMENT_COUNTER:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getMovementCounter());
|
||||
break;
|
||||
case CHANNEL_ID_PRESSURE:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getPressure());
|
||||
break;
|
||||
case CHANNEL_ID_TEMPERATURE:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getTemperature());
|
||||
break;
|
||||
case CHANNEL_ID_TX_POWER:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, ruuvitagData.getTxPower());
|
||||
break;
|
||||
//
|
||||
// Auxiliary channels, not part of bluetooth advertisement
|
||||
//
|
||||
case CHANNEL_ID_RSSI:
|
||||
atLeastOneRuuviFieldPresent |= updateStateIfLinked(channelUID, parsed.rssi);
|
||||
break;
|
||||
case CHANNEL_ID_TS:
|
||||
atLeastOneRuuviFieldPresent |= updateDateTimeStateIfLinked(channelUID, parsed.ts);
|
||||
break;
|
||||
case CHANNEL_ID_GWTS:
|
||||
atLeastOneRuuviFieldPresent |= updateDateTimeStateIfLinked(channelUID, parsed.gwts);
|
||||
break;
|
||||
case CHANNEL_ID_GWMAC:
|
||||
atLeastOneRuuviFieldPresent |= updateStringStateIfLinked(channelUID, parsed.gwMac);
|
||||
break;
|
||||
default:
|
||||
logger.warn("BUG: We have unhandled channel: {}",
|
||||
thing.getChannels().stream().map(Channel::getUID).collect(Collectors.toList()));
|
||||
}
|
||||
}
|
||||
if (atLeastOneRuuviFieldPresent) {
|
||||
String thingStatusDescription = getThing().getStatusInfo().getDescription();
|
||||
if (getThing().getStatus() != ThingStatus.ONLINE
|
||||
|| (thingStatusDescription != null && !thingStatusDescription.isBlank())) {
|
||||
// Update thing as ONLINE and possibly clear the thing detail status
|
||||
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
|
||||
}
|
||||
} else {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Received Ruuvi Tag data but no fields could be parsed: {}", HexUtils.bytesToHex(payload));
|
||||
}
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.communication-error.parse-error-no-fields");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ChannelState getChannelState(ChannelUID channelUID) {
|
||||
return channelStateByChannelUID.get(channelUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateThingStatus(boolean messageReceived, Optional<Boolean> availabilityTopicsSeen) {
|
||||
// Not used here
|
||||
}
|
||||
|
||||
/**
|
||||
* Update number channel state
|
||||
*
|
||||
* Update is not done when value is null.
|
||||
*
|
||||
* @param channelUID channel UID
|
||||
* @param value value to update
|
||||
* @return whether the value was present
|
||||
*/
|
||||
private boolean updateStateIfLinked(ChannelUID channelUID, @Nullable Number value) {
|
||||
RuuviCachedNumberState<?> cache = (RuuviCachedNumberState<?>) channelStateByChannelUID.get(channelUID);
|
||||
if (cache == null) {
|
||||
// Invariant as channels should be initialized already
|
||||
logger.warn("Channel {} not initialized. BUG", channelUID);
|
||||
return false;
|
||||
}
|
||||
if (value == null) {
|
||||
return false;
|
||||
} else {
|
||||
cache.update(value);
|
||||
if (isLinked(channelUID)) {
|
||||
updateChannelState(channelUID, cache.getCache().getChannelState());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update string channel state
|
||||
*
|
||||
* Update is not done when value is null.
|
||||
*
|
||||
* @param channelUID channel UID
|
||||
* @param value value to update
|
||||
* @return whether the value was present
|
||||
*/
|
||||
private <T extends Quantity<T>> boolean updateStringStateIfLinked(ChannelUID channelUID, Optional<String> value) {
|
||||
RuuviCachedStringState cache = (RuuviCachedStringState) channelStateByChannelUID.get(channelUID);
|
||||
if (cache == null) {
|
||||
// Invariant as channels should be initialized already
|
||||
logger.error("Channel {} not initialized. BUG", channelUID);
|
||||
return false;
|
||||
}
|
||||
if (value.isEmpty()) {
|
||||
return false;
|
||||
} else {
|
||||
cache.update(value.get());
|
||||
if (isLinked(channelUID)) {
|
||||
updateChannelState(channelUID, cache.getCache().getChannelState());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update date time channel state
|
||||
*
|
||||
* Update is not done when value is null.
|
||||
*
|
||||
* @param channelUID channel UID
|
||||
* @param value value to update
|
||||
* @return whether the value was present
|
||||
*/
|
||||
private boolean updateDateTimeStateIfLinked(ChannelUID channelUID, Optional<Instant> value) {
|
||||
RuuviCachedDateTimeState cache = (RuuviCachedDateTimeState) channelStateByChannelUID.get(channelUID);
|
||||
if (cache == null) {
|
||||
// Invariant as channels should be initialized already
|
||||
logger.error("Channel {} not initialized. BUG", channelUID);
|
||||
return false;
|
||||
}
|
||||
if (value.isEmpty()) {
|
||||
return false;
|
||||
} else {
|
||||
cache.update(value.get());
|
||||
if (isLinked(channelUID)) {
|
||||
updateChannelState(channelUID, cache.getCache().getChannelState());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal.parser;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
import fi.tkgwf.ruuvi.common.bean.RuuviMeasurement;
|
||||
import fi.tkgwf.ruuvi.common.parser.impl.AnyDataFormatParser;
|
||||
|
||||
/**
|
||||
* The {@link GatewayPayloadParser} is responsible for parsing Ruuvi Gateway MQTT JSON payloads.
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GatewayPayloadParser {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GatewayPayloadParser.class);
|
||||
private static final Gson GSON = new GsonBuilder().create();
|
||||
private static final AnyDataFormatParser parser = new AnyDataFormatParser();
|
||||
private static final Predicate<String> HEX_PATTERN_CHECKER = Pattern.compile("^([0-9A-Fa-f]{2})+$")
|
||||
.asMatchPredicate();
|
||||
|
||||
/**
|
||||
* JSON MQTT payload sent by Ruuvi Gateway
|
||||
*
|
||||
* See https://docs.ruuvi.com/gw-data-formats/mqtt-time-stamped-data-from-bluetooth-sensors
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*
|
||||
*/
|
||||
public static class GatewayPayload {
|
||||
/**
|
||||
* MAC-address of Ruuvi Gateway
|
||||
*/
|
||||
public Optional<String> gwMac = Optional.empty();
|
||||
/**
|
||||
* RSSI
|
||||
*/
|
||||
public int rssi;
|
||||
/**
|
||||
* Timestamp when the message from Bluetooth-sensor was relayed by Gateway
|
||||
*
|
||||
*/
|
||||
public Optional<Instant> gwts = Optional.empty();
|
||||
|
||||
/**
|
||||
* Timestamp (Unix-time) when the message from Bluetooth-sensor was received by Gateway
|
||||
*
|
||||
*/
|
||||
public Optional<Instant> ts = Optional.empty();
|
||||
public RuuviMeasurement measurement;
|
||||
|
||||
private GatewayPayload(GatewayPayloadIntermediate intermediate) throws IllegalArgumentException {
|
||||
String gwMac = intermediate.gw_mac;
|
||||
if (gwMac == null) {
|
||||
logger.trace("Missing mandatory field 'gw_mac', ignoring");
|
||||
}
|
||||
this.gwMac = Optional.ofNullable(gwMac);
|
||||
rssi = intermediate.rssi;
|
||||
try {
|
||||
gwts = Optional.of(Instant.ofEpochSecond(intermediate.gwts));
|
||||
} catch (DateTimeException e) {
|
||||
logger.debug("Field 'gwts' is a not valid time (epoch second), ignoring: {}", intermediate.gwts);
|
||||
}
|
||||
try {
|
||||
ts = Optional.of(Instant.ofEpochSecond(intermediate.ts));
|
||||
} catch (DateTimeException e) {
|
||||
logger.debug("Field 'ts' is a not valid time (epoch second), ignoring: {}", intermediate.ts);
|
||||
}
|
||||
|
||||
String localData = intermediate.data;
|
||||
if (localData == null) {
|
||||
throw new IllegalArgumentException("Missing mandatory field 'data'");
|
||||
}
|
||||
|
||||
if (!HEX_PATTERN_CHECKER.test(localData)) {
|
||||
logger.debug(
|
||||
"Data is not representing manufacturer specific bluetooth advertisement, it is not valid hex: {}",
|
||||
localData);
|
||||
throw new IllegalArgumentException(
|
||||
"Data is not representing manufacturer specific bluetooth advertisement, it is not valid hex: "
|
||||
+ localData);
|
||||
}
|
||||
byte[] bytes = HexUtils.hexToBytes(localData);
|
||||
if (bytes.length < 6) {
|
||||
// We want at least 6 bytes, ensuring bytes[5] is valid as well as Arrays.copyOfRange(bytes, 5, ...)
|
||||
// below
|
||||
// The payload length (might depend on format version ) is validated by parser.parse call
|
||||
throw new IllegalArgumentException("Manufacturerer data is too short");
|
||||
|
||||
}
|
||||
if ((bytes[4] & 0xff) != 0xff) {
|
||||
logger.debug("Data is not representing manufacturer specific bluetooth advertisement: {}",
|
||||
HexUtils.bytesToHex(bytes));
|
||||
throw new IllegalArgumentException(
|
||||
"Data is not representing manufacturer specific bluetooth advertisement");
|
||||
}
|
||||
// Manufacturer data starts after 0xFF byte, at index 5
|
||||
byte[] manufacturerData = Arrays.copyOfRange(bytes, 5, bytes.length);
|
||||
RuuviMeasurement localManufacturerData = parser.parse(manufacturerData);
|
||||
if (localManufacturerData == null) {
|
||||
logger.trace("Manufacturer data is not valid: {}", HexUtils.bytesToHex(manufacturerData));
|
||||
throw new IllegalArgumentException("Manufacturer data is not valid");
|
||||
}
|
||||
measurement = localManufacturerData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* JSON MQTT payload sent by Ruuvi Gateway (intermediate representation).
|
||||
*
|
||||
* This intermediate representation tries to match the low level JSON, making little data validation and conversion.
|
||||
*
|
||||
* Fields are descibed in https://docs.ruuvi.com/gw-data-formats/mqtt-time-stamped-data-from-bluetooth-sensors
|
||||
*
|
||||
* Fields are marked as nullable as GSON might apply nulls at runtime.
|
||||
*
|
||||
* @author Sami Salonen - Initial Contribution
|
||||
* @see GatewayPayload Equivalent of this class but with additional data validation and typing
|
||||
*
|
||||
*/
|
||||
private static class GatewayPayloadIntermediate {
|
||||
public @Nullable String gw_mac;
|
||||
public int rssi;
|
||||
public long gwts;
|
||||
public long ts;
|
||||
public @Nullable String data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MQTT JSON payload advertised by Ruuvi Gateway
|
||||
*
|
||||
* @param jsonPayload json payload of the Ruuvi sensor MQTT topic, as bytes
|
||||
* @return parsed payload
|
||||
* @throws JsonSyntaxException raised with JSON syntax exceptions and clearly invalid JSON types
|
||||
* @throws IllegalArgumentException raised with invalid or unparseable data
|
||||
*/
|
||||
public static GatewayPayload parse(byte[] jsonPayload) throws JsonSyntaxException, IllegalArgumentException {
|
||||
String jsonPayloadString = new String(jsonPayload, StandardCharsets.UTF_8);
|
||||
GatewayPayloadIntermediate payloadIntermediate = GSON.fromJson(jsonPayloadString,
|
||||
GatewayPayloadIntermediate.class);
|
||||
if (payloadIntermediate == null) {
|
||||
throw new JsonSyntaxException("JSON parsing failed");
|
||||
}
|
||||
GatewayPayload payload = new GatewayPayload(payloadIntermediate);
|
||||
return payload;
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
# thing types
|
||||
|
||||
thing-type.mqtt.ruuvitag_beacon.label = RuuviTag SmartBeacon
|
||||
thing-type.mqtt.ruuvitag_beacon.description = A RuuviTag SmartBeacon
|
||||
|
||||
# thing types config
|
||||
|
||||
thing-type.config.mqtt.ruuvitag_beacon.topic.label = MQTT topic
|
||||
thing-type.config.mqtt.ruuvitag_beacon.topic.description = MQTT topic containing the payload
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.mqtt.ruuvitag_accelerationx.label = Acceleration X
|
||||
channel-type.mqtt.ruuvitag_accelerationy.label = Acceleration Y
|
||||
channel-type.mqtt.ruuvitag_accelerationz.label = Acceleration Z
|
||||
channel-type.mqtt.ruuvitag_batteryVoltage.label = Battery Voltage
|
||||
channel-type.mqtt.ruuvitag_dataFormat.label = Data Format Version
|
||||
channel-type.mqtt.ruuvitag_gwmac.label = Gateway MAC Address
|
||||
channel-type.mqtt.ruuvitag_gwmac.description = MAC-address of Ruuvi Gateway
|
||||
channel-type.mqtt.ruuvitag_gwts.label = Relay Timestamp
|
||||
channel-type.mqtt.ruuvitag_gwts.description = Timestamp when the message from Bluetooth sensor was relayed by Gateway (gwts)
|
||||
channel-type.mqtt.ruuvitag_gwts.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS
|
||||
channel-type.mqtt.ruuvitag_humidity.label = Humidity
|
||||
channel-type.mqtt.ruuvitag_measurementSequenceNumber.label = Measurement Sequence Number
|
||||
channel-type.mqtt.ruuvitag_movementCounter.label = Movement Counter
|
||||
channel-type.mqtt.ruuvitag_pressure.label = Pressure
|
||||
channel-type.mqtt.ruuvitag_rssi.label = RSSI
|
||||
channel-type.mqtt.ruuvitag_rssi.description = Received signal strength indicator
|
||||
channel-type.mqtt.ruuvitag_temperature.label = Temperature
|
||||
channel-type.mqtt.ruuvitag_ts.label = Timestamp
|
||||
channel-type.mqtt.ruuvitag_ts.description = Timestamp when the message from Bluetooth sensor was received by Gateway (ts)
|
||||
channel-type.mqtt.ruuvitag_ts.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS
|
||||
channel-type.mqtt.ruuvitag_txPower.label = TX Power
|
||||
|
||||
# Thing status messages
|
||||
|
||||
online.waiting-initial-data = Waiting for initial data
|
||||
offline.configuration-error.missing-topic = Missing topic configuration, cannot subscribe to relevant MQTT topic
|
||||
offline.communication-error.mqtt-subscription-failed = MQTT subscription failed
|
||||
offline.communication-error.mqtt-subscription-failed-details = MQTT subscription failed, {0}: {1}
|
||||
offline.communication-error.timeout = No valid data received for some time
|
||||
offline.communication-error.parse-error = Received Bluetooth data which could not be parsed to any known Ruuvi Tag data formats ({0})
|
||||
offline.communication-error.parse-error-no-fields = Received Ruuvi Tag data but no fields could be parsed
|
@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="mqtt"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<thing-type id="ruuvitag_beacon">
|
||||
<supported-bridge-type-refs>
|
||||
<bridge-type-ref id="broker"/>
|
||||
</supported-bridge-type-refs>
|
||||
|
||||
<label>RuuviTag SmartBeacon</label>
|
||||
<description>A RuuviTag SmartBeacon</description>
|
||||
|
||||
<channels>
|
||||
<channel id="rssi" typeId="ruuvitag_rssi"/>
|
||||
<channel id="ts" typeId="ruuvitag_ts"/>
|
||||
<channel id="gwts" typeId="ruuvitag_gwts"/>
|
||||
<channel id="gwmac" typeId="ruuvitag_gwmac"/>
|
||||
<channel id="accelerationx" typeId="ruuvitag_accelerationx"/>
|
||||
<channel id="accelerationy" typeId="ruuvitag_accelerationy"/>
|
||||
<channel id="accelerationz" typeId="ruuvitag_accelerationz"/>
|
||||
<channel id="batteryVoltage" typeId="ruuvitag_batteryVoltage"/>
|
||||
<channel id="dataFormat" typeId="ruuvitag_dataFormat"/>
|
||||
<channel id="humidity" typeId="ruuvitag_humidity"/>
|
||||
<channel id="measurementSequenceNumber" typeId="ruuvitag_measurementSequenceNumber"/>
|
||||
<channel id="movementCounter" typeId="ruuvitag_movementCounter"/>
|
||||
<channel id="pressure" typeId="ruuvitag_pressure"/>
|
||||
<channel id="temperature" typeId="ruuvitag_temperature"/>
|
||||
<channel id="txPower" typeId="ruuvitag_txPower"/>
|
||||
</channels>
|
||||
|
||||
<config-description>
|
||||
<parameter name="topic" type="text">
|
||||
<label>MQTT Topic</label>
|
||||
<description>MQTT topic containing the payload</description>
|
||||
</parameter>
|
||||
</config-description>
|
||||
|
||||
</thing-type>
|
||||
|
||||
<channel-type id="ruuvitag_rssi">
|
||||
<item-type>Number</item-type>
|
||||
<label>RSSI</label>
|
||||
<description>Received signal strength indicator</description>
|
||||
<category>QualityOfService</category>
|
||||
<state readOnly="true" pattern="%d dBm"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_ts">
|
||||
<item-type>DateTime</item-type>
|
||||
<label>Timestamp</label>
|
||||
<description>Timestamp when the message from Bluetooth sensor was received by Gateway (ts)</description>
|
||||
<category>Time</category>
|
||||
<state readOnly="true" pattern="%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_gwts" advanced="true">
|
||||
<item-type>DateTime</item-type>
|
||||
<label>Relay Timestamp</label>
|
||||
<description>Timestamp when the message from Bluetooth sensor was relayed by Gateway (gwts)</description>
|
||||
<category>Time</category>
|
||||
<state readOnly="true" pattern="%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_gwmac" advanced="true">
|
||||
<item-type>String</item-type>
|
||||
<label>Gateway MAC Address</label>
|
||||
<description>MAC-address of Ruuvi Gateway</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="ruuvitag_accelerationx">
|
||||
<item-type>Number:Acceleration</item-type>
|
||||
<label>Acceleration X</label>
|
||||
<state readOnly="true" pattern="%.3f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_accelerationy">
|
||||
<item-type>Number:Acceleration</item-type>
|
||||
<label>Acceleration Y</label>
|
||||
<state readOnly="true" pattern="%.3f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_accelerationz">
|
||||
<item-type>Number:Acceleration</item-type>
|
||||
<label>Acceleration Z</label>
|
||||
<state readOnly="true" pattern="%.3f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_batteryVoltage">
|
||||
<item-type>Number:ElectricPotential</item-type>
|
||||
<label>Battery Voltage</label>
|
||||
<state readOnly="true" pattern="%.1f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_dataFormat" advanced="true">
|
||||
<item-type>Number</item-type>
|
||||
<label>Data Format Version</label>
|
||||
<state readOnly="true" pattern="%.0f"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_humidity">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>Humidity</label>
|
||||
<state readOnly="true" pattern="%.1f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_measurementSequenceNumber" advanced="true">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>Measurement Sequence Number</label>
|
||||
<state readOnly="true" pattern="%.0f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_movementCounter" advanced="true">
|
||||
<item-type>Number:Dimensionless</item-type>
|
||||
<label>Movement Counter</label>
|
||||
<state readOnly="true" pattern="%.0f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_pressure">
|
||||
<item-type>Number:Pressure</item-type>
|
||||
<label>Pressure</label>
|
||||
<state readOnly="true" pattern="%.0f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_temperature">
|
||||
<item-type>Number:Temperature</item-type>
|
||||
<label>Temperature</label>
|
||||
<state readOnly="true" pattern="%.1f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="ruuvitag_txPower">
|
||||
<item-type>Number:Power</item-type>
|
||||
<label>TX Power</label>
|
||||
<state readOnly="true" pattern="%.0f %unit%"/>
|
||||
</channel-type>
|
||||
|
||||
</thing:thing-descriptions>
|
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal.discovery;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.openhab.binding.mqtt.MqttBindingConstants;
|
||||
import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.RuuviGatewayBindingConstants;
|
||||
import org.openhab.core.config.discovery.DiscoveryListener;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryService;
|
||||
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
|
||||
/**
|
||||
* Tests for {@link RuuviGatewayDiscoveryService}
|
||||
*
|
||||
* @author Anton Kharuzhy - Initial contribution
|
||||
* @author Sami Salonen - Adapted from Home Assistant to Ruuvi Gateway tests
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@NonNullByDefault
|
||||
public class RuuviGatewayDiscoveryTests {
|
||||
private @NonNullByDefault({}) RuuviGatewayDiscoveryService discovery;
|
||||
private static final ThingUID MQTT_BRIDGE_UID = new ThingUID(MqttBindingConstants.BRIDGE_TYPE_BROKER, "broker");
|
||||
|
||||
private @Mock @NonNullByDefault({}) MQTTTopicDiscoveryService mqttTopicDiscoveryService;
|
||||
private @Mock @NonNullByDefault({}) MqttBrokerConnection mqttConnection;
|
||||
|
||||
@BeforeEach
|
||||
public void beforeEach() {
|
||||
discovery = new RuuviGatewayDiscoveryService(mqttTopicDiscoveryService);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "de:ea:DB:be:ff:00", "de:ea:DB:be:ff-00", "de-ea-DB-be-ff-00" })
|
||||
public void testDiscoveryMacFormatPermutations(String leafTopic) throws Exception {
|
||||
var discoveryListener = new LatchDiscoveryListener();
|
||||
var latch = discoveryListener.createWaitForThingsDiscoveredLatch(1);
|
||||
|
||||
// When discover one thing with two channels
|
||||
discovery.addDiscoveryListener(discoveryListener);
|
||||
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/" + leafTopic, "{}".getBytes());
|
||||
|
||||
// Then one thing found
|
||||
assertTrue(latch.await(3, TimeUnit.SECONDS));
|
||||
var discoveryResults = discoveryListener.getDiscoveryResults();
|
||||
assertThat(discoveryResults.size(), is(1));
|
||||
@Nullable
|
||||
DiscoveryResult result = discoveryResults.get(0);
|
||||
Objects.requireNonNull(result); // Make compiler happy
|
||||
assertThat(result.getBridgeUID(), is(MQTT_BRIDGE_UID));
|
||||
assertThat(result.getProperties().get(Thing.PROPERTY_VENDOR), is("Ruuvi Innovations Ltd (Oy)"));
|
||||
assertThat(result.getProperties().get(RuuviGatewayBindingConstants.PROPERTY_TAG_ID), is("DE:EA:DB:BE:FF:00"));
|
||||
assertThat(result.getProperties().get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC),
|
||||
is("ruuvi/foo/bar/" + leafTopic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDiscoveryMultipleThings() throws Exception {
|
||||
var discoveryListener = new LatchDiscoveryListener();
|
||||
var latch = discoveryListener.createWaitForThingsDiscoveredLatch(2);
|
||||
|
||||
discovery.addDiscoveryListener(discoveryListener);
|
||||
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "something/to/ignore/ruuvi/foo/bar/invalid:mac",
|
||||
"{}".getBytes());
|
||||
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/invalid:mac", "{}".getBytes());
|
||||
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/aa:bb", "{}".getBytes()); // too short
|
||||
// mac
|
||||
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/de:ea:DB:be:ff:00", "{}".getBytes());
|
||||
discovery.receivedMessage(MQTT_BRIDGE_UID, mqttConnection, "ruuvi/foo/bar/de:ea:DB:be:ff:01", "{}".getBytes());
|
||||
|
||||
// Then one thing found
|
||||
assertTrue(latch.await(3, TimeUnit.SECONDS));
|
||||
var discoveryResults = discoveryListener.getDiscoveryResults();
|
||||
assertThat(discoveryResults.size(), is(2));
|
||||
|
||||
assertTrue(discoveryResults.stream().allMatch(result -> {
|
||||
assertThat(result.getBridgeUID(), is(MQTT_BRIDGE_UID));
|
||||
assertThat(result.getProperties().get(Thing.PROPERTY_VENDOR), is("Ruuvi Innovations Ltd (Oy)"));
|
||||
return true;
|
||||
}));
|
||||
|
||||
assertTrue(//
|
||||
discoveryResults.stream().anyMatch(result -> {
|
||||
return "DE:EA:DB:BE:FF:00"
|
||||
.equals(result.getProperties().get(RuuviGatewayBindingConstants.PROPERTY_TAG_ID))
|
||||
&& "ruuvi/foo/bar/de:ea:DB:be:ff:00".equals(result.getProperties()
|
||||
.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC));
|
||||
}) && //
|
||||
discoveryResults.stream().anyMatch(result -> {
|
||||
return "DE:EA:DB:BE:FF:01"
|
||||
.equals(result.getProperties().get(RuuviGatewayBindingConstants.PROPERTY_TAG_ID))
|
||||
&& "ruuvi/foo/bar/de:ea:DB:be:ff:01".equals(result.getProperties()
|
||||
.get(RuuviGatewayBindingConstants.CONFIGURATION_PROPERTY_TOPIC));
|
||||
})
|
||||
|
||||
, "Failed to match: " + discoveryResults.toString());
|
||||
}
|
||||
|
||||
private static class LatchDiscoveryListener implements DiscoveryListener {
|
||||
private final CopyOnWriteArrayList<DiscoveryResult> discoveryResults = new CopyOnWriteArrayList<>();
|
||||
private @Nullable CountDownLatch latch;
|
||||
|
||||
@Override
|
||||
public void thingDiscovered(DiscoveryService source, DiscoveryResult result) {
|
||||
discoveryResults.add(result);
|
||||
CountDownLatch localLatch = latch;
|
||||
if (localLatch != null) {
|
||||
localLatch.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void thingRemoved(DiscoveryService source, ThingUID thingUID) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Collection<ThingUID> removeOlderResults(DiscoveryService source, long timestamp,
|
||||
@Nullable Collection<ThingTypeUID> thingTypeUIDs, @Nullable ThingUID bridgeUID) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
public CopyOnWriteArrayList<DiscoveryResult> getDiscoveryResults() {
|
||||
return discoveryResults;
|
||||
}
|
||||
|
||||
public CountDownLatch createWaitForThingsDiscoveredLatch(int count) {
|
||||
final var newLatch = new CountDownLatch(count);
|
||||
latch = newLatch;
|
||||
return newLatch;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.mqtt.ruuvigateway.internal.parser;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.mqtt.ruuvigateway.internal.parser.GatewayPayloadParser.GatewayPayload;
|
||||
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
/**
|
||||
* Tests for {@link GatewayPayloadParser}
|
||||
*
|
||||
* @author Sami Salonen - Initial Contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GatewayPayloadParserTests {
|
||||
|
||||
private byte[] bytes(String str) {
|
||||
ByteBuffer buffer = StandardCharsets.UTF_8.encode(str);
|
||||
buffer.rewind();
|
||||
byte[] bytes = new byte[buffer.remaining()];
|
||||
buffer.get(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with valid data.
|
||||
*
|
||||
* See 'valid case' test vector from
|
||||
* https://docs.ruuvi.com/communication/bluetooth-advertisements/data-format-5-rawv2
|
||||
*/
|
||||
@Test
|
||||
public void testValid() {
|
||||
GatewayPayload parsed = GatewayPayloadParser.parse(bytes(//
|
||||
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
|
||||
+ " \"rssi\": -83,"//
|
||||
+ " \"aoa\": [],"//
|
||||
+ " \"gwts\": \"1659365438\","//
|
||||
+ " \"ts\": \"1659365439\","//
|
||||
+ " \"data\": \"0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","//
|
||||
+ " \"coords\": \"\"" + "}"));
|
||||
assertNotNull(parsed);
|
||||
assertEquals(-83, parsed.rssi);
|
||||
assertEquals(Optional.of(Instant.ofEpochSecond(1659365438)), parsed.gwts);
|
||||
assertEquals(Optional.of(Instant.ofEpochSecond(1659365439)), parsed.ts);
|
||||
assertEquals(24.3, parsed.measurement.getTemperature());
|
||||
assertEquals(100044, parsed.measurement.getPressure());
|
||||
assertEquals(5, parsed.measurement.getDataFormat());
|
||||
assertEquals(53.49, parsed.measurement.getHumidity());
|
||||
assertEquals(0.004, parsed.measurement.getAccelerationX());
|
||||
assertEquals(-0.004, parsed.measurement.getAccelerationY());
|
||||
assertEquals(1.036, parsed.measurement.getAccelerationZ());
|
||||
assertEquals(4, parsed.measurement.getTxPower());
|
||||
assertEquals(2.9770000000000003, parsed.measurement.getBatteryVoltage());
|
||||
assertEquals(66, parsed.measurement.getMovementCounter());
|
||||
assertEquals(205, parsed.measurement.getMeasurementSequenceNumber());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidJSON() {
|
||||
assertThrows(JsonSyntaxException.class, () -> {
|
||||
GatewayPayloadParser.parse(bytes(//
|
||||
"invalid json"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUnexpectedTypes() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
GatewayPayloadParser.parse(bytes(//
|
||||
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
|
||||
+ " \"rssi\": -83,"//
|
||||
+ " \"aoa\": [],"//
|
||||
+ " \"gwts\": \"1659365438\","//
|
||||
+ " \"ts\": \"1659365438\","//
|
||||
+ " \"data\": 666," // should be hex-string of even length
|
||||
+ " \"coords\": \"\"" + "}"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidHex() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
GatewayPayloadParser.parse(bytes(//
|
||||
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
|
||||
+ " \"rssi\": -83,"//
|
||||
+ " \"aoa\": [],"//
|
||||
+ " \"gwts\": \"1659365438\","//
|
||||
+ " \"ts\": \"1659365438\","//
|
||||
+ " \"data\": \"XYZZ\"," // should be hex string
|
||||
+ " \"coords\": \"\"" + "}"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUnexpectedTypes3() {
|
||||
assertThrows(JsonSyntaxException.class, () -> {
|
||||
GatewayPayloadParser.parse(bytes(//
|
||||
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
|
||||
+ " \"rssi\": \"foobar\","// should be number
|
||||
+ " \"aoa\": [],"//
|
||||
+ " \"gwts\": \"1659365438\","//
|
||||
+ " \"ts\": \"1659365438\","//
|
||||
+ " \"data\": \"0201061BFF99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
|
||||
+ " \"coords\": \"\"" + "}"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDataTooShort() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
GatewayPayloadParser.parse(bytes(//
|
||||
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
|
||||
+ " \"rssi\": -83," + " \"aoa\": [],"//
|
||||
+ " \"gwts\": \"1659365438\","//
|
||||
+ " \"ts\": \"1659365438\","//
|
||||
+ " \"data\": \"0201061BFF990405\"," // too short
|
||||
+ " \"coords\": \"\"" + "}"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUnexpectedManufacturer() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
GatewayPayloadParser.parse(bytes(//
|
||||
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
|
||||
+ " \"rssi\": -83,"//
|
||||
+ " \"aoa\": [],"//
|
||||
+ " \"gwts\": \"1659365438\","//
|
||||
+ " \"ts\": \"1659365438\","//
|
||||
// manufacturer is not 99 04 (Ruuvi) but 99 99
|
||||
+ " \"data\": \"0201061BFF99990512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
|
||||
+ " \"coords\": \"\"" + "}"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDataNotBluetoothAdvertisement() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
GatewayPayloadParser.parse(bytes(//
|
||||
"{\"gw_mac\": \"DE:AD:BE:EF:00:00\","//
|
||||
+ " \"rssi\": -83,"//
|
||||
+ " \"aoa\": [],"//
|
||||
+ " \"gwts\": \"1659365438\","//
|
||||
+ " \"ts\": \"1659365438\","//
|
||||
// not advertisement (FF) but AA
|
||||
+ " \"data\": \"0201061BAA99040512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F\","
|
||||
+ " \"coords\": \"\"" + "}"));
|
||||
});
|
||||
}
|
||||
}
|
@ -250,6 +250,7 @@
|
||||
<module>org.openhab.binding.mqtt.generic</module>
|
||||
<module>org.openhab.binding.mqtt.homeassistant</module>
|
||||
<module>org.openhab.binding.mqtt.homie</module>
|
||||
<module>org.openhab.binding.mqtt.ruuvigateway</module>
|
||||
<module>org.openhab.binding.mybmw</module>
|
||||
<module>org.openhab.binding.mycroft</module>
|
||||
<module>org.openhab.binding.mynice</module>
|
||||
|
@ -25,6 +25,7 @@
|
||||
<bundle start-level="81">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}</bundle>
|
||||
<bundle start-level="82">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homeassistant/${project.version}</bundle>
|
||||
<bundle start-level="82">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homie/${project.version}</bundle>
|
||||
<bundle start-level="82">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.ruuvigateway/${project.version}</bundle>
|
||||
</feature>
|
||||
<feature name="openhab-binding-modbus" description="Modbus Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
|
13
itests/org.openhab.binding.mqtt.ruuvigateway.tests/NOTICE
Normal file
13
itests/org.openhab.binding.mqtt.ruuvigateway.tests/NOTICE
Normal file
@ -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
|
108
itests/org.openhab.binding.mqtt.ruuvigateway.tests/itest.bndrun
Normal file
108
itests/org.openhab.binding.mqtt.ruuvigateway.tests/itest.bndrun
Normal file
@ -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)'
|
112
itests/org.openhab.binding.mqtt.ruuvigateway.tests/pom.xml
Normal file
112
itests/org.openhab.binding.mqtt.ruuvigateway.tests/pom.xml
Normal file
@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.itests</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.itests</artifactId>
|
||||
<version>4.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.binding.mqtt.ruuvigateway.tests</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Integration Tests :: MQTT Ruuvi Gateway Tests</name>
|
||||
|
||||
<properties>
|
||||
<mqttbroker.port>1884</mqttbroker.port>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.mqtt</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.mqtt.generic</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.mqtt.ruuvigateway</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2-mvstore</artifactId>
|
||||
<version>1.4.199</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.moquette</groupId>
|
||||
<artifactId>moquette-broker</artifactId>
|
||||
<version>0.15</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-buffer</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-codec</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-codec-mqtt</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-common</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-handler</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-handler-proxy</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-resolver</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-transport</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>reserve-mqtt-broker-port</id>
|
||||
<goals>
|
||||
<goal>reserve-network-port</goal>
|
||||
</goals>
|
||||
<phase>process-resources</phase>
|
||||
<configuration>
|
||||
<portNames>
|
||||
<portName>mqttbroker.port</portName>
|
||||
</portNames>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
@ -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<Boolean> 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());
|
||||
}
|
||||
}
|
@ -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<String, String> 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<Thing> 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<CompletableFuture<Boolean>> 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<ThingStatusInfo> 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<ThingStatusInfo> 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<ThingStatusInfo> statusUpdates, int index, ThingStatus status) {
|
||||
assertThingStatus(statusUpdates, index, status, ThingStatusDetail.NONE, null);
|
||||
}
|
||||
|
||||
private void assertItems(Function<String, State> 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<Temperature>(new BigDecimal(temperatureCelsius), SIUnits.CELSIUS),
|
||||
channelStateGetter.apply(CHANNEL_ID_TEMPERATURE));
|
||||
assertEquals(
|
||||
new QuantityType<Acceleration>(new BigDecimal(accelerationXStandardGravity), Units.STANDARD_GRAVITY),
|
||||
channelStateGetter.apply(CHANNEL_ID_ACCELERATIONX));
|
||||
assertEquals(
|
||||
new QuantityType<Acceleration>(new BigDecimal(accelerationYStandardGravity), Units.STANDARD_GRAVITY),
|
||||
channelStateGetter.apply(CHANNEL_ID_ACCELERATIONY));
|
||||
assertEquals(
|
||||
new QuantityType<Acceleration>(new BigDecimal(accelerationZStandardGravity), Units.STANDARD_GRAVITY),
|
||||
channelStateGetter.apply(CHANNEL_ID_ACCELERATIONZ));
|
||||
assertEquals(new QuantityType<ElectricPotential>(new BigDecimal(batteryVolt), Units.VOLT),
|
||||
channelStateGetter.apply(CHANNEL_ID_BATTERY));
|
||||
assertEquals(new DecimalType(dataFormat), channelStateGetter.apply(CHANNEL_ID_DATA_FORMAT));
|
||||
assertEquals(new QuantityType<Dimensionless>(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<Pressure>(new BigDecimal(pressurePascal), SIUnits.PASCAL),
|
||||
channelStateGetter.apply(CHANNEL_ID_PRESSURE));
|
||||
assertEquals(new QuantityType<Power>(new BigDecimal(txPowerDecibelMilliwatts), Units.DECIBEL_MILLIWATTS),
|
||||
channelStateGetter.apply(CHANNEL_ID_TX_POWER));
|
||||
|
||||
assertEquals(new QuantityType<Power>(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<ThingStatusInfo> 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<DiscoveryResult>();
|
||||
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<String, State> getItemState = channelId -> itemRegistry
|
||||
.get(itemName(ruuviThing.getChannel(channelId).getUID())).getState();
|
||||
|
||||
AtomicInteger statusUpdateIndex = new AtomicInteger();
|
||||
waitForAssert(() -> {
|
||||
List<ThingStatusInfo> 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<ThingStatusInfo> 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());
|
||||
}
|
||||
}
|
@ -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<ThingUID, List<ThingStatusInfo>> 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<ThingStatusInfo> updates = statusUpdates.computeIfAbsent(statusEvent.getThingUID(),
|
||||
item -> new CopyOnWriteArrayList<>());
|
||||
Objects.requireNonNull(updates); // To make compiler happy
|
||||
updates.add(statusEvent.getStatusInfo());
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@
|
||||
<module>org.openhab.binding.modbus.tests</module>
|
||||
<module>org.openhab.binding.mqtt.homeassistant.tests</module>
|
||||
<module>org.openhab.binding.mqtt.homie.tests</module>
|
||||
<module>org.openhab.binding.mqtt.ruuvigateway.tests</module>
|
||||
<module>org.openhab.binding.nest.tests</module>
|
||||
<module>org.openhab.binding.ntp.tests</module>
|
||||
<module>org.openhab.binding.systeminfo.tests</module>
|
||||
|
Loading…
Reference in New Issue
Block a user