]> git.basschouten.com Git - openhab-addons.git/blob
b4c16e004f9fc02f31f95bdb3b37e16437f5ee82
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.kostalinverter.internal.thirdgeneration;
14
15 import static org.openhab.binding.kostalinverter.internal.thirdgeneration.ThirdGenerationBindingConstants.*;
16
17 import java.io.UnsupportedEncodingException;
18 import java.security.InvalidAlgorithmParameterException;
19 import java.security.InvalidKeyException;
20 import java.security.NoSuchAlgorithmException;
21 import java.security.SecureRandom;
22 import java.security.spec.InvalidKeySpecException;
23 import java.util.Base64;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.Map.Entry;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31
32 import javax.crypto.BadPaddingException;
33 import javax.crypto.Cipher;
34 import javax.crypto.IllegalBlockSizeException;
35 import javax.crypto.Mac;
36 import javax.crypto.NoSuchPaddingException;
37 import javax.crypto.spec.GCMParameterSpec;
38 import javax.crypto.spec.SecretKeySpec;
39
40 import org.eclipse.jdt.annotation.NonNullByDefault;
41 import org.eclipse.jdt.annotation.Nullable;
42 import org.eclipse.jetty.client.HttpClient;
43 import org.eclipse.jetty.client.api.ContentResponse;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.QuantityType;
46 import org.openhab.core.library.unit.SIUnits;
47 import org.openhab.core.library.unit.Units;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.thing.ThingStatus;
51 import org.openhab.core.thing.ThingStatusDetail;
52 import org.openhab.core.thing.binding.BaseThingHandler;
53 import org.openhab.core.types.Command;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 import com.google.gson.JsonArray;
58 import com.google.gson.JsonObject;
59
60 /**
61  * The {@link ThirdGenerationHandler} is responsible for handling commands, which are
62  * sent to one of the channels.
63  *
64  * @author RenĂ© Stakemeier - Initial contribution
65  */
66 @NonNullByDefault
67 public class ThirdGenerationHandler extends BaseThingHandler {
68
69     /*
70      * operations used for authentication
71      */
72     private static final String AUTH_START = "/auth/start";
73     private static final String AUTH_FINISH = "/auth/finish";
74     private static final String AUTH_CREATE_SESSION = "/auth/create_session";
75
76     /*
77      * operations used for gathering process data from the device
78      */
79     private static final String PROCESSDATA = "/processdata";
80
81     /*
82      * After the authentication the result (the session id) is stored here and used to "sign" future requests
83      */
84     private @Nullable String sessionId;
85     /*
86      * The configuration file containing the host, the password and the refresh interval
87      */
88     private @NonNullByDefault({}) ThirdGenerationConfiguration config;
89
90     private @Nullable ScheduledFuture<?> refreshScheduler;
91
92     private @Nullable HttpClient httpClient;
93
94     private ThirdGenerationInverterTypes inverterType;
95
96     private final Logger logger = LoggerFactory.getLogger(this.getClass());
97
98     /**
99      * Constructor of this class
100      *
101      * @param thing the thing
102      * @param httpClient the httpClient used for communication
103      * @param inverterType the type of the device
104      */
105     public ThirdGenerationHandler(Thing thing, HttpClient httpClient, ThirdGenerationInverterTypes inverterType) {
106         super(thing);
107         this.inverterType = inverterType;
108         this.httpClient = httpClient;
109     }
110
111     @Override
112     public void handleCommand(ChannelUID channelUID, Command command) {
113         // All channels are readonly and updated by the scheduler
114     }
115
116     @Override
117     public void dispose() {
118         if (refreshScheduler != null) {
119             refreshScheduler.cancel(true);
120             refreshScheduler = null;
121         }
122         super.dispose();
123     }
124
125     @Override
126     public void initialize() {
127         config = getConfigAs(ThirdGenerationConfiguration.class);
128         // temporary value while initializing
129         updateStatus(ThingStatus.UNKNOWN);
130
131         // Start the authentication
132         scheduler.schedule(this::authenticate, 1, TimeUnit.SECONDS);
133
134         // Start the update scheduler as configured
135         refreshScheduler = scheduler.scheduleWithFixedDelay(this::updateChannelValues, 10,
136                 config.refreshInternalInSeconds, TimeUnit.SECONDS);
137     }
138
139     /**
140      * The API supports the resolution of multiple values at a time
141      *
142      * Therefore this methods builds one request to gather all information for the current inverter.
143      * The list contains all channels as defined in {@link ThirdGenerationMappingInverterToChannel} for the
144      * current inverter
145      *
146      */
147     private void updateChannelValues() {
148         Map<String, List<ThirdGenerationChannelMappingToWebApi>> channelList = ThirdGenerationMappingInverterToChannel
149                 .getModuleToChannelsMappingForInverter(inverterType);
150         JsonArray updateMessageJsonArray = getUpdateChannelMessage(channelList);
151
152         // Send the API request to get values for all channels
153         ContentResponse updateMessageContentResponse;
154         try {
155             updateMessageContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
156                     PROCESSDATA, updateMessageJsonArray, sessionId);
157             if (updateMessageContentResponse.getStatus() == 404) {
158                 // Module not found
159                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
160                         COMMUNICATION_ERROR_INCOMPATIBLE_DEVICE);
161                 return;
162             }
163             if (updateMessageContentResponse.getStatus() == 503) {
164                 // Communication error (e.g. during initial boot of the SCB)
165                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
166                         COMMUNICATION_ERROR_HTTP);
167                 return;
168             }
169             if (updateMessageContentResponse.getStatus() == 401) {
170                 // session not valid (timed out? device rebooted?)
171                 logger.info("Session expired - performing retry");
172                 authenticate();
173                 // Retry
174                 updateMessageContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
175                         PROCESSDATA, updateMessageJsonArray, sessionId);
176             }
177         } catch (TimeoutException | ExecutionException e) {
178             // Communication problem
179             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
180             return;
181         } catch (InterruptedException e) {
182             Thread.currentThread().interrupt();
183             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
184             return;
185         }
186         JsonArray updateMessageResultsJsonArray = ThirdGenerationHttpHelper
187                 .getJsonArrayFromResponse(updateMessageContentResponse);
188
189         // Map the returned values back to the channels and update them
190         for (int i = 0; i < updateMessageResultsJsonArray.size(); i++) {
191             JsonObject moduleAnswer = updateMessageResultsJsonArray.get(i).getAsJsonObject();
192             String moduleName = moduleAnswer.get("moduleid").getAsString();
193             JsonArray processdata = moduleAnswer.get("processdata").getAsJsonArray();
194             for (int j = 0; j < processdata.size(); j++) {
195                 // Update the channels with their new value
196                 JsonObject newValueObject = processdata.get(j).getAsJsonObject();
197                 String valueId = newValueObject.get("id").getAsString();
198                 double valueAsDouble = newValueObject.get("value").getAsDouble();
199                 ThirdGenerationChannelMappingToWebApi channel = channelList.get(moduleName).stream()
200                         .filter(c -> c.moduleId.equals(moduleName) && c.processdataId.equals(valueId)).findFirst()
201                         .get();
202                 updateChannelValue(channel.channelUID, channel.dataType, valueAsDouble);
203             }
204         }
205         updateStatus(ThingStatus.ONLINE);
206     }
207
208     /**
209      * Update the channel to the given value.
210      * The value is set to the matching data (SITypes etc)
211      *
212      * @param channeluid Channel to update
213      * @param dataType target data type
214      * @param value value
215      */
216     private void updateChannelValue(String channeluid, ThirdGenerationChannelDatatypes dataType, Double value) {
217         switch (dataType) {
218             case INTEGER: {
219                 updateState(channeluid, new DecimalType(value.longValue()));
220                 break;
221             }
222             case PERCEMTAGE: {
223                 updateState(channeluid, new QuantityType<>(value, Units.PERCENT));
224                 break;
225             }
226             case KILOGRAM: {
227                 updateState(channeluid, new QuantityType<>(value / 1000, SIUnits.KILOGRAM));
228                 break;
229             }
230             case SECONDS: {
231                 updateState(channeluid, new QuantityType<>(value, Units.SECOND));
232                 break;
233             }
234             case KILOWATT_HOUR: {
235                 updateState(channeluid, new QuantityType<>(value / 1000, Units.KILOWATT_HOUR));
236                 break;
237             }
238             case WATT: {
239                 updateState(channeluid, new QuantityType<>(value, Units.WATT));
240                 break;
241             }
242             case AMPERE: {
243                 updateState(channeluid, new QuantityType<>(value, Units.AMPERE));
244                 break;
245             }
246             case AMPERE_HOUR: {
247                 // Ampere hours is not a supported unit, but 1 AH is equal tp 3600 coulomb...
248                 updateState(channeluid, new QuantityType<>(value * 3600, Units.COULOMB));
249                 break;
250             }
251             case VOLT: {
252                 updateState(channeluid, new QuantityType<>(value, Units.VOLT));
253                 break;
254             }
255             default: {
256                 // unknown datatype
257                 logger.debug("{} not known!", dataType);
258             }
259         }
260     }
261
262     /**
263      * Creates the message which has to be send to the inverter to gather the current informations for all channels
264      *
265      * @param channelList channels of this thing
266      * @return the JSON array to send to the device
267      */
268     private JsonArray getUpdateChannelMessage(Map<String, List<ThirdGenerationChannelMappingToWebApi>> channelList) {
269         // Build the message to send to the inverter
270         JsonArray updateMessageJsonArray = new JsonArray();
271         for (Entry<String, List<ThirdGenerationChannelMappingToWebApi>> moduleId : channelList.entrySet()) {
272             JsonObject moduleJsonObject = new JsonObject();
273             moduleJsonObject.addProperty("moduleid", moduleId.getKey());
274
275             JsonArray processdataNames = new JsonArray();
276             for (ThirdGenerationChannelMappingToWebApi processdata : channelList.get(moduleId.getKey())) {
277                 processdataNames.add(processdata.processdataId);
278             }
279             moduleJsonObject.add("processdataids", processdataNames);
280             updateMessageJsonArray.add(moduleJsonObject);
281         }
282         return updateMessageJsonArray;
283     }
284
285     /**
286      * This function is used to authenticate against the SCB.
287      * SCB uses PBKDF2 and AES256 GCM mode with a slightly modified authentication message.
288      * The authentication will fail on JRE < 8u162. since the security policy is set to "limited" by default (see readme
289      * for fix)
290      */
291     private final void authenticate() {
292         // Create random numbers
293         String clientNonce = ThirdGenerationEncryptionHelper.createClientNonce();
294         // Perform first step of authentication
295         JsonObject authMeJsonObject = new JsonObject();
296         authMeJsonObject.addProperty("username", USER_TYPE);
297         authMeJsonObject.addProperty("nonce", clientNonce);
298
299         ContentResponse authStartResponseContentResponse;
300         try {
301             authStartResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
302                     AUTH_START, authMeJsonObject);
303
304             // 200 is the desired status code
305             int statusCode = authStartResponseContentResponse.getStatus();
306
307             if (statusCode == 400) {
308                 // Invalid user (which is hard coded and therefore can not be wrong until the api is changed by the
309                 // manufacturer
310                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
311                         COMMUNICATION_ERROR_API_CHANGED);
312                 return;
313             }
314             if (statusCode == 403) {
315                 // User is logged
316                 // This can happen, if the user had to many bad attempts of entering the password in the web
317                 // front end
318                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
319                         COMMUNICATION_ERROR_USER_ACCOUNT_LOCKED);
320                 return;
321             }
322
323             if (statusCode == 503) {
324                 // internal communication error
325                 // This can happen if the device is not ready yet for communication
326                 updateStatus(ThingStatus.UNINITIALIZED);
327                 return;
328             }
329         } catch (InterruptedException | TimeoutException | ExecutionException e) {
330             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
331             return;
332         }
333         JsonObject authMeResponseJsonObject = ThirdGenerationHttpHelper
334                 .getJsonObjectFromResponse(authStartResponseContentResponse);
335
336         // Extract information from the response
337         String salt = authMeResponseJsonObject.get("salt").getAsString();
338         String serverNonce = authMeResponseJsonObject.get("nonce").getAsString();
339         int rounds = authMeResponseJsonObject.get("rounds").getAsInt();
340         String transactionId = authMeResponseJsonObject.get("transactionId").getAsString();
341
342         // Do the cryptography stuff (magic happens here)
343         byte[] saltedPasswort;
344         byte[] clientKey;
345         byte[] serverKey;
346         byte[] storedKey;
347         byte[] clientSignature;
348         byte[] serverSignature;
349         String authMessage;
350         try {
351             saltedPasswort = ThirdGenerationEncryptionHelper.getPBKDF2Hash(config.userPassword,
352                     Base64.getDecoder().decode(salt), rounds);
353             clientKey = ThirdGenerationEncryptionHelper.getHMACSha256(saltedPasswort, "Client Key");
354             serverKey = ThirdGenerationEncryptionHelper.getHMACSha256(saltedPasswort, "Server Key");
355             storedKey = ThirdGenerationEncryptionHelper.getSha256Hash(clientKey);
356             authMessage = String.format("n=%s,r=%s,r=%s,s=%s,i=%d,c=biws,r=%s", USER_TYPE, clientNonce, serverNonce,
357                     salt, rounds, serverNonce);
358             clientSignature = ThirdGenerationEncryptionHelper.getHMACSha256(storedKey, authMessage);
359             serverSignature = ThirdGenerationEncryptionHelper.getHMACSha256(serverKey, authMessage);
360         } catch (NoSuchAlgorithmException | InvalidKeySpecException | InvalidKeyException | IllegalStateException e2) {
361             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
362                     COMMUNICATION_ERROR_AUTHENTICATION);
363             return;
364         }
365         String clientProof = ThirdGenerationEncryptionHelper.createClientProof(clientSignature, clientKey);
366         // Perform step 2 of the authentication
367         JsonObject authFinishJsonObject = new JsonObject();
368         authFinishJsonObject.addProperty("transactionId", transactionId);
369         authFinishJsonObject.addProperty("proof", clientProof);
370
371         ContentResponse authFinishResponseContentResponse;
372         JsonObject authFinishResponseJsonObject;
373         try {
374             authFinishResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
375                     AUTH_FINISH, authFinishJsonObject);
376             authFinishResponseJsonObject = ThirdGenerationHttpHelper
377                     .getJsonObjectFromResponse(authFinishResponseContentResponse);
378             // 200 is the desired status code
379             if (authFinishResponseContentResponse.getStatus() == 400) {
380                 // Authentication failed
381                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
382                         CONFIGURATION_ERROR_PASSWORD);
383                 return;
384             }
385         } catch (InterruptedException | TimeoutException | ExecutionException e3) {
386             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
387             return;
388         }
389
390         // Extract information from the response
391         byte[] signature = Base64.getDecoder().decode(authFinishResponseJsonObject.get("signature").getAsString());
392         String token = authFinishResponseJsonObject.get("token").getAsString();
393
394         // Validate provided signature against calculated signature
395         if (!java.util.Arrays.equals(serverSignature, signature)) {
396             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
397                     COMMUNICATION_ERROR_AUTHENTICATION);
398             return;
399         }
400
401         // Calculate protocol key
402         SecretKeySpec signingKey = new SecretKeySpec(storedKey, HMAC_SHA256_ALGORITHM);
403         Mac mac;
404         byte[] protocolKeyHMAC;
405         try {
406             mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
407             mac.init(signingKey);
408             mac.update("Session Key".getBytes());
409             mac.update(authMessage.getBytes());
410             mac.update(clientKey);
411             protocolKeyHMAC = mac.doFinal();
412         } catch (NoSuchAlgorithmException | InvalidKeyException e1) {
413             // Since the necessary libraries are provided, this should not happen
414             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
415                     COMMUNICATION_ERROR_AUTHENTICATION);
416             return;
417         }
418
419         byte[] data;
420         byte[] iv;
421
422         // AES GCM stuff
423         iv = new byte[16];
424
425         new SecureRandom().nextBytes(iv);
426
427         SecretKeySpec skeySpec = new SecretKeySpec(protocolKeyHMAC, "AES");
428         GCMParameterSpec param = new GCMParameterSpec(protocolKeyHMAC.length * 8 - AES_GCM_TAG_LENGTH, iv);
429
430         Cipher cipher;
431         try {
432             cipher = Cipher.getInstance("AES_256/GCM/NOPADDING");
433             cipher.init(Cipher.ENCRYPT_MODE, skeySpec, param);
434         } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
435                 | InvalidAlgorithmParameterException e1) {
436             // The java installation does not support AES encryption in GCM mode
437             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
438                     COMMUNICATION_ERROR_AUTHENTICATION);
439             return;
440         }
441         try {
442             data = cipher.doFinal(token.getBytes("UTF-8"));
443         } catch (IllegalBlockSizeException | BadPaddingException | UnsupportedEncodingException e1) {
444             // No JSON answer received
445             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_JSON);
446             return;
447         }
448
449         byte[] ciphertext = new byte[data.length - AES_GCM_TAG_LENGTH / 8];
450         byte[] gcmTag = new byte[AES_GCM_TAG_LENGTH / 8];
451         System.arraycopy(data, 0, ciphertext, 0, data.length - AES_GCM_TAG_LENGTH / 8);
452         System.arraycopy(data, data.length - AES_GCM_TAG_LENGTH / 8, gcmTag, 0, AES_GCM_TAG_LENGTH / 8);
453
454         JsonObject createSessionJsonObject = new JsonObject();
455         createSessionJsonObject.addProperty("transactionId", transactionId);
456         createSessionJsonObject.addProperty("iv", Base64.getEncoder().encodeToString(iv));
457         createSessionJsonObject.addProperty("tag", Base64.getEncoder().encodeToString(gcmTag));
458         createSessionJsonObject.addProperty("payload", Base64.getEncoder().encodeToString(ciphertext));
459
460         // finally create the session for further communication
461         ContentResponse createSessionResponseContentResponse;
462         JsonObject createSessionResponseJsonObject;
463         try {
464             createSessionResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
465                     AUTH_CREATE_SESSION, createSessionJsonObject);
466             createSessionResponseJsonObject = ThirdGenerationHttpHelper
467                     .getJsonObjectFromResponse(createSessionResponseContentResponse);
468         } catch (InterruptedException | TimeoutException | ExecutionException e) {
469             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
470                     COMMUNICATION_ERROR_AUTHENTICATION);
471             return;
472         }
473         // 200 is the desired status code
474         if (createSessionResponseContentResponse.getStatus() == 400) {
475             // Authentication failed
476             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
477                     CONFIGURATION_ERROR_PASSWORD);
478             return;
479         }
480
481         sessionId = createSessionResponseJsonObject.get("sessionId").getAsString();
482
483         updateStatus(ThingStatus.ONLINE);
484     }
485 }