]> git.basschouten.com Git - openhab-addons.git/blob
7d66418e8a25278a554f4a02a5b995501351c3b6
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.nio.charset.StandardCharsets;
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             case HERTZ: {
256                 updateState(channeluid, new QuantityType<>(value, Units.HERTZ));
257                 break;
258             }
259             default: {
260                 // unknown datatype
261                 logger.debug("{} not known!", dataType);
262             }
263         }
264     }
265
266     /**
267      * Creates the message which has to be send to the inverter to gather the current informations for all channels
268      *
269      * @param channelList channels of this thing
270      * @return the JSON array to send to the device
271      */
272     private JsonArray getUpdateChannelMessage(Map<String, List<ThirdGenerationChannelMappingToWebApi>> channelList) {
273         // Build the message to send to the inverter
274         JsonArray updateMessageJsonArray = new JsonArray();
275         for (Entry<String, List<ThirdGenerationChannelMappingToWebApi>> moduleId : channelList.entrySet()) {
276             JsonObject moduleJsonObject = new JsonObject();
277             moduleJsonObject.addProperty("moduleid", moduleId.getKey());
278
279             JsonArray processdataNames = new JsonArray();
280             for (ThirdGenerationChannelMappingToWebApi processdata : channelList.get(moduleId.getKey())) {
281                 processdataNames.add(processdata.processdataId);
282             }
283             moduleJsonObject.add("processdataids", processdataNames);
284             updateMessageJsonArray.add(moduleJsonObject);
285         }
286         return updateMessageJsonArray;
287     }
288
289     /**
290      * This function is used to authenticate against the SCB.
291      * SCB uses PBKDF2 and AES256 GCM mode with a slightly modified authentication message.
292      * The authentication will fail on JRE < 8u162. since the security policy is set to "limited" by default (see readme
293      * for fix)
294      */
295     private final void authenticate() {
296         // Create random numbers
297         String clientNonce = ThirdGenerationEncryptionHelper.createClientNonce();
298         // Perform first step of authentication
299         JsonObject authMeJsonObject = new JsonObject();
300         authMeJsonObject.addProperty("username", USER_TYPE);
301         authMeJsonObject.addProperty("nonce", clientNonce);
302
303         ContentResponse authStartResponseContentResponse;
304         try {
305             authStartResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
306                     AUTH_START, authMeJsonObject);
307
308             // 200 is the desired status code
309             int statusCode = authStartResponseContentResponse.getStatus();
310
311             if (statusCode == 400) {
312                 // Invalid user (which is hard coded and therefore can not be wrong until the api is changed by the
313                 // manufacturer
314                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
315                         COMMUNICATION_ERROR_API_CHANGED);
316                 return;
317             }
318             if (statusCode == 403) {
319                 // User is logged
320                 // This can happen, if the user had to many bad attempts of entering the password in the web
321                 // front end
322                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
323                         COMMUNICATION_ERROR_USER_ACCOUNT_LOCKED);
324                 return;
325             }
326
327             if (statusCode == 503) {
328                 // internal communication error
329                 // This can happen if the device is not ready yet for communication
330                 updateStatus(ThingStatus.UNINITIALIZED);
331                 return;
332             }
333         } catch (InterruptedException | TimeoutException | ExecutionException e) {
334             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
335             return;
336         }
337         JsonObject authMeResponseJsonObject = ThirdGenerationHttpHelper
338                 .getJsonObjectFromResponse(authStartResponseContentResponse);
339
340         // Extract information from the response
341         String salt = authMeResponseJsonObject.get("salt").getAsString();
342         String serverNonce = authMeResponseJsonObject.get("nonce").getAsString();
343         int rounds = authMeResponseJsonObject.get("rounds").getAsInt();
344         String transactionId = authMeResponseJsonObject.get("transactionId").getAsString();
345
346         // Do the cryptography stuff (magic happens here)
347         byte[] saltedPasswort;
348         byte[] clientKey;
349         byte[] serverKey;
350         byte[] storedKey;
351         byte[] clientSignature;
352         byte[] serverSignature;
353         String authMessage;
354         try {
355             saltedPasswort = ThirdGenerationEncryptionHelper.getPBKDF2Hash(config.userPassword,
356                     Base64.getDecoder().decode(salt), rounds);
357             clientKey = ThirdGenerationEncryptionHelper.getHMACSha256(saltedPasswort, "Client Key");
358             serverKey = ThirdGenerationEncryptionHelper.getHMACSha256(saltedPasswort, "Server Key");
359             storedKey = ThirdGenerationEncryptionHelper.getSha256Hash(clientKey);
360             authMessage = String.format("n=%s,r=%s,r=%s,s=%s,i=%d,c=biws,r=%s", USER_TYPE, clientNonce, serverNonce,
361                     salt, rounds, serverNonce);
362             clientSignature = ThirdGenerationEncryptionHelper.getHMACSha256(storedKey, authMessage);
363             serverSignature = ThirdGenerationEncryptionHelper.getHMACSha256(serverKey, authMessage);
364         } catch (NoSuchAlgorithmException | InvalidKeySpecException | InvalidKeyException | IllegalStateException e2) {
365             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
366                     COMMUNICATION_ERROR_AUTHENTICATION);
367             return;
368         }
369         String clientProof = ThirdGenerationEncryptionHelper.createClientProof(clientSignature, clientKey);
370         // Perform step 2 of the authentication
371         JsonObject authFinishJsonObject = new JsonObject();
372         authFinishJsonObject.addProperty("transactionId", transactionId);
373         authFinishJsonObject.addProperty("proof", clientProof);
374
375         ContentResponse authFinishResponseContentResponse;
376         JsonObject authFinishResponseJsonObject;
377         try {
378             authFinishResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
379                     AUTH_FINISH, authFinishJsonObject);
380             authFinishResponseJsonObject = ThirdGenerationHttpHelper
381                     .getJsonObjectFromResponse(authFinishResponseContentResponse);
382             // 200 is the desired status code
383             if (authFinishResponseContentResponse.getStatus() == 400) {
384                 // Authentication failed
385                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
386                         CONFIGURATION_ERROR_PASSWORD);
387                 return;
388             }
389         } catch (InterruptedException | TimeoutException | ExecutionException e3) {
390             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
391             return;
392         }
393
394         // Extract information from the response
395         byte[] signature = Base64.getDecoder().decode(authFinishResponseJsonObject.get("signature").getAsString());
396         String token = authFinishResponseJsonObject.get("token").getAsString();
397
398         // Validate provided signature against calculated signature
399         if (!java.util.Arrays.equals(serverSignature, signature)) {
400             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
401                     COMMUNICATION_ERROR_AUTHENTICATION);
402             return;
403         }
404
405         // Calculate protocol key
406         SecretKeySpec signingKey = new SecretKeySpec(storedKey, HMAC_SHA256_ALGORITHM);
407         Mac mac;
408         byte[] protocolKeyHMAC;
409         try {
410             mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
411             mac.init(signingKey);
412             mac.update("Session Key".getBytes());
413             mac.update(authMessage.getBytes());
414             mac.update(clientKey);
415             protocolKeyHMAC = mac.doFinal();
416         } catch (NoSuchAlgorithmException | InvalidKeyException e1) {
417             // Since the necessary libraries are provided, this should not happen
418             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
419                     COMMUNICATION_ERROR_AUTHENTICATION);
420             return;
421         }
422
423         byte[] data;
424         byte[] iv;
425
426         // AES GCM stuff
427         iv = new byte[16];
428
429         new SecureRandom().nextBytes(iv);
430
431         SecretKeySpec skeySpec = new SecretKeySpec(protocolKeyHMAC, "AES");
432         GCMParameterSpec param = new GCMParameterSpec(protocolKeyHMAC.length * 8 - AES_GCM_TAG_LENGTH, iv);
433
434         Cipher cipher;
435         try {
436             cipher = Cipher.getInstance("AES_256/GCM/NOPADDING");
437             cipher.init(Cipher.ENCRYPT_MODE, skeySpec, param);
438         } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
439                 | InvalidAlgorithmParameterException e1) {
440             // The java installation does not support AES encryption in GCM mode
441             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
442                     COMMUNICATION_ERROR_AUTHENTICATION);
443             return;
444         }
445         try {
446             data = cipher.doFinal(token.getBytes(StandardCharsets.UTF_8));
447         } catch (IllegalBlockSizeException | BadPaddingException e1) {
448             // No JSON answer received
449             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_JSON);
450             return;
451         }
452
453         byte[] ciphertext = new byte[data.length - AES_GCM_TAG_LENGTH / 8];
454         byte[] gcmTag = new byte[AES_GCM_TAG_LENGTH / 8];
455         System.arraycopy(data, 0, ciphertext, 0, data.length - AES_GCM_TAG_LENGTH / 8);
456         System.arraycopy(data, data.length - AES_GCM_TAG_LENGTH / 8, gcmTag, 0, AES_GCM_TAG_LENGTH / 8);
457
458         JsonObject createSessionJsonObject = new JsonObject();
459         createSessionJsonObject.addProperty("transactionId", transactionId);
460         createSessionJsonObject.addProperty("iv", Base64.getEncoder().encodeToString(iv));
461         createSessionJsonObject.addProperty("tag", Base64.getEncoder().encodeToString(gcmTag));
462         createSessionJsonObject.addProperty("payload", Base64.getEncoder().encodeToString(ciphertext));
463
464         // finally create the session for further communication
465         ContentResponse createSessionResponseContentResponse;
466         JsonObject createSessionResponseJsonObject;
467         try {
468             createSessionResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
469                     AUTH_CREATE_SESSION, createSessionJsonObject);
470             createSessionResponseJsonObject = ThirdGenerationHttpHelper
471                     .getJsonObjectFromResponse(createSessionResponseContentResponse);
472         } catch (InterruptedException | TimeoutException | ExecutionException e) {
473             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
474                     COMMUNICATION_ERROR_AUTHENTICATION);
475             return;
476         }
477         // 200 is the desired status code
478         if (createSessionResponseContentResponse.getStatus() == 400) {
479             // Authentication failed
480             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
481                     CONFIGURATION_ERROR_PASSWORD);
482             return;
483         }
484
485         sessionId = createSessionResponseJsonObject.get("sessionId").getAsString();
486
487         updateStatus(ThingStatus.ONLINE);
488     }
489 }