From 2ae6f9b83d4d13081d3bca668e26c9d5c42d0a11 Mon Sep 17 00:00:00 2001 From: Bertrik Sikken Date: Sun, 28 Feb 2021 00:25:10 +0100 Subject: [PATCH] Refactor / add support for TTN v3 MQTT messages. --- .../java/nl/sikken/bertrik/TtnHabBridge.java | 20 +-- .../nl/sikken/bertrik/hab/PayloadDecoder.java | 14 +-- .../hab/habitat/docs/PayloadTelemetryDoc.java | 1 - .../bertrik/hab/ttn/IMessageReceived.java | 7 +- .../sikken/bertrik/hab/ttn/TtnListener.java | 52 ++++++-- .../bertrik/hab/ttn/TtnMessageGateway.java | 6 +- .../bertrik/hab/ttn/TtnMessageMetaData.java | 5 +- .../bertrik/hab/ttn/TtnUplinkMessage.java | 100 +++++++++++++++ ...tnMessage.java => Ttnv2UplinkMessage.java} | 36 +++--- .../bertrik/hab/ttn/Ttnv3UplinkMessage.java | 114 ++++++++++++++++++ .../bertrik/hab/PayloadDecoderTest.java | 93 +++++--------- .../bertrik/hab/ttn/TtnMessageTest.java | 4 +- .../hab/ttn/Ttnv2UplinkMessageTest.java | 49 ++++++++ .../hab/ttn/Ttnv3UplinkMessageTest.java | 38 ++++++ .../src/test/resources/ttnv3_uplink.json | 63 ++++++++++ 15 files changed, 482 insertions(+), 120 deletions(-) create mode 100644 ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnUplinkMessage.java rename ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/{TtnMessage.java => Ttnv2UplinkMessage.java} (67%) create mode 100644 ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessage.java create mode 100644 ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv2UplinkMessageTest.java create mode 100644 ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessageTest.java create mode 100644 ttnhabbridge/src/test/resources/ttnv3_uplink.json diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/TtnHabBridge.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/TtnHabBridge.java index 4fab007..4a8a64a 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/TtnHabBridge.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/TtnHabBridge.java @@ -13,8 +13,6 @@ import org.eclipse.paho.client.mqttv3.MqttException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.ObjectMapper; - import nl.sikken.bertrik.hab.DecodeException; import nl.sikken.bertrik.hab.EPayloadEncoding; import nl.sikken.bertrik.hab.ExpiringCache; @@ -25,8 +23,8 @@ import nl.sikken.bertrik.hab.habitat.HabitatUploader; import nl.sikken.bertrik.hab.habitat.IHabitatRestApi; import nl.sikken.bertrik.hab.habitat.Location; import nl.sikken.bertrik.hab.ttn.TtnListener; -import nl.sikken.bertrik.hab.ttn.TtnMessage; -import nl.sikken.bertrik.hab.ttn.TtnMessageGateway; +import nl.sikken.bertrik.hab.ttn.TtnUplinkMessage; +import nl.sikken.bertrik.hab.ttn.TtnUplinkMessage.GatewayInfo; /** * Bridge between the-things-network and the habhub network. @@ -40,7 +38,6 @@ public final class TtnHabBridge { private final TtnListener ttnListener; private final HabitatUploader habUploader; private final PayloadDecoder decoder; - private final ObjectMapper mapper; private final ExpiringCache gwCache; /** @@ -73,7 +70,6 @@ public final class TtnHabBridge { IHabitatRestApi restApi = HabitatUploader.newRestClient(config.getHabitatUrl(), config.getHabitatTimeout()); this.habUploader = new HabitatUploader(restApi); - this.mapper = new ObjectMapper(); this.decoder = new PayloadDecoder(EPayloadEncoding.parse(config.getTtnPayloadEncoding())); this.gwCache = new ExpiringCache(config.getTtnGwCacheExpiry()); } @@ -95,15 +91,13 @@ public final class TtnHabBridge { /** * Handles an incoming TTN message - * - * @param now message arrival time - * @param topic the topic on which the message was received * @param textMessage the message contents + * @param now message arrival time */ - private void handleTTNMessage(Instant now, String topic, String textMessage) { + private void handleTTNMessage(TtnUplinkMessage message) { + Instant now = Instant.now(); try { // decode from JSON - TtnMessage message = mapper.readValue(textMessage, TtnMessage.class); if (message.isRetry()) { // skip "retry" messages, they contain duplicate data with a misleading time stamp LOG.warn("Ignoring 'retry' message"); @@ -114,7 +108,7 @@ public final class TtnHabBridge { // collect list of listeners List receivers = new ArrayList<>(); - for (TtnMessageGateway gw : message.getMetaData().getMqttGateways()) { + for (GatewayInfo gw : message.getGateways()) { String gwName = gw.getId(); Location gwLocation = gw.getLocation(); HabReceiver receiver = new HabReceiver(gwName, gwLocation); @@ -128,8 +122,6 @@ public final class TtnHabBridge { // send payload telemetry data habUploader.schedulePayloadTelemetryUpload(line, receivers, now); - } catch (IOException e) { - LOG.warn("JSON unmarshalling exception '{}' for {}", e.getMessage(), textMessage); } catch (DecodeException e) { LOG.warn("Payload decoding exception: {}", e.getMessage()); } catch (Exception e) { diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/PayloadDecoder.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/PayloadDecoder.java index c615c75..f39e114 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/PayloadDecoder.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/PayloadDecoder.java @@ -13,7 +13,7 @@ import nl.sikken.bertrik.cayenne.CayenneException; import nl.sikken.bertrik.cayenne.CayenneItem; import nl.sikken.bertrik.cayenne.CayenneMessage; import nl.sikken.bertrik.cayenne.ECayennePayloadFormat; -import nl.sikken.bertrik.hab.ttn.TtnMessage; +import nl.sikken.bertrik.hab.ttn.TtnUplinkMessage; /** * Decodes a payload and encodes it into a UKHAS sentence. @@ -41,7 +41,7 @@ public final class PayloadDecoder { * @return the UKHAS sentence * @throws DecodeException in case of a problem decoding the message */ - public Sentence decode(TtnMessage message) throws DecodeException { + public Sentence decode(TtnUplinkMessage message) throws DecodeException { // common fields String callSign = message.getDevId(); int counter = message.getCounter(); @@ -74,7 +74,7 @@ public final class PayloadDecoder { * @return the UKHAS sentence * @throws DecodeException in case of a problem decoding the message */ - private Sentence decodeSodaqOne(TtnMessage message, String callSign, int counter) throws DecodeException { + private Sentence decodeSodaqOne(TtnUplinkMessage message, String callSign, int counter) throws DecodeException { LOG.info("Decoding 'sodaqone' message..."); try { @@ -107,11 +107,11 @@ public final class PayloadDecoder { * @return the UKHAS sentence * @throws DecodeException in case of a problem decoding the message */ - private Sentence decodeJson(TtnMessage message, String callSign, int counter) throws DecodeException { + private Sentence decodeJson(TtnUplinkMessage message, String callSign, int counter) throws DecodeException { LOG.info("Decoding 'json' message..."); try { - Instant time = message.getMetaData().getTime(); + Instant time = message.getTime(); Map fields = message.getPayloadFields(); double latitude = parseDouble(fields.get("lat")); double longitude = parseDouble(fields.get("lon")); @@ -154,11 +154,11 @@ public final class PayloadDecoder { * @return the UKHAS sentence * @throws DecodeException */ - private Sentence decodeCayenne(TtnMessage message, String callSign, int counter) throws DecodeException { + private Sentence decodeCayenne(TtnUplinkMessage message, String callSign, int counter) throws DecodeException { LOG.info("Decoding 'cayenne' message..."); try { - Instant time = message.getMetaData().getTime(); + Instant time = message.getTime(); Sentence sentence = new Sentence(callSign, counter, time); ECayennePayloadFormat cayenneFormat = ECayennePayloadFormat.fromPort(message.getPort()); CayenneMessage cayenne = new CayenneMessage(cayenneFormat); diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/docs/PayloadTelemetryDoc.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/docs/PayloadTelemetryDoc.java index 96cecba..c6e7a7e 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/docs/PayloadTelemetryDoc.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/docs/PayloadTelemetryDoc.java @@ -6,7 +6,6 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/IMessageReceived.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/IMessageReceived.java index 47fd8cc..2bb0631 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/IMessageReceived.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/IMessageReceived.java @@ -1,7 +1,5 @@ package nl.sikken.bertrik.hab.ttn; -import java.time.Instant; - /** * Interface of the callback from the TTN listener. */ @@ -9,11 +7,8 @@ public interface IMessageReceived { /** * Indicates that a message was received. - * - * @param now the arrival time - * @param topic the topic * @param message the message */ - void messageReceived(Instant now, String topic, String message); + void messageReceived(TtnUplinkMessage message); } diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnListener.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnListener.java index 4391f83..b7fc38b 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnListener.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnListener.java @@ -1,7 +1,6 @@ package nl.sikken.bertrik.hab.ttn; import java.nio.charset.StandardCharsets; -import java.time.Instant; import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; import org.eclipse.paho.client.mqttv3.MqttCallbackExtended; @@ -13,6 +12,9 @@ import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + /** * Listener process for receiving data from the TTN. */ @@ -21,16 +23,18 @@ public final class TtnListener { private static final Logger LOG = LoggerFactory.getLogger(TtnListener.class); private static final long DISCONNECT_TIMEOUT_MS = 3000; + private final IMessageReceived callback; private final MqttClient mqttClient; private final MqttConnectOptions options; + private final ObjectMapper objectMapper = new ObjectMapper(); /** * Constructor. * * @param callback the listener for a received message. - * @param url the URL of the MQTT server - * @param appId the user name - * @param appKey the password + * @param url the URL of the MQTT server + * @param appId the user name + * @param appKey the password */ public TtnListener(IMessageReceived callback, String url, String appId, String appKey) { LOG.info("Creating client for MQTT server '{}' for app '{}'", url, appId); @@ -39,7 +43,8 @@ public final class TtnListener { } catch (MqttException e) { throw new IllegalArgumentException(e); } - mqttClient.setCallback(new MqttCallbackHandler(mqttClient, "+/devices/+/up", callback)); + this.callback = callback; + mqttClient.setCallback(new MqttCallbackHandler(mqttClient, "+/devices/+/up", this::handleMessage)); // create connect options options = new MqttConnectOptions(); @@ -48,6 +53,30 @@ public final class TtnListener { options.setAutomaticReconnect(true); } + // notify our caller in a thread safe manner + private void handleMessage(String topic, String payload) { + try { + TtnUplinkMessage uplinkMessage = convertMessage(topic, payload); + callback.messageReceived(uplinkMessage); + } catch (JsonProcessingException e) { + LOG.warn("Caught {}", e.getMessage()); + } catch (Throwable e) { + // safety net + LOG.error("Caught unhandled throwable", e); + } + } + + // package private for testing + TtnUplinkMessage convertMessage(String topic, String payload) throws JsonProcessingException { + if (topic.startsWith("v3/")) { + Ttnv3UplinkMessage v3message = objectMapper.readValue(payload, Ttnv3UplinkMessage.class); + return v3message.toUplinkMessage(); + } else { + Ttnv2UplinkMessage v2message = objectMapper.readValue(payload, Ttnv2UplinkMessage.class); + return v2message.toUplinkMessage(); + } + } + /** * Starts this module. * @@ -78,9 +107,9 @@ public final class TtnListener { private final MqttClient client; private final String topic; - private final IMessageReceived listener; + private final IMqttMessageArrived listener; - private MqttCallbackHandler(MqttClient client, String topic, IMessageReceived listener) { + private MqttCallbackHandler(MqttClient client, String topic, IMqttMessageArrived listener) { this.client = client; this.topic = topic; this.listener = listener; @@ -97,9 +126,8 @@ public final class TtnListener { // notify our listener, in an exception safe manner try { - Instant now = Instant.now(); - String message = new String(mqttMessage.getPayload(), StandardCharsets.US_ASCII); - listener.messageReceived(now, topic, message); + String json = new String(mqttMessage.getPayload(), StandardCharsets.US_ASCII); + listener.messageArrived(topic, json); } catch (Exception e) { LOG.trace("Caught exception", e); LOG.error("Caught exception in MQTT listener: {}", e.getMessage()); @@ -122,4 +150,8 @@ public final class TtnListener { } } + interface IMqttMessageArrived { + void messageArrived(String topic, String json); + } + } diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageGateway.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageGateway.java index 0bb16d0..78d5924 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageGateway.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageGateway.java @@ -22,13 +22,13 @@ public final class TtnMessageGateway { private String time; @JsonProperty("latitude") - private Double latitude; + private Double latitude = Double.NaN; @JsonProperty("longitude") - private Double longitude; + private Double longitude = Double.NaN; @JsonProperty("altitude") - private Double altitude; + private Double altitude = Double.NaN; private TtnMessageGateway() { // jackson constructor diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageMetaData.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageMetaData.java index 9d7d609..73d31b5 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageMetaData.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessageMetaData.java @@ -1,6 +1,7 @@ package nl.sikken.bertrik.hab.ttn; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -13,10 +14,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; public final class TtnMessageMetaData { @JsonProperty("time") - private String time; + private String time = ""; @JsonProperty("gateways") - private List gateways; + private List gateways = new ArrayList<>(); private TtnMessageMetaData() { // empty jackson constructor diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnUplinkMessage.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnUplinkMessage.java new file mode 100644 index 0000000..e1be9ef --- /dev/null +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnUplinkMessage.java @@ -0,0 +1,100 @@ +package nl.sikken.bertrik.hab.ttn; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import nl.sikken.bertrik.hab.habitat.Location; + +/** + * Uplink message, TTN stack version independent, containing all information needed to create a habhub sentence + */ +public final class TtnUplinkMessage { + + private final Instant time; + private final String appId; + private final String deviceId; + private final int counter; + private final int port; + private final Map payloadFields = new HashMap<>(); + private final byte[] payloadRaw; + private final boolean isRetry; + private final List gateways = new ArrayList<>(); + + public TtnUplinkMessage(Instant time, String appId, String deviceId, int counter, byte[] payloadRaw, boolean isRetry) { + this.time = time; + this.appId = appId; + this.deviceId = deviceId; + this.counter = counter; + this.port = 1; // TODO port + this.payloadRaw = payloadRaw.clone(); + this.isRetry = isRetry; + } + + public void addField(String name, Object value) { + payloadFields.put(name, value); + } + + public Instant getTime() { + return time; + } + + public String getAppId() { + return appId; + } + + public String getDevId() { + return deviceId; + } + + public int getCounter() { + return counter; + } + + public byte[] getPayloadRaw() { + return payloadRaw.clone(); + } + + public Map getPayloadFields() { + return new HashMap<>(payloadFields); + } + + public boolean isRetry() { + return isRetry; + } + + public int getPort() { + return port; + } + + public void addGateway(String id, double lat, double lon, double alt) { + gateways.add(new GatewayInfo(id, new Location(lat, lon, alt))); + } + + public List getGateways() { + return gateways; + } + + public static final class GatewayInfo { + + private final String id; + private final Location location; + + public GatewayInfo(String id, Location location) { + this.id = id; + this.location = location; + } + + public String getId() { + return id; + } + + public Location getLocation() { + return location; + } + + } + +} diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessage.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/Ttnv2UplinkMessage.java similarity index 67% rename from ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessage.java rename to ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/Ttnv2UplinkMessage.java index 47529ca..9830bcd 100644 --- a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/TtnMessage.java +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/Ttnv2UplinkMessage.java @@ -10,7 +10,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; * Representation of a message received from the TTN MQTT stream. */ @JsonIgnoreProperties(ignoreUnknown = true) -public final class TtnMessage { +public final class Ttnv2UplinkMessage { @JsonProperty("app_id") private String appId; @@ -29,29 +29,29 @@ public final class TtnMessage { @JsonProperty("is_retry") private boolean isRetry; - + @JsonProperty("payload_raw") private byte[] payloadRaw = new byte[0]; @JsonProperty("payload_fields") private Map payloadFields = new HashMap<>(); - + @JsonProperty("metadata") private TtnMessageMetaData metaData; - private TtnMessage() { - // Jackson constructor + private Ttnv2UplinkMessage() { + // Jackson constructor } - + // constructor for testing - public TtnMessage(String devId, int counter, TtnMessageMetaData metaData, byte[] payloadRaw) { - this(); - this.devId = devId; - this.counter = counter; - this.metaData = metaData; - this.payloadRaw = payloadRaw.clone(); + public Ttnv2UplinkMessage(String devId, int counter, TtnMessageMetaData metaData, byte[] payloadRaw) { + this(); + this.devId = devId; + this.counter = counter; + this.metaData = metaData; + this.payloadRaw = payloadRaw.clone(); } - + public String getAppId() { return appId; } @@ -67,7 +67,7 @@ public final class TtnMessage { public int getPort() { return port; } - + public boolean isRetry() { return isRetry; } @@ -88,4 +88,12 @@ public final class TtnMessage { return metaData; } + public TtnUplinkMessage toUplinkMessage() { + TtnUplinkMessage message = new TtnUplinkMessage(metaData.getTime(), appId, devId, counter, payloadRaw, isRetry); + for (TtnMessageGateway gw : metaData.getMqttGateways()) { + message.addGateway(gw.getId(), gw.getLatitude(), gw.getLongitude(), gw.getAltitude()); + } + return message; + } + } diff --git a/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessage.java b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessage.java new file mode 100644 index 0000000..ab79ad9 --- /dev/null +++ b/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessage.java @@ -0,0 +1,114 @@ +package nl.sikken.bertrik.hab.ttn; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Representation of the TTNv3 uplink message.
+ *
+ * This is purely a data structure, so all fields are public for easy access. + * All sub-structures are contained in this file too. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class Ttnv3UplinkMessage { + + @JsonProperty("end_device_ids") + EndDeviceIds endDeviceIds = new EndDeviceIds(); + + @JsonProperty("received_at") + String receivedAt = ""; + + @JsonProperty("uplink_message") + UplinkMessage uplinkMessage = new UplinkMessage(); + + @JsonIgnoreProperties(ignoreUnknown = true) + final static class EndDeviceIds { + @JsonProperty("device_id") + String deviceId = ""; + + @JsonProperty("application_ids") + ApplicationIds applicationIds = new ApplicationIds(); + + @JsonProperty("dev_eui") + String deviceEui = ""; + + @JsonProperty("join_eui") + String joinEui = ""; + + @JsonProperty("dev_addr") + String deviceAddress = ""; + } + + final static class ApplicationIds { + @JsonProperty("application_id") + String applicationId = ""; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + final static class UplinkMessage { + @JsonProperty("f_port") + int fport = 0; + + @JsonProperty("f_cnt") + int fcnt = 0; + + @JsonProperty("frm_payload") + byte[] payload = new byte[0]; + + @JsonProperty("rx_metadata") + List rxMetadata = new ArrayList<>(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + final static class RxMetadata { + + @JsonProperty("gateway_ids") + final GatewayIds gatewayIds = new GatewayIds(); + + @JsonProperty("location") + final Location location = new Location(); + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + final static class GatewayIds { + @JsonProperty("gateway_id") + String gatewayId = ""; + + @JsonProperty("eui") + String eui = ""; + } + + final static class Location { + @JsonProperty("latitude") + private double latitude = Double.NaN; + + @JsonProperty("longitude") + private double longitude = Double.NaN; + + @JsonProperty("altitude") + private double altitude = Double.NaN; + + @JsonProperty("source") + private String source = ""; + } + + public TtnUplinkMessage toUplinkMessage() { + TtnUplinkMessage uplink = new TtnUplinkMessage(Instant.parse(receivedAt), + endDeviceIds.applicationIds.applicationId, endDeviceIds.deviceId, uplinkMessage.fcnt, + uplinkMessage.payload, false); + for (RxMetadata metadata : uplinkMessage.rxMetadata) { + String id = metadata.gatewayIds.gatewayId; + if (id.isBlank()) { + id = metadata.gatewayIds.eui; + } + uplink.addGateway(id, metadata.location.latitude, metadata.location.longitude, metadata.location.altitude); + } + return uplink; + } + +} diff --git a/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/PayloadDecoderTest.java b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/PayloadDecoderTest.java index 87002ad..5ad61ed 100644 --- a/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/PayloadDecoderTest.java +++ b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/PayloadDecoderTest.java @@ -1,8 +1,8 @@ package nl.sikken.bertrik.hab; import java.io.IOException; +import java.time.Instant; import java.util.Base64; -import java.util.Collections; import org.junit.Assert; import org.junit.Test; @@ -11,52 +11,20 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; -import nl.sikken.bertrik.hab.ttn.TtnMessage; -import nl.sikken.bertrik.hab.ttn.TtnMessageMetaData; +import nl.sikken.bertrik.hab.ttn.TtnUplinkMessage; +import nl.sikken.bertrik.hab.ttn.Ttnv2UplinkMessage; /** * Unit tests for PayloadDecoder. */ public final class PayloadDecoderTest { - + private static final ObjectMapper mapper = new ObjectMapper(); - /** - * Verifies decoding of some actual MQTT data, in JSON format. - * - * @throws IOException in case of a parse exception - * @throws DecodeException in case of a decode exception - */ - @Test - public void testDecodeJson() throws IOException, DecodeException { - String data = "{\"app_id\":\"spaceballoon\",\"dev_id\":\"devtrack\"," - + "\"hardware_serial\":\"00490B7EB25521E6\",\"port\":1,\"counter\":1707," - + "\"payload_raw\":\"AAsm1AxMAUEAY/wCNh68EwKaihEClAEAAwAF\",\"payload_fields\":{\"baralt\":321," - + "\"gpsalt\":660,\"hacc\":3,\"hpa\":994,\"lat\":51.5642112,\"lon\":4.3682304,\"mode\":0,\"rssi\":-99," - + "\"sats\":17,\"seq\":565,\"slot\":1,\"snr\":-4,\"temp\":1.1,\"type\":\"stat\",\"vacc\":5," - + "\"vcc\":3.148},\"metadata\":{\"time\":\"2017-03-24T19:02:46.316523288Z\",\"frequency\":867.1," - + "\"modulation\":\"LORA\",\"data_rate\":\"SF7BW125\",\"coding_rate\":\"4/5\"," - + "\"gateways\":[{\"gtw_id\":\"eui-008000000000b8b6\",\"timestamp\":1250904307," - + "\"time\":\"2017-03-24T19:02:46.338171Z\",\"channel\":3,\"rssi\":-120,\"snr\":-8.5," - + "\"latitude\":52.0182,\"longitude\":4.7084384},{\"gtw_id\":\"eui-008000000000b706\"," - + "\"timestamp\":3407032963,\"time\":\"\",\"channel\":3,\"rssi\":-120,\"snr\":-3," - + "\"latitude\":51.57847,\"longitude\":4.4564,\"altitude\":4},{\"gtw_id\":\"eui-aa555a00080e0096\"," - + "\"timestamp\":422749595,\"time\":\"2017-03-24T19:02:45.89182Z\",\"channel\":3,\"rssi\":-118," - + "\"snr\":-0.8}]}}"; - TtnMessage message = mapper.readValue(data, TtnMessage.class); - Assert.assertEquals(3, message.getMetaData().getMqttGateways().size()); - - PayloadDecoder decoder = new PayloadDecoder(EPayloadEncoding.JSON); - Sentence sentence = decoder.decode(message); - - Assert.assertEquals("$$devtrack,1707,19:02:46,51.564211,4.368230,660.0,1.1,3.148*B35B", - sentence.format().trim()); - } - /** * Verifies decoding of some actual MQTT data, in RAW format. * - * @throws IOException in case of a parse exception + * @throws IOException in case of a parse exception * @throws DecodeException in case of a decode exception */ @Test @@ -69,23 +37,25 @@ public final class PayloadDecoderTest { + "\"gateways\":[{\"gtw_id\":\"eui-008000000000b8b6\",\"timestamp\":1409115451," + "\"time\":\"2017-08-21T07:11:18.338662Z\",\"channel\":1,\"rssi\":-114,\"snr\":-0.2," + "\"rf_chain\":1,\"latitude\":52.0182,\"longitude\":4.70844,\"altitude\":27}]}}"; - - TtnMessage message = mapper.readValue(data, TtnMessage.class); + + Ttnv2UplinkMessage message = mapper.readValue(data, Ttnv2UplinkMessage.class); + TtnUplinkMessage uplink = message.toUplinkMessage(); // check gateway field Assert.assertEquals(27, message.getMetaData().getMqttGateways().get(0).getAltitude(), 0.1); - + // decode payload PayloadDecoder decoder = new PayloadDecoder(EPayloadEncoding.SODAQ_ONE); - Sentence sentence = decoder.decode(message); - + Sentence sentence = decoder.decode(uplink); + Assert.assertEquals("$$mapper2,4,07:11:18,52.022064,4.693023,30.0,19,4.10*81FD", sentence.format().trim()); } - + /** - * Verifies decoding of some actual MQTT data, in cayenne format (fix applied to analog input). + * Verifies decoding of some actual MQTT data, in cayenne format (fix applied to + * analog input). * - * @throws IOException in case of a parse exception + * @throws IOException in case of a parse exception * @throws DecodeException in case of a decode exception */ @Test @@ -97,15 +67,16 @@ public final class PayloadDecoderTest { + "\"gateways\":[{\"gtw_id\":\"eui-008000000000b8b6\",\"timestamp\":2382048707," + "\"time\":\"2017-09-08T16:53:10.342388Z\",\"channel\":0,\"rssi\":-119,\"snr\":-2,\"rf_chain\":1," + "\"latitude\":52.0182,\"longitude\":4.70844,\"altitude\":27}]}}"; - TtnMessage message = mapper.readValue(data, TtnMessage.class); + Ttnv2UplinkMessage message = mapper.readValue(data, Ttnv2UplinkMessage.class); + TtnUplinkMessage uplink = message.toUplinkMessage(); // decode payload PayloadDecoder decoder = new PayloadDecoder(EPayloadEncoding.CAYENNE); - Sentence sentence = decoder.decode(message); - + Sentence sentence = decoder.decode(uplink); + Assert.assertEquals("$$ttntest1,9,16:53:10,52.0220,4.6927,44.00,4.21,29.0*383E\n", sentence.format()); } - + /** * Verifies that an unknown payload encoding is detected. */ @@ -114,7 +85,7 @@ public final class PayloadDecoderTest { PayloadDecoder decoder = new PayloadDecoder(null); Assert.assertNotNull(decoder); } - + /** * Verifies decoding of another set of actual payload data. * @@ -122,27 +93,27 @@ public final class PayloadDecoderTest { */ @Test public void testCayenne2() throws DecodeException { - TtnMessageMetaData metaData = new TtnMessageMetaData("2020-02-05T22:00:58.930936Z", Collections.emptyList()); - TtnMessage message = new TtnMessage("test", 123, metaData, - Base64.getDecoder().decode("AYgH1ecAzV4AC7gCZwArAwIBhg==")); + TtnUplinkMessage message = new TtnUplinkMessage(Instant.parse("2020-02-05T22:00:58.930936Z"), "test", "test", + 123, Base64.getDecoder().decode("AYgH1ecAzV4AC7gCZwArAwIBhg=="), false); // decode payload PayloadDecoder decoder = new PayloadDecoder(EPayloadEncoding.CAYENNE); Sentence sentence = decoder.decode(message); Assert.assertEquals("$$test,123,22:00:58,51.3511,5.2574,30.00,4.3,3.90*A07E\n", sentence.format()); } - + @Test public void testCayenneWithFields() throws DecodeException, JsonMappingException, JsonProcessingException { - String json = "{\"app_id\":\"ttn-arduino-tracker-swallow\",\"dev_id\":\"ttnwiv2n\",\"hardware_serial\":\"003B0C6BF8C3B76E\",\"port\":1,\"counter\":170,\"payload_raw\":\"AYgHr8T/Yr4AIaI=\",\r\n" + - "\"payload_fields\":{\"gps_1\":{\"altitude\":86.1,\"latitude\":50.3748,\"longitude\":-4.0258}},\"metadata\":{\"time\":\"2020-08-17T17:51:05.573485776Z\",\"frequency\":868.5,\"modulation\":\r\n" + - "\"LORA\",\"data_rate\":\"SF7BW125\",\"airtime\":61696000,\"coding_rate\":\"4/5\",\"gateways\":[{\"gtw_id\":\"eui-58a0cbfffe801f61\",\"timestamp\":141113011,\"time\":\"2020-08-17T17:51:05.438510894Z\",\r\n" + - "\"channel\":0,\"rssi\":-34,\"snr\":7.75,\"rf_chain\":0}]}}"; + String json = "{\"app_id\":\"ttn-arduino-tracker-swallow\",\"dev_id\":\"ttnwiv2n\",\"hardware_serial\":\"003B0C6BF8C3B76E\",\"port\":1,\"counter\":170,\"payload_raw\":\"AYgHr8T/Yr4AIaI=\",\r\n" + + "\"payload_fields\":{\"gps_1\":{\"altitude\":86.1,\"latitude\":50.3748,\"longitude\":-4.0258}},\"metadata\":{\"time\":\"2020-08-17T17:51:05.573485776Z\",\"frequency\":868.5,\"modulation\":\r\n" + + "\"LORA\",\"data_rate\":\"SF7BW125\",\"airtime\":61696000,\"coding_rate\":\"4/5\",\"gateways\":[{\"gtw_id\":\"eui-58a0cbfffe801f61\",\"timestamp\":141113011,\"time\":\"2020-08-17T17:51:05.438510894Z\",\r\n" + + "\"channel\":0,\"rssi\":-34,\"snr\":7.75,\"rf_chain\":0}]}}"; ObjectMapper mapper = new ObjectMapper(); - TtnMessage message = mapper.readValue(json, TtnMessage.class); + Ttnv2UplinkMessage message = mapper.readValue(json, Ttnv2UplinkMessage.class); + TtnUplinkMessage uplink = message.toUplinkMessage(); PayloadDecoder decoder = new PayloadDecoder(EPayloadEncoding.CAYENNE); - Sentence sentence = decoder.decode(message); + Sentence sentence = decoder.decode(uplink); Assert.assertNotNull(sentence); Assert.assertEquals("$$ttnwiv2n,170,17:51:05,50.3748,-4.0258,86.10*6647\n", sentence.format()); } - + } diff --git a/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/TtnMessageTest.java b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/TtnMessageTest.java index 4044435..98755ee 100644 --- a/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/TtnMessageTest.java +++ b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/TtnMessageTest.java @@ -26,7 +26,7 @@ public final class TtnMessageTest { @Test public void testUplink() throws JsonParseException, JsonMappingException, IOException { InputStream is = getClass().getClassLoader().getResourceAsStream("uplink_nominal.json"); - TtnMessage message = mapper.readValue(is, TtnMessage.class); + Ttnv2UplinkMessage message = mapper.readValue(is, Ttnv2UplinkMessage.class); Assert.assertEquals(false, message.isRetry()); } @@ -36,7 +36,7 @@ public final class TtnMessageTest { @Test public void testUplinkWithRetry() throws JsonParseException, JsonMappingException, IOException { InputStream is = getClass().getClassLoader().getResourceAsStream("uplink_with_retry.json"); - TtnMessage message = mapper.readValue(is, TtnMessage.class); + Ttnv2UplinkMessage message = mapper.readValue(is, Ttnv2UplinkMessage.class); Assert.assertEquals(true, message.isRetry()); } diff --git a/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv2UplinkMessageTest.java b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv2UplinkMessageTest.java new file mode 100644 index 0000000..dd707d7 --- /dev/null +++ b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv2UplinkMessageTest.java @@ -0,0 +1,49 @@ +package nl.sikken.bertrik.hab.ttn; + +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import nl.sikken.bertrik.hab.ttn.TtnUplinkMessage.GatewayInfo; + +public final class Ttnv2UplinkMessageTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Tests decoding of a JSON uplink message. + */ + @Test + public void testDecode() throws JsonMappingException, JsonProcessingException { + String data = "{\"app_id\":\"habhub\",\"dev_id\":\"ttntest1\",\"hardware_serial\":\"0004A30B001ADBC5\"," + + "\"port\":1,\"counter\":9,\"payload_raw\":\"AYgH8BwAt08AETACAgGlA2cBIg==\"," + + "\"metadata\":{\"time\":\"2017-09-08T16:53:10.446526987Z\",\"frequency\":868.1," + + "\"modulation\":\"LORA\",\"data_rate\":\"SF7BW125\",\"coding_rate\":\"4/5\"," + + "\"gateways\":[{\"gtw_id\":\"eui-008000000000b8b6\",\"timestamp\":2382048707," + + "\"time\":\"2017-09-08T16:53:10.342388Z\",\"channel\":0,\"rssi\":-119,\"snr\":-2,\"rf_chain\":1," + + "\"latitude\":52.0182,\"longitude\":4.70844,\"altitude\":27}]}}"; + Ttnv2UplinkMessage message = MAPPER.readValue(data, Ttnv2UplinkMessage.class); + + Assert.assertNotNull(message); + TtnUplinkMessage uplinkMessage = message.toUplinkMessage(); + + Assert.assertEquals("habhub", uplinkMessage.getAppId()); + Assert.assertEquals("ttntest1", uplinkMessage.getDevId()); + Assert.assertEquals(1, uplinkMessage.getPort()); + Assert.assertEquals(9, uplinkMessage.getCounter()); + + List gateways = uplinkMessage.getGateways(); + GatewayInfo gw = gateways.get(0); + Assert.assertEquals("eui-008000000000b8b6", gw.getId()); + Assert.assertEquals(52.0182, gw.getLocation().getLat(), 1E-4); + Assert.assertEquals(4.70844, gw.getLocation().getLon(), 1E-4); + Assert.assertEquals(27, gw.getLocation().getAlt(), 1E-1); + } + +} + diff --git a/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessageTest.java b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessageTest.java new file mode 100644 index 0000000..39768f4 --- /dev/null +++ b/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/ttn/Ttnv3UplinkMessageTest.java @@ -0,0 +1,38 @@ +package nl.sikken.bertrik.hab.ttn; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import nl.sikken.bertrik.hab.ttn.TtnUplinkMessage.GatewayInfo; + +public final class Ttnv3UplinkMessageTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + public void testDecode() throws IOException { + InputStream is = getClass().getClassLoader().getResourceAsStream("ttnv3_uplink.json"); + Ttnv3UplinkMessage message = MAPPER.readValue(is, Ttnv3UplinkMessage.class); + + TtnUplinkMessage uplinkMessage = message.toUplinkMessage(); + + Assert.assertEquals("test2id", uplinkMessage.getAppId()); + Assert.assertEquals("v3demo1", uplinkMessage.getDevId()); + Assert.assertEquals(1, uplinkMessage.getPort()); + Assert.assertEquals(84, uplinkMessage.getCounter()); + + List gateways = uplinkMessage.getGateways(); + GatewayInfo gw = gateways.get(0); + Assert.assertEquals("eui-024b08fefe040083", gw.getId()); + Assert.assertEquals(52.0, gw.getLocation().getLat(), 0.1); + Assert.assertEquals(4.7, gw.getLocation().getLon(), 0.1); + Assert.assertFalse(Double.isFinite(gw.getLocation().getAlt())); + } + +} diff --git a/ttnhabbridge/src/test/resources/ttnv3_uplink.json b/ttnhabbridge/src/test/resources/ttnv3_uplink.json new file mode 100644 index 0000000..f33a487 --- /dev/null +++ b/ttnhabbridge/src/test/resources/ttnv3_uplink.json @@ -0,0 +1,63 @@ +{ + "end_device_ids": { + "device_id": "v3demo1", + "application_ids": { + "application_id": "test2id" + }, + "dev_eui": "008000000000A0B6", + "join_eui": "0000000000000000", + "dev_addr": "260B850F" + }, + "correlation_ids": [ + "as:up:01EY5RKC5HCVMCVAHPN8EMZQQC", + "gs:conn:01EY5NK2FWERHS4M75W8QSANK9", + "gs:up:host:01EY5NK2G2MCRVMKG9015JJ0GT", + "gs:uplink:01EY5RKBZ1XJJA112CDY5BSQKQ", + "ns:uplink:01EY5RKBZ3YRCCCPRQCAY2YQ4Y", + "rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01EY5RKBZ2DW9HCP0AZ6XQ1C4V", + "rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01EY5RKC5GT79XR6EXRZ4HBYXK" + ], + "received_at": "2021-02-10T10:43:12.689616958Z", + "uplink_message": { + "session_key_id": "AXeLWcyXpwXNMEKFxfTSqQ==", + "f_port": 1, + "f_cnt": 84, + "frm_payload": "eyJ0IjoyNC40fQ==", + "rx_metadata": [ + { + "gateway_ids": { + "gateway_id": "eui-024b08fefe040083", + "eui": "E024B08FEFE04008" + }, + "time": "2021-02-10T10:43:12.461088Z", + "timestamp": 3157838364, + "rssi": -113, + "channel_rssi": -113, + "snr": -3, + "location": { + "latitude": 52.00996862975038, + "longitude": 4.716007411479951, + "source": "SOURCE_REGISTRY" + }, + "uplink_token": "CiIKIAoUZXVpLTAyNGIwOGZlZmUwNDAwODMSCOAksI/v4EAIEJyU4+ELGgwIwPGOgQYQ97bR5QEg4PrV7vNb", + "channel_index": 5 + } + ], + "settings": { + "data_rate": { + "lora": { + "bandwidth": 125000, + "spreading_factor": 7 + } + }, + "data_rate_index": 5, + "coding_rate": "4/5", + "frequency": "867500000", + "timestamp": 3157838364, + "time": "2021-02-10T10:43:12.461088Z" + }, + "received_at": "2021-02-10T10:43:12.483085362Z", + "consumed_airtime": "0.061696s" + } +} +