2 * Copyright (c) 2010-2020 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.internal.kostal.inverter.thirdgeneration;
15 import static org.openhab.binding.internal.kostal.inverter.thirdgeneration.ThirdGenerationBindingConstants.*;
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;
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.SmartHomeUnits;
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, SmartHomeUnits.PERCENT));
227 updateState(channeluid, new QuantityType<>(value / 1000, SIUnits.KILOGRAM));
231 updateState(channeluid, new QuantityType<>(value, SmartHomeUnits.SECOND));
234 case KILOWATT_HOUR: {
235 updateState(channeluid, new QuantityType<>(value / 1000, SmartHomeUnits.KILOWATT_HOUR));
239 updateState(channeluid, new QuantityType<>(value, SmartHomeUnits.WATT));
243 updateState(channeluid, new QuantityType<>(value, SmartHomeUnits.AMPERE));
247 // Ampere hours are not supported by ESH, but 1 AH is equal tp 3600 coulomb...
248 updateState(channeluid, new QuantityType<>(value * 3600, SmartHomeUnits.COULOMB));
252 updateState(channeluid, new QuantityType<>(value, SmartHomeUnits.VOLT));
257 logger.debug("{} not known!", dataType);
263 * Creates the message which has to be send to the inverter to gather the current informations for all channels
265 * @param channelList channels of this thing
266 * @return the JSON array to send to the device
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());
275 JsonArray processdataNames = new JsonArray();
276 for (ThirdGenerationChannelMappingToWebApi processdata : channelList.get(moduleId.getKey())) {
277 processdataNames.add(processdata.processdataId);
279 moduleJsonObject.add("processdataids", processdataNames);
280 updateMessageJsonArray.add(moduleJsonObject);
282 return updateMessageJsonArray;
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
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);
299 ContentResponse authStartResponseContentResponse;
301 authStartResponseContentResponse = ThirdGenerationHttpHelper.executeHttpPost(httpClient, config.url,
302 AUTH_START, authMeJsonObject);
304 // 200 is the desired status code
305 int statusCode = authStartResponseContentResponse.getStatus();
307 if (statusCode == 400) {
308 // Invalid user (which is hard coded and therefore can not be wrong until the api is changed by the
310 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
311 COMMUNICATION_ERROR_API_CHANGED);
314 if (statusCode == 403) {
316 // This can happen, if the user had to many bad attempts of entering the password in the web
318 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
319 COMMUNICATION_ERROR_USER_ACCOUNT_LOCKED);
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);
329 } catch (InterruptedException | TimeoutException | ExecutionException e) {
330 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
333 JsonObject authMeResponseJsonObject = ThirdGenerationHttpHelper
334 .getJsonObjectFromResponse(authStartResponseContentResponse);
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();
342 // Do the cryptography stuff (magic happens here)
343 byte[] saltedPasswort;
347 byte[] clientSignature;
348 byte[] serverSignature;
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);
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);
371 ContentResponse authFinishResponseContentResponse;
372 JsonObject authFinishResponseJsonObject;
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);
385 } catch (InterruptedException | TimeoutException | ExecutionException e3) {
386 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, COMMUNICATION_ERROR_HTTP);
390 // Extract information from the response
391 byte[] signature = Base64.getDecoder().decode(authFinishResponseJsonObject.get("signature").getAsString());
392 String token = authFinishResponseJsonObject.get("token").getAsString();
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);
401 // Calculate protocol key
402 SecretKeySpec signingKey = new SecretKeySpec(storedKey, HMAC_SHA256_ALGORITHM);
404 byte[] protocolKeyHMAC;
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);
425 new SecureRandom().nextBytes(iv);
427 SecretKeySpec skeySpec = new SecretKeySpec(protocolKeyHMAC, "AES");
428 GCMParameterSpec param = new GCMParameterSpec(protocolKeyHMAC.length * 8 - AES_GCM_TAG_LENGTH, iv);
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);
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);
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);
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));
460 // finally create the session for further communication
461 ContentResponse createSessionResponseContentResponse;
462 JsonObject createSessionResponseJsonObject;
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);
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);
481 sessionId = createSessionResponseJsonObject.get("sessionId").getAsString();
483 updateStatus(ThingStatus.ONLINE);