]> git.basschouten.com Git - openhab-addons.git/blob
7697bc056923ca2fbbafc81e32ace94ae1fbc8a9
[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             }
119             if ((bytes[4] & 0xff) != 0xff) {
120                 logger.debug("Data is not representing manufacturer specific bluetooth advertisement: {}",
121                         HexUtils.bytesToHex(bytes));
122                 throw new IllegalArgumentException(
123                         "Data is not representing manufacturer specific bluetooth advertisement");
124             }
125             // Manufacturer data starts after 0xFF byte, at index 5
126             byte[] manufacturerData = Arrays.copyOfRange(bytes, 5, bytes.length);
127             RuuviMeasurement localManufacturerData = parser.parse(manufacturerData);
128             if (localManufacturerData == null) {
129                 logger.trace("Manufacturer data is not valid: {}", HexUtils.bytesToHex(manufacturerData));
130                 throw new IllegalArgumentException("Manufacturer data is not valid");
131             }
132             measurement = localManufacturerData;
133         }
134     }
135
136     /**
137      *
138      * JSON MQTT payload sent by Ruuvi Gateway (intermediate representation).
139      *
140      * This intermediate representation tries to match the low level JSON, making little data validation and conversion.
141      *
142      * Fields are descibed in https://docs.ruuvi.com/gw-data-formats/mqtt-time-stamped-data-from-bluetooth-sensors
143      *
144      * Fields are marked as nullable as GSON might apply nulls at runtime.
145      *
146      * @author Sami Salonen - Initial Contribution
147      * @see GatewayPayload Equivalent of this class but with additional data validation and typing
148      *
149      */
150     private static class GatewayPayloadIntermediate {
151         public @Nullable String gw_mac;
152         public int rssi;
153         public long gwts;
154         public long ts;
155         public @Nullable String data;
156     }
157
158     /**
159      * Parse MQTT JSON payload advertised by Ruuvi Gateway
160      *
161      * @param jsonPayload json payload of the Ruuvi sensor MQTT topic, as bytes
162      * @return parsed payload
163      * @throws JsonSyntaxException raised with JSON syntax exceptions and clearly invalid JSON types
164      * @throws IllegalArgumentException raised with invalid or unparseable data
165      */
166     public static GatewayPayload parse(byte[] jsonPayload) throws JsonSyntaxException, IllegalArgumentException {
167         String jsonPayloadString = new String(jsonPayload, StandardCharsets.UTF_8);
168         GatewayPayloadIntermediate payloadIntermediate = GSON.fromJson(jsonPayloadString,
169                 GatewayPayloadIntermediate.class);
170         if (payloadIntermediate == null) {
171             throw new JsonSyntaxException("JSON parsing failed");
172         }
173         return new GatewayPayload(payloadIntermediate);
174     }
175 }