2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.kostalinverter.internal.thirdgeneration;
15 import static org.openhab.binding.kostalinverter.internal.thirdgeneration.ThirdGenerationBindingConstants.*;
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;
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;
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;
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;
57 import com.google.gson.JsonArray;
58 import com.google.gson.JsonObject;
61 * The {@link ThirdGenerationHandler} is responsible for handling commands, which are
62 * sent to one of the channels.
64 * @author René Stakemeier - Initial contribution
67 public class ThirdGenerationHandler extends BaseThingHandler {
70 * operations used for authentication
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";
77 * operations used for gathering process data from the device
79 private static final String PROCESSDATA = "/processdata";
82 * After the authentication the result (the session id) is stored here and used to "sign" future requests
84 private @Nullable String sessionId;
86 * The configuration file containing the host, the password and the refresh interval
88 private @NonNullByDefault({}) ThirdGenerationConfiguration config;
90 private @Nullable ScheduledFuture<?> refreshScheduler;
92 private @Nullable HttpClient httpClient;
94 private ThirdGenerationInverterTypes inverterType;
96 private final Logger logger = LoggerFactory.getLogger(this.getClass());
99 * Constructor of this class
101 * @param thing the thing
102 * @param httpClient the httpClient used for communication
103 * @param inverterType the type of the device
105 public ThirdGenerationHandler(Thing thing, HttpClient httpClient, ThirdGenerationInverterTypes inverterType) {
107 this.inverterType = inverterType;
108 this.httpClient = httpClient;
112 public void handleCommand(ChannelUID channelUID, Command command) {
113 // All channels are readonly and updated by the scheduler
117 public void dispose() {
118 if (refreshScheduler != null) {
119 refreshScheduler.cancel(true);
120 refreshScheduler = null;
126 public void initialize() {
127 config = getConfigAs(ThirdGenerationConfiguration.class);
128 // temporary value while initializing
129 updateStatus(ThingStatus.UNKNOWN);
131 // Start the authentication
132 scheduler.schedule(this::authenticate, 1, TimeUnit.SECONDS);
134 // Start the update scheduler as configured
135 refreshScheduler = scheduler.scheduleWithFixedDelay(this::updateChannelValues, 10,
136 config.refreshInternalInSeconds, TimeUnit.SECONDS);
140 * The API supports the resolution of multiple values at a time
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
147 private void updateChannelValues() {
148 Map<String, List<ThirdGenerationChannelMappingToWebApi>> channelList = ThirdGenerationMappingInverterToChannel
149 .getModuleToChannelsMappingForInverter(inverterType);
150 JsonArray updateMessageJsonArray = getUpdateChannelMessage(channelList);
152 // Send the API request to get values for all channels
153 ContentResponse updateMessageContentResponse;
155 updateMessageContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
156 PROCESSDATA, updateMessageJsonArray, sessionId);
157 if (updateMessageContentResponse.getStatus() == 404) {
159 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
160 COMMUNICATION_ERROR_INCOMPATIBLE_DEVICE);
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);
169 if (updateMessageContentResponse.getStatus() == 401) {
170 // session not valid (timed out? device rebooted?)
171 logger.info("Session expired - performing retry");
174 updateMessageContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
175 PROCESSDATA, updateMessageJsonArray, sessionId);
177 } catch (TimeoutException | ExecutionException e) {
178 // Communication problem
179 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
181 } catch (InterruptedException e) {
182 Thread.currentThread().interrupt();
183 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
186 JsonArray updateMessageResultsJsonArray = ThirdGenerationHttpHelper
187 .getJsonArrayFromResponse(updateMessageContentResponse);
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()
202 updateChannelValue(channel.channelUID, channel.dataType, valueAsDouble);
205 updateStatus(ThingStatus.ONLINE);
209 * Update the channel to the given value.
210 * The value is set to the matching data (SITypes etc)
212 * @param channeluid Channel to update
213 * @param dataType target data type
216 private void updateChannelValue(String channeluid, ThirdGenerationChannelDatatypes dataType, Double value) {
219 updateState(channeluid, new DecimalType(value.longValue()));
223 updateState(channeluid, new QuantityType<>(value, Units.PERCENT));
227 updateState(channeluid, new QuantityType<>(value / 1000, SIUnits.KILOGRAM));
231 updateState(channeluid, new QuantityType<>(value, Units.SECOND));
234 case KILOWATT_HOUR: {
235 updateState(channeluid, new QuantityType<>(value / 1000, Units.KILOWATT_HOUR));
239 updateState(channeluid, new QuantityType<>(value, Units.WATT));
243 updateState(channeluid, new QuantityType<>(value, Units.AMPERE));
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));
252 updateState(channeluid, new QuantityType<>(value, Units.VOLT));
256 updateState(channeluid, new QuantityType<>(value, Units.HERTZ));
261 logger.debug("{} not known!", dataType);
267 * Creates the message which has to be send to the inverter to gather the current informations for all channels
269 * @param channelList channels of this thing
270 * @return the JSON array to send to the device
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());
279 JsonArray processdataNames = new JsonArray();
280 for (ThirdGenerationChannelMappingToWebApi processdata : channelList.get(moduleId.getKey())) {
281 processdataNames.add(processdata.processdataId);
283 moduleJsonObject.add("processdataids", processdataNames);
284 updateMessageJsonArray.add(moduleJsonObject);
286 return updateMessageJsonArray;
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
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);
303 ContentResponse authStartResponseContentResponse;
305 authStartResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
306 AUTH_START, authMeJsonObject);
308 // 200 is the desired status code
309 int statusCode = authStartResponseContentResponse.getStatus();
311 if (statusCode == 400) {
312 // Invalid user (which is hard coded and therefore can not be wrong until the api is changed by the
314 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
315 COMMUNICATION_ERROR_API_CHANGED);
318 if (statusCode == 403) {
320 // This can happen, if the user had to many bad attempts of entering the password in the web
322 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
323 COMMUNICATION_ERROR_USER_ACCOUNT_LOCKED);
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);
333 } catch (InterruptedException | TimeoutException | ExecutionException e) {
334 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
337 JsonObject authMeResponseJsonObject = ThirdGenerationHttpHelper
338 .getJsonObjectFromResponse(authStartResponseContentResponse);
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();
346 // Do the cryptography stuff (magic happens here)
347 byte[] saltedPasswort;
351 byte[] clientSignature;
352 byte[] serverSignature;
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);
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);
375 ContentResponse authFinishResponseContentResponse;
376 JsonObject authFinishResponseJsonObject;
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);
389 } catch (InterruptedException | TimeoutException | ExecutionException e3) {
390 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
394 // Extract information from the response
395 byte[] signature = Base64.getDecoder().decode(authFinishResponseJsonObject.get("signature").getAsString());
396 String token = authFinishResponseJsonObject.get("token").getAsString();
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);
405 // Calculate protocol key
406 SecretKeySpec signingKey = new SecretKeySpec(storedKey, HMAC_SHA256_ALGORITHM);
408 byte[] protocolKeyHMAC;
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);
429 new SecureRandom().nextBytes(iv);
431 SecretKeySpec skeySpec = new SecretKeySpec(protocolKeyHMAC, "AES");
432 GCMParameterSpec param = new GCMParameterSpec(protocolKeyHMAC.length * 8 - AES_GCM_TAG_LENGTH, iv);
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);
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);
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);
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));
464 // finally create the session for further communication
465 ContentResponse createSessionResponseContentResponse;
466 JsonObject createSessionResponseJsonObject;
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);
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);
485 sessionId = createSessionResponseJsonObject.get("sessionId").getAsString();
487 updateStatus(ThingStatus.ONLINE);