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.wolfsmartset.internal.api;
15 import java.net.URLEncoder;
16 import java.nio.charset.StandardCharsets;
17 import java.time.Instant;
18 import java.time.LocalDateTime;
19 import java.time.format.DateTimeFormatter;
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.HashMap;
23 import java.util.List;
25 import java.util.Map.Entry;
26 import java.util.concurrent.CancellationException;
27 import java.util.concurrent.CompletableFuture;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.LinkedBlockingQueue;
30 import java.util.concurrent.RejectedExecutionException;
31 import java.util.concurrent.ScheduledExecutorService;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
34 import java.util.concurrent.TimeoutException;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.client.HttpResponseException;
40 import org.eclipse.jetty.client.api.ContentResponse;
41 import org.eclipse.jetty.client.api.Request;
42 import org.eclipse.jetty.client.util.StringContentProvider;
43 import org.eclipse.jetty.http.HttpHeader;
44 import org.eclipse.jetty.http.HttpMethod;
45 import org.eclipse.jetty.http.HttpStatus;
46 import org.openhab.binding.wolfsmartset.internal.dto.CreateSession2DTO;
47 import org.openhab.binding.wolfsmartset.internal.dto.GetGuiDescriptionForGatewayDTO;
48 import org.openhab.binding.wolfsmartset.internal.dto.GetParameterValuesDTO;
49 import org.openhab.binding.wolfsmartset.internal.dto.GetSystemListDTO;
50 import org.openhab.binding.wolfsmartset.internal.dto.GetSystemStateListDTO;
51 import org.openhab.binding.wolfsmartset.internal.dto.LoginResponseDTO;
52 import org.openhab.binding.wolfsmartset.internal.dto.ReadFaultMessagesDTO;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
56 import com.google.gson.Gson;
57 import com.google.gson.GsonBuilder;
58 import com.google.gson.JsonArray;
59 import com.google.gson.JsonElement;
60 import com.google.gson.JsonObject;
61 import com.google.gson.JsonParseException;
62 import com.google.gson.JsonSyntaxException;
65 * The {@link WolfSmartsetCloudConnector} class is used for connecting to the Wolf Smartset cloud service
67 * @author Bo Biene - Initial contribution
70 public class WolfSmartsetApi {
71 private static final int MAX_QUEUE_SIZE = 1000; // maximum queue size
72 private static final int REQUEST_TIMEOUT_SECONDS = 10;
74 private static final DateTimeFormatter SESSION_TIME_STAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
75 private static final String WOLF_API_URL = "https://www.wolf-smartset.com/portal/";
77 private Instant blockRequestsUntil = Instant.now();
78 private String username;
79 private String password;
80 private String serviceToken = "";
81 private @Nullable CreateSession2DTO session = null;
82 private int loginFailedCounter = 0;
83 private HttpClient httpClient;
84 private int delay = 500; // in ms
85 private final ScheduledExecutorService scheduler;
86 private final Gson gson = new GsonBuilder().serializeNulls().create();
87 private final LinkedBlockingQueue<RequestQueueEntry> requestQueue = new LinkedBlockingQueue<>(MAX_QUEUE_SIZE);
88 private @Nullable ScheduledFuture<?> processJob;
90 private final Logger logger = LoggerFactory.getLogger(WolfSmartsetApi.class);
92 public WolfSmartsetApi(String username, String password, HttpClient httpClient, ScheduledExecutorService scheduler)
93 throws WolfSmartsetCloudException {
94 this.username = username;
95 this.password = password;
96 this.httpClient = httpClient;
97 this.scheduler = scheduler;
98 if (!checkCredentials()) {
99 throw new WolfSmartsetCloudException("username or password can't be empty");
104 * Validate Login to wolf smartset. Returns true if valid token is available, otherwise tries to authenticate with
105 * wolf smartset portal
107 public synchronized boolean login() {
108 if (!checkCredentials()) {
111 if (!serviceToken.isEmpty()) {
114 logger.debug("Wolf Smartset login with username {}", username);
117 loginFailedCounter = 0;
118 this.session = getCreateSession();
119 if (this.session != null) {
120 logger.debug("login successful, browserSessionId {}", session.getBrowserSessionId());
123 loginFailedCounter++;
125 logger.trace("Login succeeded but failed to create session {}", loginFailedCounter);
129 } catch (WolfSmartsetCloudException e) {
130 logger.debug("Error logging on to Wolf Smartset ({}): {}", loginFailedCounter, e.getMessage());
131 loginFailedCounter++;
133 loginFailedCounterCheck();
139 * Request the systems available for the authenticated account
141 * @return a list of the available systems
143 public List<GetSystemListDTO> getSystems() {
144 final String response = getSystemString();
145 List<GetSystemListDTO> devicesList = new ArrayList<>();
147 GetSystemListDTO[] cdl = gson.fromJson(response, GetSystemListDTO[].class);
149 for (GetSystemListDTO system : cdl) {
150 devicesList.add(system);
153 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
154 loginFailedCounter++;
155 logger.warn("Error while parsing devices: {}", e.getMessage());
161 * Request the description of the given system
163 * @param systemId the id of the system
164 * @param gatewayId the id of the gateway the system relates to
165 * @return dto describing the requested system
167 public @Nullable GetGuiDescriptionForGatewayDTO getSystemDescription(Integer systemId, Integer gatewayId) {
168 final String response = getSystemDescriptionString(systemId, gatewayId);
169 GetGuiDescriptionForGatewayDTO deviceDescription = null;
171 deviceDescription = gson.fromJson(response, GetGuiDescriptionForGatewayDTO.class);
172 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
173 loginFailedCounter++;
174 logger.warn("Error while parsing device descriptions: {}", e.getMessage());
176 return deviceDescription;
180 * Request the system state of the given systems
182 * @param systems a list of {@link GetSystemListDTO}
183 * @return the {@link GetSystemStateListDTO} descibing the state of the given {@link GetSystemListDTO} items
185 public @Nullable GetSystemStateListDTO @Nullable [] getSystemState(Collection<@Nullable GetSystemListDTO> systems) {
186 final String response = getSystemStateString(systems);
187 GetSystemStateListDTO[] systemState = null;
189 systemState = gson.fromJson(response, GetSystemStateListDTO[].class);
190 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
191 loginFailedCounter++;
192 logger.warn("Error while parsing device descriptions: {}", e.getMessage());
194 if (systemState != null && systemState.length >= 1) {
202 * Request the fault messages of the given system
204 * @param systemId the id of the system
205 * @param gatewayId the id of the gateway the system relates to
206 * @return {@link ReadFaultMessagesDTO} containing the faultmessages
208 public @Nullable ReadFaultMessagesDTO getFaultMessages(Integer systemId, Integer gatewayId) {
209 final String response = getFaultMessagesString(systemId, gatewayId);
210 ReadFaultMessagesDTO faultMessages = null;
212 faultMessages = gson.fromJson(response, ReadFaultMessagesDTO.class);
213 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
214 loginFailedCounter++;
215 logger.warn("Error while parsing faultmessages: {}", e.getMessage());
217 return faultMessages;
221 * Request the current values for a unit associated with the given system.
222 * if lastAccess is not null, only value changes newer than the given timestamp are returned
224 * @param systemId the id of the system
225 * @param gatewayId the id of the gateway the system relates to
226 * @param bundleId the id of the Unit
227 * @param valueIdList list of the values to request
228 * @param lastAccess timestamp of the last valid value request
229 * @return {@link GetParameterValuesDTO} containing the requested values
231 public @Nullable GetParameterValuesDTO getGetParameterValues(Integer systemId, Integer gatewayId, Long bundleId,
232 List<Long> valueIdList, @Nullable Instant lastAccess) {
233 final String response = getGetParameterValuesString(systemId, gatewayId, bundleId, valueIdList, lastAccess);
234 GetParameterValuesDTO parameterValues = null;
236 parameterValues = gson.fromJson(response, GetParameterValuesDTO.class);
237 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
238 loginFailedCounter++;
239 logger.warn("Error while parsing device parameter values: {}", e.getMessage());
241 return parameterValues;
244 public void stopRequestQueue() {
247 requestQueue.forEach(queueEntry -> queueEntry.future.completeExceptionally(new CancellationException()));
248 } catch (Exception e) {
249 logger.debug("Error stopping request queue background processing:{}", e.getMessage(), e);
256 * @param delay in ms between to requests
258 private void setDelay(int delay) {
260 throw new IllegalArgumentException("Delay needs to be larger or equal to zero");
265 processJob = scheduler.scheduleWithFixedDelay(() -> processQueue(), 0, delay, TimeUnit.MILLISECONDS);
269 private boolean checkCredentials() {
270 if (username.trim().isEmpty() || password.trim().isEmpty()) {
271 logger.debug("Wolf Smartset: username or password missing.");
277 private String getCreateSessionString() {
280 JsonObject json = new JsonObject();
281 json.addProperty("Timestamp", SESSION_TIME_STAMP.format(LocalDateTime.now()));
282 resp = requestPOST("api/portal/CreateSession2", json).get();
283 logger.trace("api/portal/CreateSession2 response: {}", resp);
284 } catch (InterruptedException | ExecutionException e) {
285 logger.warn("getSystemStateString failed with {}: {}", e.getCause(), e.getMessage());
286 loginFailedCounter++;
287 } catch (WolfSmartsetCloudException e) {
288 logger.debug("getSystemStateString failed with {}: {}", e.getCause(), e.getMessage());
289 loginFailedCounter++;
295 private @Nullable CreateSession2DTO getCreateSession() {
296 final String response = getCreateSessionString();
297 CreateSession2DTO session = null;
299 session = gson.fromJson(response, CreateSession2DTO.class);
300 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
301 loginFailedCounter++;
302 logger.warn("getCreateSession failed with {}: {}", e.getCause(), e.getMessage());
307 private String getSystemString() {
310 resp = requestGET("api/portal/GetSystemList").get();
311 logger.trace("api/portal/GetSystemList response: {}", resp);
312 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
313 logger.warn("getSystemString failed with {}: {}", e.getCause(), e.getMessage());
314 loginFailedCounter++;
319 private String getSystemDescriptionString(Integer systemId, Integer gatewayId) {
322 Map<String, String> params = new HashMap<String, String>();
323 params.put("SystemId", systemId.toString());
324 params.put("GatewayId", gatewayId.toString());
325 resp = requestGET("api/portal/GetGuiDescriptionForGateway", params).get();
326 logger.trace("api/portal/GetGuiDescriptionForGateway response: {}", resp);
327 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
328 logger.warn("getSystemDescriptionString failed with {}: {}", e.getCause(), e.getMessage());
329 loginFailedCounter++;
334 private String getSystemStateString(Collection<@Nullable GetSystemListDTO> systems) {
337 JsonArray jsonSystemList = new JsonArray();
340 GetSystemListDTO system : systems) {
341 if (system != null) {
342 JsonObject jsonSystem = new JsonObject();
343 jsonSystem.addProperty("SystemId", system.getId());
344 jsonSystem.addProperty("GatewayId", system.getGatewayId());
346 if (system.getSystemShareId() != null) {
347 jsonSystem.addProperty("SystemShareId", system.getSystemShareId());
349 jsonSystemList.add(jsonSystem);
353 JsonObject json = new JsonObject();
355 json.add("SystemList", jsonSystemList);
356 resp = requestPOST("api/portal/GetSystemStateList", json).get();
357 logger.trace("api/portal/GetSystemStateList response: {}", resp);
358 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
359 logger.warn("getSystemStateString failed with {}: {}", e.getCause(), e.getMessage());
360 loginFailedCounter++;
365 private String getFaultMessagesString(Integer systemId, Integer gatewayId) {
368 JsonObject json = new JsonObject();
370 json.addProperty("SystemId", systemId);
371 json.addProperty("GatewayId", gatewayId);
372 resp = requestPOST("api/portal/ReadFaultMessages", json).get();
373 logger.trace("api/portal/ReadFaultMessages response: {}", resp);
374 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
375 logger.warn("getFaultMessagesString failed with {}: {}", e.getCause(), e.getMessage());
376 loginFailedCounter++;
381 private String getGetParameterValuesString(Integer systemId, Integer gatewayId, Long bundleId,
382 List<Long> valueIdList, @Nullable Instant lastAccess) {
385 JsonObject json = new JsonObject();
386 json.addProperty("SystemId", systemId);
387 json.addProperty("GatewayId", gatewayId);
388 json.addProperty("BundleId", bundleId);
389 json.addProperty("IsSubBundle", false);
390 json.add("ValueIdList", gson.toJsonTree(valueIdList));
391 if (lastAccess != null) {
392 json.addProperty("LastAccess", DateTimeFormatter.ISO_INSTANT.format(lastAccess));
394 json.addProperty("LastAccess", (String) null);
396 json.addProperty("GuiIdChanged", false);
397 if (session != null) {
398 json.addProperty("SessionId", session.getBrowserSessionId());
400 resp = requestPOST("api/portal/GetParameterValues", json).get();
401 logger.trace("api/portal/GetParameterValues response: {}", resp);
402 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
403 logger.warn("getGetParameterValuesString failed with {}: {}", e.getCause(), e.getMessage());
404 loginFailedCounter++;
409 private CompletableFuture<String> requestGET(String url) throws WolfSmartsetCloudException {
410 return requestGET(url, new HashMap<String, String>());
413 private CompletableFuture<String> requestGET(String url, Map<String, String> params)
414 throws WolfSmartsetCloudException {
415 return rateLimtedRequest(() -> {
416 if (this.serviceToken.isEmpty()) {
417 throw new WolfSmartsetCloudException("Cannot execute request. service token missing");
419 loginFailedCounterCheck();
421 var requestUrl = WOLF_API_URL + url;
422 Request request = httpClient.newRequest(requestUrl).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
424 // using HTTP GET with ContentType application/x-www-form-urlencoded like the iOS App does
425 request.header(HttpHeader.AUTHORIZATION, serviceToken);
426 request.method(HttpMethod.GET);
427 request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
429 for (Entry<String, String> entry : params.entrySet()) {
430 logger.debug("Send request param: {}={} to {}", entry.getKey(), entry.getValue().toString(), url);
431 request.param(entry.getKey(), entry.getValue());
438 private CompletableFuture<String> requestPOST(String url, JsonElement json) throws WolfSmartsetCloudException {
439 return rateLimtedRequest(() -> {
440 if (this.serviceToken.isEmpty()) {
441 throw new WolfSmartsetCloudException("Cannot execute request. service token missing");
443 loginFailedCounterCheck();
445 var request = createPOSTRequest(url, json);
446 request.header(HttpHeader.AUTHORIZATION, serviceToken);
451 private Request createPOSTRequest(String url, JsonElement json) {
452 var requestUrl = WOLF_API_URL + url;
453 Request request = httpClient.newRequest(requestUrl).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
455 request.header(HttpHeader.ACCEPT, "application/json");
456 request.header(HttpHeader.CONTENT_TYPE, "application/json");
457 request.method(HttpMethod.POST);
459 request.content(new StringContentProvider(json.toString()), "application/json");
463 private CompletableFuture<String> rateLimtedRequest(SupplyRequestFunctionalInterface buildRequest) {
464 // if no delay is set, return a completed CompletableFuture
465 CompletableFuture<String> future = new CompletableFuture<>();
466 RequestQueueEntry queueEntry = new RequestQueueEntry(buildRequest, future);
469 queueEntry.completeFuture((r) -> this.getResponse(r));
471 if (!requestQueue.offer(queueEntry)) {
472 future.completeExceptionally(new RejectedExecutionException("Maximum queue size exceeded."));
478 private void stopProcessJob() {
479 ScheduledFuture<?> processJob = this.processJob;
480 if (processJob != null) {
481 processJob.cancel(false);
482 this.processJob = null;
486 private void processQueue() {
487 // No new Requests until blockRequestsUntil, is set when recieved HttpStatus.TOO_MANY_REQUESTS_429
488 if (blockRequestsUntil.isBefore(Instant.now())) {
489 RequestQueueEntry queueEntry = requestQueue.poll();
490 if (queueEntry != null) {
491 queueEntry.completeFuture((r) -> this.getResponse(r));
497 interface SupplyRequestFunctionalInterface {
498 Request get() throws WolfSmartsetCloudException;
502 interface GetResponseFunctionalInterface {
503 String get(Request request) throws WolfSmartsetCloudException;
506 private String getResponse(Request request) throws WolfSmartsetCloudException {
508 logger.debug("execute request {} {}", request.getMethod(), request.getURI());
509 final ContentResponse response = request.send();
510 if (response.getStatus() == HttpStatus.NOT_FOUND_404) {
511 throw new WolfSmartsetCloudException("Invalid request, not found " + request.getURI());
512 } else if (response.getStatus() == HttpStatus.TOO_MANY_REQUESTS_429) {
513 blockRequestsUntil = Instant.now().plusSeconds(30);
514 throw new WolfSmartsetCloudException("Error too many requests: " + response.getContentAsString());
515 } else if (response.getStatus() >= HttpStatus.BAD_REQUEST_400
516 && response.getStatus() < HttpStatus.INTERNAL_SERVER_ERROR_500) {
517 this.serviceToken = "";
518 logger.debug("Status {} while executing request to {} :{}", response.getStatus(), request.getURI(),
519 response.getContentAsString());
521 return response.getContentAsString();
523 } catch (HttpResponseException e) {
525 logger.debug("Error while executing request to {} :{}", request.getURI(), e.getMessage());
526 loginFailedCounter++;
527 } catch (InterruptedException | TimeoutException | ExecutionException /* | IOException */ e) {
528 logger.debug("Error while executing request to {} :{}", request.getURI(), e.getMessage());
529 loginFailedCounter++;
534 void loginFailedCounterCheck() {
535 if (loginFailedCounter > 10) {
536 logger.debug("Repeated errors logging on to Wolf Smartset");
538 loginFailedCounter = 0;
542 protected void loginRequest() throws WolfSmartsetCloudException {
545 logger.trace("Wolf Smartset Login");
547 String url = WOLF_API_URL + "connect/token";
548 Request request = httpClient.POST(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
549 request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
551 // Building Request body exacly the way the iOS App did this
552 var encodedUser = URLEncoder.encode(username, StandardCharsets.UTF_8);
553 var encodedPassword = URLEncoder.encode(password, StandardCharsets.UTF_8);
554 var authRequestBody = "grant_type=password&username=" + encodedUser + "&password=" + encodedPassword;
556 request.content(new StringContentProvider("application/x-www-form-urlencoded", authRequestBody,
557 StandardCharsets.UTF_8));
559 final ContentResponse response;
560 response = request.send();
562 final String content = response.getContentAsString();
563 logger.trace("Wolf smartset Login response= {}", response);
564 logger.trace("Wolf smartset Login content= {}", content);
566 switch (response.getStatus()) {
567 case HttpStatus.FORBIDDEN_403:
568 throw new WolfSmartsetCloudException(
569 "Access denied. Did you set the correct password and/or username?");
570 case HttpStatus.OK_200:
571 LoginResponseDTO jsonResp = gson.fromJson(content, LoginResponseDTO.class);
572 if (jsonResp == null) {
573 throw new WolfSmartsetCloudException("Error getting logon details: " + content);
576 serviceToken = jsonResp.getTokenType() + " " + jsonResp.getAccessToken();
578 logger.trace("Wolf Smartset login scope = {}", jsonResp.getScope());
579 logger.trace("Wolf Smartset login expiresIn = {}", jsonResp.getExpiresIn());
580 logger.trace("Wolf Smartset login tokenType = {}", jsonResp.getTokenType());
583 logger.trace("request returned status '{}', reason: {}, content = {}", response.getStatus(),
584 response.getReason(), response.getContentAsString());
585 throw new WolfSmartsetCloudException(response.getStatus() + response.getReason());
587 } catch (InterruptedException | TimeoutException | ExecutionException | JsonParseException e) {
588 throw new WolfSmartsetCloudException("Cannot logon to Wolf Smartset cloud: " + e.getMessage(), e);
592 private static class RequestQueueEntry {
593 private SupplyRequestFunctionalInterface buildRequest;
594 private CompletableFuture<String> future;
596 public RequestQueueEntry(SupplyRequestFunctionalInterface buildRequest, CompletableFuture<String> future) {
597 this.buildRequest = buildRequest;
598 this.future = future;
601 public void completeFuture(GetResponseFunctionalInterface getResponse) {
603 String response = getResponse.get(this.buildRequest.get());
604 future.complete(response);
605 } catch (WolfSmartsetCloudException e) {
606 future.completeExceptionally(e);