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}