001/*
002 * geordi
003 *
004 * Copyright (C) 2020 Richard "Shred" Körber
005 *   https://github.com/shred/geordi
006 *
007 * This program is free software: you can redistribute it and/or modify
008 * it under the terms of the GNU General Public License as
009 * published by the Free Software Foundation, either version 3 of the
010 * License, or (at your option) any later version.
011 *
012 * This program is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
015 */
016package org.shredzone.geordi.device;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.net.HttpURLConnection;
021import java.net.MalformedURLException;
022import java.net.URL;
023import java.time.Instant;
024import java.time.temporal.ChronoField;
025import java.time.temporal.ChronoUnit;
026import java.time.temporal.Temporal;
027import java.util.ArrayList;
028import java.util.List;
029import java.util.Optional;
030
031import javax.inject.Inject;
032
033import org.json.JSONArray;
034import org.json.JSONException;
035import org.json.JSONObject;
036import org.json.JSONTokener;
037import org.shredzone.geordi.GeordiException;
038import org.shredzone.geordi.data.Sample;
039import org.shredzone.geordi.sensor.Sensor;
040import org.shredzone.geordi.service.DatabaseService;
041
042/**
043 * A {@link Device} implementation for the Kaminari lightning sensor project.
044 *
045 * @see <a href="https://kaminari.shredzone.org">Kaminari project page</a>
046 */
047public class KaminariDevice extends Device {
048
049    @Inject
050    private DatabaseService databaseService;
051
052    @Override
053    public List<Sample> readSensors() {
054        JSONObject json;
055        try (InputStream in = openConnection("status").getInputStream()) {
056            json = new JSONObject(new JSONTokener(in));
057        } catch (IOException | JSONException ex) {
058            throw new GeordiException("Could not read data for sensor " + getId(), ex);
059        }
060
061        List<Sensor> sensors = databaseService.fetchSensors(this);
062        List<Sample> result = new ArrayList<>();
063
064        JSONArray values = json.getJSONArray("lightnings");
065        for (int ix = 0; ix < values.length(); ix++) {
066            JSONObject jo = values.getJSONObject(ix);
067            for (Sensor sensor : sensors) {
068                getLightningValue(jo, sensor).ifPresent(result::add);
069            }
070        }
071
072        Instant now = Instant.now();
073        for (Sensor sensor : sensors) {
074            getSensorValue(json, sensor, now).ifPresent(result::add);
075        }
076
077        try (InputStream in = openConnection("clear").getInputStream()) {
078            while (in.read() != -1) {
079                // intentionally left empty
080            }
081        } catch (IOException ex) {
082            throw new GeordiException("Could not clear data for sensor " + getId(), ex);
083        }
084
085        return result;
086    }
087
088    private Optional<Sample> getLightningValue(JSONObject values, Sensor sensor) {
089        JSONObject config = sensor.getConfig();
090        if (!config.has("lightning_key")) {
091            return Optional.empty();
092        }
093
094        Instant ts = Instant.now()
095                .minus(values.getLong("age"), ChronoUnit.SECONDS)
096                .with(KaminariDevice::truncate2Seconds);
097
098        String key = config.getString("lightning_key");
099        return Optional.ofNullable(values.optBigDecimal(key, null))
100                .map(value -> new Sample(sensor, ts, value));
101    }
102
103    /**
104     * Reads the value of a {@link Sensor} from the JSON response.
105     *
106     * @param values
107     *            JSON response of Kaminari
108     * @param sensor
109     *            {@link Sensor} to read
110     * @return Sensor value, or empty if the sensor provided no value
111     */
112    private Optional<Sample> getSensorValue(JSONObject values, Sensor sensor, Instant now) {
113        JSONObject config = sensor.getConfig();
114        if (!config.has("key")) {
115            return Optional.empty();
116        }
117
118        String key = config.getString("key");
119        return Optional.ofNullable(values.optBigDecimal(key, null))
120                .map(value -> new Sample(sensor, now, value));
121    }
122
123    /**
124     * Opens a connection to Kaminari.
125     */
126    private HttpURLConnection openConnection(String target) throws IOException {
127        HttpURLConnection connection = (HttpURLConnection) getServerUrl(target).openConnection();
128        String apikey = getConfig().getString("apikey");
129        if (apikey != null) {
130            connection.setRequestProperty("X-API-Key", apikey);
131        }
132        return connection;
133    }
134
135    /**
136     * Returns the URL of Kaminari's web server.
137     */
138    private URL getServerUrl(String target) {
139        try {
140            return new URL(String.format("http://%s/%s", getConfig().getString("host"), target));
141        } catch (MalformedURLException | JSONException ex) {
142            throw new GeordiException("Bad host config", ex);
143        }
144    }
145
146    /**
147     * Truncates the {@link Temporal} at "2 seconds" intervals.
148     *
149     * @param temporal
150     *            {@link Temporal} to truncate
151     * @return Truncated {@link Temporal}
152     */
153    private static Temporal truncate2Seconds(Temporal temporal) {
154        long time = temporal.getLong(ChronoField.INSTANT_SECONDS);
155        time = ((time + 1L) / 2L) * 2L;
156        return Instant.ofEpochSecond(time);
157    }
158
159}