diff --git a/workspace/ttnhabbridge/build.gradle b/workspace/ttnhabbridge/build.gradle index add5d15..767cd4c 100644 --- a/workspace/ttnhabbridge/build.gradle +++ b/workspace/ttnhabbridge/build.gradle @@ -4,6 +4,12 @@ mainClassName = 'nl.sikken.bertrik.TtnHabBridge' dependencies { compile libraries.slf4jlog4j + + compile libraries.jersey_client + compile libraries.jackson + compile libraries.jetty + + testCompile libraries.mockito } //Add configuration folder to classpath: diff --git a/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/HabitatUploader.java b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/HabitatUploader.java new file mode 100644 index 0000000..0abf9e4 --- /dev/null +++ b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/HabitatUploader.java @@ -0,0 +1,180 @@ +package nl.sikken.bertrik.hab.habitat; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Base64; +import java.util.Base64.Encoder; +import java.util.Date; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.WebTarget; +import javax.xml.bind.DatatypeConverter; + +import org.glassfish.jersey.client.ClientProperties; +import org.glassfish.jersey.client.proxy.WebResourceFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Habitat uploader. + */ +public final class HabitatUploader { + + private static MessageDigest sha256; + + private final Logger LOG = LoggerFactory.getLogger(HabitatUploader.class); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Encoder base64Encoder = Base64.getEncoder(); + private final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX"); + + private final IHabitatRestApi restClient; + + static { + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("No SHA-256 hash found"); + } + } + + /** + * Constructor. + * + * @param restClient the REST client used for uploading + */ + public HabitatUploader(IHabitatRestApi restClient) { + this.restClient = restClient; + } + + /** + * Starts the uploader process. + */ + public void start() { + LOG.info("Starting habitat uploader"); + + LOG.info("Started habitat uploader"); + } + + /** + * Stops the uploader process. + * @throws InterruptedException in case of a termination problem + */ + public void stop() throws InterruptedException { + LOG.info("Stopping habitat uploader"); + executor.shutdown(); + LOG.info("Stopped habitat uploader"); + } + + /** + * Uploads a new sentence to the HAB network (non-blocking). + * + * @param sentence the ASCII sentence + * @param receivers list of receivers that got this sentence + * @param date the current date + */ + public void upload(String sentence, List receivers, Date date) { + // encode sentence as raw bytes + final byte[] bytes = sentence.getBytes(StandardCharsets.US_ASCII); + + // determine docId + final String docId = createDocId(bytes); + LOG.info("docid = {}", docId); + + for (IHabReceiver receiver : receivers) { + LOG.info("Uploading for {}: {}", receiver.getCallsign(), sentence.trim()); + + // create Json + final String json = createJson(receiver, bytes, date, date); + + // submit it to our processing thread + uploadTelemetry(docId, json); + } + } + + /** + * Performs the actual upload as a REST-like call towards habitat. + * + * @param docId the document id + * @param json the JSON payload + */ + private void uploadTelemetry(String docId, String json) { + LOG.info("Sending for {}: {}", docId, json); + try { + final String response = restClient.updateListener(docId, json); + LOG.info("Response for {}: {}", docId, response); + } catch (WebApplicationException e) { + LOG.warn("Caught exception: {}", e.getMessage()); + } + } + + /** + * Creates the JSON payload. + * + * @param receiver the radio receiver properties + * @param bytes the raw sentence + * @param dateCreated the creation date + * @param dateUploaded the upload date + * @return a new JSON encoded string + */ + public String createJson(IHabReceiver receiver, byte[] bytes, Date dateCreated, Date dateUploaded) { + final JsonNodeFactory factory = new JsonNodeFactory(false); + final ObjectNode topNode = factory.objectNode(); + + // create data node + final ObjectNode dataNode = factory.objectNode(); + dataNode.set("_raw", factory.binaryNode(bytes)); + + // create receivers node + final ObjectNode receiversNode = factory.objectNode(); + final ObjectNode receiverNode = factory.objectNode(); + receiverNode.set("time_created", factory.textNode(dateFormat.format(dateCreated))); + receiverNode.set("time_uploaded", factory.textNode(dateFormat.format(dateUploaded))); + receiversNode.set(receiver.getCallsign(), receiverNode); + + // put it together in the top node + topNode.set("data", dataNode); + topNode.set("receivers", receiversNode); + + return topNode.toString(); + } + + /** + * Creates the document id. + * + * @param bytes the raw sentence + * @return the document id + */ + public String createDocId(byte[] bytes) { + final byte[] base64 = base64Encoder.encode(bytes); + final byte[] hash = sha256.digest(base64); + return DatatypeConverter.printHexBinary(hash).toLowerCase(); + } + + /** + * Creates an actual REST client. + * Can be used in the constructor. + * + * @param url the URL to connect to + * @param timeout the connect and read timeout (ms) + * @return a new REST client + */ + public static IHabitatRestApi newRestClient(String url, int timeout) { + // create the REST client + final WebTarget target = ClientBuilder.newClient() + .property(ClientProperties.CONNECT_TIMEOUT, timeout) + .property(ClientProperties.READ_TIMEOUT, timeout) + .target(url); + return WebResourceFactory.newResource(IHabitatRestApi.class, target); + } + +} diff --git a/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/IHabReceiver.java b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/IHabReceiver.java new file mode 100644 index 0000000..a0762e1 --- /dev/null +++ b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/IHabReceiver.java @@ -0,0 +1,12 @@ +package nl.sikken.bertrik.hab.habitat; + +/** + * Interface describing a HAB receiver. + */ +public interface IHabReceiver { + + public String getCallsign(); + + public Location getLocation(); + +} diff --git a/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/IHabitatRestApi.java b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/IHabitatRestApi.java new file mode 100644 index 0000000..8282ee6 --- /dev/null +++ b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/IHabitatRestApi.java @@ -0,0 +1,25 @@ +package nl.sikken.bertrik.hab.habitat; + +import javax.ws.rs.Consumes; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +/** + * Interface definition for payload telemetry + * + * Publish this on "/habitat" for example. + * + */ +@Path("/_design/payload_telemetry") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public interface IHabitatRestApi { + + @Path("/_update/add_listener/{doc_id}") + @PUT + public String updateListener(@PathParam("doc_id") String docId, String json); + +} diff --git a/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/Location.java b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/Location.java new file mode 100644 index 0000000..5473415 --- /dev/null +++ b/workspace/ttnhabbridge/src/main/java/nl/sikken/bertrik/hab/habitat/Location.java @@ -0,0 +1,37 @@ +package nl.sikken.bertrik.hab.habitat; + +/** + * Representation of a HAB receiver location. + */ +public final class Location { + + private final double lat; + private final double lon; + private final double alt; + + /** + * Constructor. + * + * @param lat latitude (degrees) + * @param lon longitude (degrees) + * @param alt altitude (meter) + */ + public Location(double lat, double lon, double alt) { + this.lat = lat; + this.lon = lon; + this.alt = alt; + } + + public double getLat() { + return lat; + } + + public double getLon() { + return lon; + } + + public double getAlt() { + return alt; + } + +} diff --git a/workspace/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/habitat/HabitatUploaderTest.java b/workspace/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/habitat/HabitatUploaderTest.java new file mode 100644 index 0000000..60de057 --- /dev/null +++ b/workspace/ttnhabbridge/src/test/java/nl/sikken/bertrik/hab/habitat/HabitatUploaderTest.java @@ -0,0 +1,59 @@ +package nl.sikken.bertrik.hab.habitat; + +import java.util.Arrays; +import java.util.Date; + +import org.junit.Test; +import org.mockito.Mockito; + +import nl.sikken.bertrik.hab.Sentence; + +/** + * @author bertrik + * + */ +public final class HabitatUploaderTest { + + /** + * Happy flow scenario. + * + * Verifies that a request to upload results in an actual upload. + * + * @throws InterruptedException + */ + @Test + public void testMockedHappyFlow() throws InterruptedException { + // create a mocked rest client + final IHabitatRestApi restClient = Mockito.mock(IHabitatRestApi.class); + Mockito.when(restClient.updateListener(Mockito.anyString(), Mockito.anyString())).thenReturn("OK"); + final HabitatUploader uploader = new HabitatUploader(restClient); + + // test it + uploader.start(); + try { + final IHabReceiver receiver = createReceiver(); + final Date date = new Date(); + final Sentence sentence = new Sentence("NOTAFLIGHT", 1, date, 52.0182307, 4.695772, 1000); + uploader.upload(sentence.format(), Arrays.asList(receiver), date); + + Mockito.verify(restClient, Mockito.timeout(3000)).updateListener(Mockito.anyString(), Mockito.anyString()); + } finally { + uploader.stop(); + } + } + + private IHabReceiver createReceiver() { + final IHabReceiver receiver = new IHabReceiver() { + @Override + public Location getLocation() { + return new Location(52.0182307, 4.695772, 4); + } + @Override + public String getCallsign() { + return "BERTRIK"; + } + }; + return receiver; + } + +}