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}