]> git.basschouten.com Git - openhab-addons.git/blob
e87e4b7277d34b9c7d4bd60ce76756628ea72065
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.mqtt.ruuvigateway.internal.parser;
14
15 import java.nio.charset.StandardCharsets;
16 import java.time.DateTimeException;
17 import java.time.Instant;
18 import java.util.Arrays;
19 import java.util.Optional;
20 import java.util.function.Predicate;
21 import java.util.regex.Pattern;
22
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.core.util.HexUtils;
26 import org.slf4j.Logger;
27 import org.slf4j.LoggerFactory;
28
29 import com.google.gson.Gson;
30 import com.google.gson.GsonBuilder;
31 import com.google.gson.JsonSyntaxException;
32
33 import fi.tkgwf.ruuvi.common.bean.RuuviMeasurement;
34 import fi.tkgwf.ruuvi.common.parser.impl.AnyDataFormatParser;
35
36 /**
37  * The {@link GatewayPayloadParser} is responsible for parsing Ruuvi Gateway MQTT JSON payloads.
38  *
39  * @author Sami Salonen - Initial contribution
40  */
41 @NonNullByDefault
42 public class GatewayPayloadParser {
43
44     private static final Logger logger = LoggerFactory.getLogger(GatewayPayloadParser.class);
45     private static final Gson GSON = new GsonBuilder().create();
46     private static final AnyDataFormatParser parser = new AnyDataFormatParser();
47     private static final Predicate<String> HEX_PATTERN_CHECKER = Pattern.compile("^([0-9A-Fa-f]{2})+$")
48             .asMatchPredicate();
49
50     /**
51      * JSON MQTT payload sent by Ruuvi Gateway
52      *
53      * See https://docs.ruuvi.com/gw-data-formats/mqtt-time-stamped-data-from-bluetooth-sensors
54      *
55      * @author Sami Salonen - Initial contribution
56      *
57      */
58     public static class GatewayPayload {
59         /**
60          * MAC-address of Ruuvi Gateway
61          */
62         public Optional<String> gwMac = Optional.empty();
63         /**
64          * RSSI
65          */
66         public int rssi;
67         /**
68          * Timestamp when the message from Bluetooth-sensor was relayed by Gateway
69          *
70          */
71         public Optional<Instant> gwts = Optional.empty();
72
73         /**
74          * Timestamp (Unix-time) when the message from Bluetooth-sensor was received by Gateway
75          *
76          */
77         public Optional<Instant> ts = Optional.empty();
78         public RuuviMeasurement measurement;
79
80         private GatewayPayload(GatewayPayloadIntermediate intermediate) throws IllegalArgumentException {
81             String gwMac = intermediate.gw_mac;
82             if (gwMac == null) {
83                 logger.trace("Missing mandatory field 'gw_mac', ignoring");
84             }
85             this.gwMac = Optional.ofNullable(gwMac);
86             rssi = intermediate.rssi;
87             try {
88                 gwts = Optional.of(Instant.ofEpochSecond(intermediate.gwts));
89             } catch (DateTimeException e) {
90                 logger.debug("Field 'gwts' is a not valid time (epoch second), ignoring: {}", intermediate.gwts);
91             }
92             try {
93                 ts = Optional.of(Instant.ofEpochSecond(intermediate.ts));
94             } catch (DateTimeException e) {
95                 logger.debug("Field 'ts' is a not valid time (epoch second), ignoring: {}", intermediate.ts);
96             }
97
98             String localData = intermediate.data;
99             if (localData == null) {
100                 throw new IllegalArgumentException("Missing mandatory field 'data'");
101             }
102
103             if (!HEX_PATTERN_CHECKER.test(localData)) {
104                 logger.debug(
105                         "Data is not representing manufacturer specific bluetooth advertisement, it is not valid hex: {}",
106                         localData);
107                 throw new IllegalArgumentException(
108                         "Data is not representing manufacturer specific bluetooth advertisement, it is not valid hex: "
109                                 + localData);
110             }
111             byte[] bytes = HexUtils.hexToBytes(localData);
112             if (bytes.length < 6) {
113                 // We want at least 6 bytes, ensuring bytes[5] is valid as well as Arrays.copyOfRange(bytes, 5, ...)
114                 // below
115                 // The payload length (might depend on format version ) is validated by parser.parse call
116                 throw new IllegalArgumentException("Manufacturerer data is too short");
117             }
118             if ((bytes[4] & 0xff) != 0xff) {
119                 logger.debug("Data is not representing manufacturer specific bluetooth advertisement: {}",
120                         HexUtils.bytesToHex(bytes));
121                 throw new IllegalArgumentException(
122                         "Data is not representing manufacturer specific bluetooth advertisement");
123             }
124             // Manufacturer data starts after 0xFF byte, at index 5
125             byte[] manufacturerData = Arrays.copyOfRange(bytes, 5, bytes.length);
126             RuuviMeasurement localManufacturerData = parser.parse(manufacturerData);
127             if (localManufacturerData == null) {
128                 logger.trace("Manufacturer data is not valid: {}", HexUtils.bytesToHex(manufacturerData));
129                 throw new IllegalArgumentException("Manufacturer data is not valid");
130             }
131             measurement = localManufacturerData;
132         }
133     }
134
135     /**
136      *
137      * JSON MQTT payload sent by Ruuvi Gateway (intermediate representation).
138      *
139      * This intermediate representation tries to match the low level JSON, making little data validation and conversion.
140      *
141      * Fields are descibed in https://docs.ruuvi.com/gw-data-formats/mqtt-time-stamped-data-from-bluetooth-sensors
142      *
143      * Fields are marked as nullable as GSON might apply nulls at runtime.
144      *
145      * @author Sami Salonen - Initial Contribution
146      * @see GatewayPayload Equivalent of this class but with additional data validation and typing
147      *
148      */
149     private static class GatewayPayloadIntermediate {
150         public @Nullable String gw_mac;
151         public int rssi;
152         public long gwts;
153         public long ts;
154         public @Nullable String data;
155     }
156
157     /**
158      * Parse MQTT JSON payload advertised by Ruuvi Gateway
159      *
160      * @param jsonPayload json payload of the Ruuvi sensor MQTT topic, as bytes
161      * @return parsed payload
162      * @throws JsonSyntaxException raised with JSON syntax exceptions and clearly invalid JSON types
163      * @throws IllegalArgumentException raised with invalid or unparseable data
164      */
165     public static GatewayPayload parse(byte[] jsonPayload) throws JsonSyntaxException, IllegalArgumentException {
166         String jsonPayloadString = new String(jsonPayload, StandardCharsets.UTF_8);
167         GatewayPayloadIntermediate payloadIntermediate = GSON.fromJson(jsonPayloadString,
168                 GatewayPayloadIntermediate.class);
169         if (payloadIntermediate == null) {
170             throw new JsonSyntaxException("JSON parsing failed");
171         }
172         return new GatewayPayload(payloadIntermediate);
173     }
174 }