001/*
002 * geordi
003 *
004 * Copyright (C) 2018 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.math.BigDecimal;
021import java.math.RoundingMode;
022import java.net.MalformedURLException;
023import java.net.URL;
024import java.time.Instant;
025import java.time.temporal.ChronoField;
026import java.time.temporal.ChronoUnit;
027import java.time.temporal.Temporal;
028import java.util.ArrayList;
029import java.util.List;
030import java.util.Optional;
031
032import javax.inject.Inject;
033
034import org.json.JSONArray;
035import org.json.JSONException;
036import org.json.JSONObject;
037import org.json.JSONTokener;
038import org.shredzone.geordi.GeordiException;
039import org.shredzone.geordi.data.Sample;
040import org.shredzone.geordi.sensor.Sensor;
041import org.shredzone.geordi.service.DatabaseService;
042
043/**
044 * A {@link Device} implementation that reads particulate sensors. It also supports all
045 * other sensors that can be attached to the device, like temperature, humidity, and
046 * atmospheric pressure.
047 * <p>
048 * Note that <em>Dusty</em> is not an official term for the device. It refers to the
049 * German word for particulates, "Feinstaub", which literally translates to "fine dust".
050 *
051 * @see <a href="https://luftdaten.info/">luftdaten.info</a>
052 */
053public class DustyDevice extends Device {
054
055    @Inject
056    private DatabaseService databaseService;
057
058    @Override
059    public List<Sample> readSensors() {
060        JSONObject json;
061        try (InputStream in = getServerUrl().openStream()) {
062            json = new JSONObject(new JSONTokener(in));
063        } catch (IOException | JSONException ex) {
064            throw new GeordiException("Could not read data for sensor " + getId(), ex);
065        }
066
067        // Dusty only returns the "age" of the last sample, in seconds. To convert it
068        // into a timestamp, we subtract the "age" from the current time, and truncate
069        // it to slots having a width of 2 seconds. This way, "ts" will always contain
070        // the same timestamp of the sample, taking into account that Dusty's internal
071        // clock is not synchronized to the server's clock.
072        Instant ts = Instant.now()
073                    .minus(json.getLong("age"), ChronoUnit.SECONDS)
074                    .with(DustyDevice::truncate2Seconds);
075
076        JSONArray values = json.getJSONArray("sensordatavalues");
077
078        List<Sample> result = new ArrayList<>();
079        for (Sensor sensor : databaseService.fetchSensors(this)) {
080            getSensorValue(values, sensor)
081                    .map(value -> new Sample(sensor, ts, value))
082                    .ifPresent(result::add);
083        }
084
085        return result;
086    }
087
088    /**
089     * Reads the value of a {@link Sensor} from the JSON response.
090     *
091     * @param values
092     *            JSON response of Dusty
093     * @param sensor
094     *            {@link Sensor} to read
095     * @return Sensor value, or empty if the sensor provided no value
096     */
097    private Optional<BigDecimal> getSensorValue(JSONArray values, Sensor sensor) {
098        JSONObject config = sensor.getConfig();
099
100        String key = config.getString("value_type");
101
102        Optional<BigDecimal> result = findValue(values, key);
103
104        if (config.has("divisor")) {
105            result = result.map(it -> it.divide(config.getBigDecimal("divisor")));
106        }
107
108        if (config.has("height")) {
109            Optional<BigDecimal> temp = findValue(values, "BMP_temperature");
110            if (!temp.isPresent()) {
111                return Optional.empty();
112            }
113            result = result.map(BigDecimal::doubleValue)
114                    .map(it -> convertToRelative(it, temp.get().doubleValue(), config.getInt("height")))
115                    .map(BigDecimal::new)
116                    .map(it -> it.setScale(2, RoundingMode.HALF_UP));
117        }
118
119        if (config.has("dewpoint") && config.getBoolean("dewpoint") == true) {
120            Optional<BigDecimal> humidity = findValue(values, "humidity");
121            if (!humidity.isPresent()) {
122                return Optional.empty();
123            }
124
125            // Avoid infinity as result
126            BigDecimal humidVal = humidity.get();
127            if (BigDecimal.ZERO.equals(humidVal)) {
128                return Optional.empty();
129            }
130
131            result = result.map(BigDecimal::doubleValue)
132                    .map(it -> dewpoint(it, humidVal.doubleValue()))
133                    .map(BigDecimal::new)
134                    .map(it -> it.setScale(2, RoundingMode.HALF_UP));
135        }
136
137        return result;
138    }
139
140    /**
141     * Finds a sensor value in the JSON data.
142     *
143     * @param values
144     *            JSON data
145     * @param key
146     *            Sensor key
147     * @return Sensor value, or empty if not found
148     */
149    private Optional<BigDecimal> findValue(JSONArray values, String key) {
150        for (int ix = 0; ix < values.length(); ix++) {
151            JSONObject jo = values.getJSONObject(ix);
152            if (key.equals(jo.getString("value_type"))) {
153                return Optional.of(jo.getBigDecimal("value"));
154            }
155        }
156        return Optional.empty();
157    }
158
159    /**
160     * Returns the URL of Dusty's web server.
161     */
162    private URL getServerUrl() {
163        try {
164            return new URL(String.format("http://%s/data.json", getConfig().getString("host")));
165        } catch (MalformedURLException | JSONException ex) {
166            throw new GeordiException("Bad host config", ex);
167        }
168    }
169
170    /**
171     * Converts absolute air pressure to relative air pressure.
172     *
173     * @param absolute
174     *            Absolute pressure, in mbar
175     * @param temperature
176     *            Temperature, in °C
177     * @param height
178     *            Height above sea level, in meters
179     * @return Relative pressure, in mbar
180     * @see <a href="http://keisan.casio.com/exec/system/1224575267">http://keisan.casio.com/exec/system/1224575267</a>
181     */
182    private static double convertToRelative(double absolute, double temperature, int height) {
183        return absolute * Math.pow(
184                1.0 - ((0.0065 * height) / (temperature + 0.0065 * height + 273.15)),
185                -5.257);
186    }
187
188    /**
189     * Computes the dew point from the temperature and humidity.
190     *
191     * @param temp
192     *            Temperature (in °C)
193     * @param humid
194     *            Humidity (in percent)
195     * @return Dew point
196     * @see <a href="https://en.wikipedia.org/wiki/Dew_point">https://en.wikipedia.org/wiki/Dew_point</a>
197     */
198    private static double dewpoint(double temp, double humid) {
199        final double k2 = 17.62;
200        final double k3 = 243.12;
201
202        double d1 = ((k2 * temp) / (k3 + temp)) + Math.log(humid / 100.0);
203        double d2 = ((k2 * k3) / (k3 + temp)) - Math.log(humid / 100.0);
204
205        return k3 * (d1 / d2);
206    }
207
208    /**
209     * Truncates the {@link Temporal} at "2 seconds" intervals.
210     *
211     * @param temporal
212     *            {@link Temporal} to truncate
213     * @return Truncated {@link Temporal}
214     */
215    private static Temporal truncate2Seconds(Temporal temporal) {
216        long time = temporal.getLong(ChronoField.INSTANT_SECONDS);
217        time = ((time + 1L) / 2L) * 2L;
218        return Instant.ofEpochSecond(time);
219    }
220
221}