001/* 002 * geordi 003 * 004 * Copyright (C) 2019 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 static java.math.RoundingMode.HALF_UP; 019import static java.nio.charset.StandardCharsets.UTF_16LE; 020import static java.nio.charset.StandardCharsets.UTF_8; 021 022import java.io.IOException; 023import java.io.InputStreamReader; 024import java.io.Reader; 025import java.math.BigDecimal; 026import java.net.URL; 027import java.security.MessageDigest; 028import java.security.NoSuchAlgorithmException; 029import java.time.Instant; 030import java.util.List; 031import java.util.Objects; 032import java.util.function.Function; 033import java.util.stream.Collectors; 034 035import javax.inject.Inject; 036 037import org.json.JSONObject; 038import org.shredzone.commons.xml.XQuery; 039import org.shredzone.geordi.GeordiException; 040import org.shredzone.geordi.data.Sample; 041import org.shredzone.geordi.sensor.Sensor; 042import org.shredzone.geordi.service.DatabaseService; 043import org.slf4j.Logger; 044import org.slf4j.LoggerFactory; 045 046/** 047 * A {@link Device} implementation that reads values from AVM FRITZ!DECT smart home 048 * devices. 049 * 050 * @see <a href="https://avm.de/">AVM GmbH</a> 051 */ 052public class AvmDevice extends Device { 053 private static final char[] HEX = { 054 '0', '1', '2', '3', '4', '5', '6', '7', 055 '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' 056 }; 057 private static final String NO_SESSION = "0000000000000000"; 058 private static final BigDecimal TWO = new BigDecimal("2"); 059 private static final BigDecimal TEN = BigDecimal.TEN; 060 private static final BigDecimal ONE_THOUSAND = new BigDecimal("1000"); 061 062 private final Logger log = LoggerFactory.getLogger(getClass()); 063 064 @Inject 065 private DatabaseService databaseService; 066 067 @Override 068 public List<Sample> readSensors() { 069 String sid = getSessionId(); 070 XQuery values = fetchFromServer(sid); 071 Instant instant = Instant.now(); 072 073 return databaseService.fetchSensors(this).stream() 074 .map(sensor -> getSensorValue(values, sensor, instant)) 075 .filter(Objects::nonNull) 076 .collect(Collectors.toList()); 077 } 078 079 /** 080 * Reads the current sensor value from the given {@link Sensor}. 081 * 082 * @param values 083 * XML that was read from the AHA interface 084 * @param sensor 085 * {@link Sensor} to be read 086 * @param instant 087 * {@link Instant} of sensor reading 088 * @return {@link Sample} containing the sensor value 089 */ 090 private Sample getSensorValue(XQuery values, Sensor sensor, Instant instant) { 091 try { 092 JSONObject config = sensor.getConfig(); 093 094 XQuery sensorDevice = values.get(String.format( 095 "//device[@identifier='%s']", 096 config.getString("ain") 097 )); 098 099 if ("0".equals(sensorDevice.get("present").text())) { 100 return null; 101 } 102 103 BigDecimal value = null; 104 String type = config.getString("type"); 105 if ("power".equals(type)) { 106 value = getValue(sensorDevice, "powermeter/power", 107 v -> v.setScale(3, HALF_UP) 108 .divide(ONE_THOUSAND, HALF_UP)); 109 } else if ("voltage".equals(type)) { 110 value = getValue(sensorDevice, "powermeter/voltage", 111 v -> v.setScale(3, HALF_UP) 112 .divide(ONE_THOUSAND, HALF_UP)); 113 } else if ("temperature".equals(type)) { 114 value = getValue(sensorDevice, "temperature/celsius", 115 v -> v.setScale(1, HALF_UP) 116 .divide(TEN, HALF_UP)); 117 } else if ("switch".equals(type)) { 118 value = getValue(sensorDevice, "switch/state", v -> v); 119 } else if ("alert".equals(type)) { 120 value = getValue(sensorDevice, "alert/state", v -> v); 121 } else if ("currentTemperature".equals(type)) { 122 value = getValue(sensorDevice, "hkr/tist", AvmDevice::convertTemp); 123 } else if ("targetTemperature".equals(type)) { 124 value = getValue(sensorDevice, "hkr/tsoll", AvmDevice::convertTemp); 125 } 126 127 if (value != null) { 128 return new Sample(sensor, instant, value); 129 } 130 } catch (Exception ex) { 131 log.warn("Could not read sensor id {} ({})", sensor.getId(), sensor.getName(), ex); 132 } 133 return null; 134 } 135 136 /** 137 * Gets the value from the XML structure. 138 * 139 * @param xml 140 * XML to get the value from 141 * @param path 142 * Value's XPath 143 * @param mapper 144 * A mapper that converts the value that was found 145 * @return Value, or {@code null} if the value is not present 146 */ 147 private BigDecimal getValue(XQuery xml, String path, Function<BigDecimal, BigDecimal> mapper) { 148 return xml.select(path) 149 .findFirst() 150 .map(XQuery::text) 151 .filter(t -> !t.isEmpty()) 152 .map(BigDecimal::new) 153 .map(mapper) 154 .orElse(null); 155 } 156 157 /** 158 * Logs into the AHA interface and returns a Session ID. 159 * 160 * @return Session ID 161 */ 162 private String getSessionId() { 163 try { 164 String challenge; 165 166 URL url1 = new URL(getHostName() + "/login_sid.lua"); 167 try (Reader in = new InputStreamReader(url1.openStream(), UTF_8)) { 168 XQuery xml = XQuery.parse(in); 169 String sid = findSessionId(xml); 170 if (sid != null && !NO_SESSION.equals(sid)) { 171 return sid; 172 } 173 174 challenge = findChallenge(xml); 175 } 176 177 if (challenge == null) { 178 throw new GeordiException("No challenge provided by FRITZ!Box " + getHostName()); 179 } 180 181 String user = getConfig().getString("user"); 182 String password = getConfig().getString("password"); 183 String response = computeResponse(challenge, password); 184 185 URL url2 = new URL(getHostName() + "/login_sid.lua?username=" 186 + user + "&response=" + response); 187 try (Reader in = new InputStreamReader(url2.openStream(), UTF_8)) { 188 XQuery xml = XQuery.parse(in); 189 String sid = findSessionId(xml); 190 if (sid == null || NO_SESSION.equals(sid)) { 191 throw new GeordiException("Access denied by FRITZ!Box " + getHostName() + " for user " + user); 192 } 193 194 return sid; 195 } 196 } catch (IOException ex) { 197 throw new GeordiException("Could not connect to FRITZ!Box", ex); 198 } 199 } 200 201 /** 202 * Finds the session ID in the XML structure. 203 * 204 * @param xml 205 * XML to search 206 * @return Session ID, or {@code null} if none was found 207 */ 208 private String findSessionId(XQuery xml) { 209 return xml.select("/SessionInfo/SID") 210 .findFirst() 211 .map(XQuery::text) 212 .orElse(null); 213 } 214 215 /** 216 * Finds the challenge in the XML structure. 217 * 218 * @param xml 219 * XML to search 220 * @return Challenge, or {@code null} if none was found 221 */ 222 private String findChallenge(XQuery xml) { 223 return xml.select("/SessionInfo/Challenge") 224 .findFirst() 225 .map(XQuery::text) 226 .orElse(null); 227 } 228 229 /** 230 * Computes the response to the given challenge. 231 * 232 * @param challenge 233 * Challenge sent by the AHA interface. 234 * @param password 235 * User's password 236 * @return Response to the challenge 237 */ 238 private String computeResponse(String challenge, String password) { 239 try { 240 MessageDigest md5 = MessageDigest.getInstance("MD5"); 241 md5.update(challenge.getBytes(UTF_16LE)); 242 md5.update("-".getBytes(UTF_16LE)); 243 md5.update(password.getBytes(UTF_16LE)); 244 StringBuilder sb = new StringBuilder(); 245 sb.append(challenge).append('-'); 246 for (byte b : md5.digest()) { 247 sb.append(HEX[(b >> 4) & 0x0F]).append(HEX[b & 0x0F]); 248 } 249 return sb.toString(); 250 } catch (NoSuchAlgorithmException ex) { 251 throw new IllegalStateException(ex); // Should never happen, md5 is standard 252 } 253 } 254 255 /** 256 * Reads the current status from the AHA interface (aka FRITZ!Box). 257 * 258 * @param sid 259 * Session ID 260 * @return XML containing the overall sensor status 261 */ 262 private XQuery fetchFromServer(String sid) { 263 try { 264 URL url = new URL(getHostName() 265 + "/webservices/homeautoswitch.lua" 266 + "?switchcmd=getdevicelistinfos" 267 + "&sid=" + sid); 268 269 try (Reader in = new InputStreamReader(url.openStream(), UTF_8)) { 270 return XQuery.parse(in); 271 } 272 } catch (IOException ex) { 273 throw new GeordiException("Could not read from FRITZ!Box", ex); 274 } 275 } 276 277 /** 278 * Returns the host name of the AHA interface. 279 * 280 * @return Host name (e.g. "http://fritz.box") 281 */ 282 private String getHostName() { 283 return getConfig().optBoolean("tls", false) ? "https" : "http" 284 + "://" 285 + getConfig().optString("host", "fritz.box"); 286 } 287 288 /** 289 * Converts a thermostat decimal into a temperature value. 290 * 291 * @param value 292 * Decimal value 293 * @return Temperature, or {@code null} if this is not a temperature value 294 */ 295 private static BigDecimal convertTemp(BigDecimal value) { 296 int val = value.intValue(); 297 if (val < 16 || val > 56) { 298 // This is not a valid temperature value 299 return null; 300 } 301 302 return value 303 .setScale(1, HALF_UP) 304 .divide(TWO, HALF_UP); 305 } 306 307}