2 * Copyright (c) 2010-2024 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 WolfSmartsetApi} 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);
128 } catch (WolfSmartsetCloudException e) {
129 logger.debug("Error logging on to Wolf Smartset ({}): {}", loginFailedCounter, e.getMessage());
130 loginFailedCounter++;
132 loginFailedCounterCheck();
138 * Request the systems available for the authenticated account
140 * @return a list of the available systems
142 public List<GetSystemListDTO> getSystems() {
143 final String response = getSystemString();
144 List<GetSystemListDTO> devicesList = new ArrayList<>();
146 GetSystemListDTO[] cdl = gson.fromJson(response, GetSystemListDTO[].class);
148 for (GetSystemListDTO system : cdl) {
149 devicesList.add(system);
152 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
153 loginFailedCounter++;
154 logger.warn("Error while parsing devices: {}", e.getMessage());
160 * Request the description of the given system
162 * @param systemId the id of the system
163 * @param gatewayId the id of the gateway the system relates to
164 * @return dto describing the requested system
166 public @Nullable GetGuiDescriptionForGatewayDTO getSystemDescription(Integer systemId, Integer gatewayId) {
167 final String response = getSystemDescriptionString(systemId, gatewayId);
168 GetGuiDescriptionForGatewayDTO deviceDescription = null;
170 deviceDescription = gson.fromJson(response, GetGuiDescriptionForGatewayDTO.class);
171 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
172 loginFailedCounter++;
173 logger.warn("Error while parsing device descriptions: {}", e.getMessage());
175 return deviceDescription;
179 * Request the system state of the given systems
181 * @param systems a list of {@link GetSystemListDTO}
182 * @return the {@link GetSystemStateListDTO} descibing the state of the given {@link GetSystemListDTO} items
184 public @Nullable GetSystemStateListDTO @Nullable [] getSystemState(Collection<@Nullable GetSystemListDTO> systems) {
185 final String response = getSystemStateString(systems);
186 GetSystemStateListDTO[] systemState = null;
188 systemState = gson.fromJson(response, GetSystemStateListDTO[].class);
189 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
190 loginFailedCounter++;
191 logger.warn("Error while parsing device descriptions: {}", e.getMessage());
193 if (systemState != null && systemState.length >= 1) {
201 * Request the fault messages of the given system
203 * @param systemId the id of the system
204 * @param gatewayId the id of the gateway the system relates to
205 * @return {@link ReadFaultMessagesDTO} containing the faultmessages
207 public @Nullable ReadFaultMessagesDTO getFaultMessages(Integer systemId, Integer gatewayId) {
208 final String response = getFaultMessagesString(systemId, gatewayId);
209 ReadFaultMessagesDTO faultMessages = null;
211 faultMessages = gson.fromJson(response, ReadFaultMessagesDTO.class);
212 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
213 loginFailedCounter++;
214 logger.warn("Error while parsing faultmessages: {}", e.getMessage());
216 return faultMessages;
220 * Request the current values for a unit associated with the given system.
221 * if lastAccess is not null, only value changes newer than the given timestamp are returned
223 * @param systemId the id of the system
224 * @param gatewayId the id of the gateway the system relates to
225 * @param bundleId the id of the Unit
226 * @param valueIdList list of the values to request
227 * @param lastAccess timestamp of the last valid value request
228 * @return {@link GetParameterValuesDTO} containing the requested values
230 public @Nullable GetParameterValuesDTO getGetParameterValues(Integer systemId, Integer gatewayId, Long bundleId,
231 List<Long> valueIdList, @Nullable Instant lastAccess) {
232 final String response = getGetParameterValuesString(systemId, gatewayId, bundleId, valueIdList, lastAccess);
233 GetParameterValuesDTO parameterValues = null;
235 parameterValues = gson.fromJson(response, GetParameterValuesDTO.class);
236 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
237 loginFailedCounter++;
238 logger.warn("Error while parsing device parameter values: {}", e.getMessage());
240 return parameterValues;
243 public void stopRequestQueue() {
246 requestQueue.forEach(queueEntry -> queueEntry.future.completeExceptionally(new CancellationException()));
247 } catch (Exception e) {
248 logger.debug("Error stopping request queue background processing:{}", e.getMessage(), e);
255 * @param delay in ms between to requests
257 private void setDelay(int delay) {
259 throw new IllegalArgumentException("Delay needs to be larger or equal to zero");
264 processJob = scheduler.scheduleWithFixedDelay(() -> processQueue(), 0, delay, TimeUnit.MILLISECONDS);
268 private boolean checkCredentials() {
269 if (username.trim().isEmpty() || password.trim().isEmpty()) {
270 logger.debug("Wolf Smartset: username or password missing.");
276 private String getCreateSessionString() {
279 JsonObject json = new JsonObject();
280 json.addProperty("Timestamp", SESSION_TIME_STAMP.format(LocalDateTime.now()));
281 resp = requestPOST("api/portal/CreateSession2", json).get();
282 logger.trace("api/portal/CreateSession2 response: {}", resp);
283 } catch (InterruptedException | ExecutionException e) {
284 logger.warn("getSystemStateString failed with {}: {}", e.getCause(), e.getMessage());
285 loginFailedCounter++;
286 } catch (WolfSmartsetCloudException e) {
287 logger.debug("getSystemStateString failed with {}: {}", e.getCause(), e.getMessage());
288 loginFailedCounter++;
294 private @Nullable CreateSession2DTO getCreateSession() {
295 final String response = getCreateSessionString();
296 CreateSession2DTO session = null;
298 session = gson.fromJson(response, CreateSession2DTO.class);
299 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
300 loginFailedCounter++;
301 logger.warn("getCreateSession failed with {}: {}", e.getCause(), e.getMessage());
306 private String getSystemString() {
309 resp = requestGET("api/portal/GetSystemList").get();
310 logger.trace("api/portal/GetSystemList response: {}", resp);
311 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
312 logger.warn("getSystemString failed with {}: {}", e.getCause(), e.getMessage());
313 loginFailedCounter++;
318 private String getSystemDescriptionString(Integer systemId, Integer gatewayId) {
321 Map<String, String> params = new HashMap<String, String>();
322 params.put("SystemId", systemId.toString());
323 params.put("GatewayId", gatewayId.toString());
324 resp = requestGET("api/portal/GetGuiDescriptionForGateway", params).get();
325 logger.trace("api/portal/GetGuiDescriptionForGateway response: {}", resp);
326 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
327 logger.warn("getSystemDescriptionString failed with {}: {}", e.getCause(), e.getMessage());
328 loginFailedCounter++;
333 private String getSystemStateString(Collection<@Nullable GetSystemListDTO> systems) {
336 JsonArray jsonSystemList = new JsonArray();
339 GetSystemListDTO system : systems) {
340 if (system != null) {
341 JsonObject jsonSystem = new JsonObject();
342 jsonSystem.addProperty("SystemId", system.getId());
343 jsonSystem.addProperty("GatewayId", system.getGatewayId());
345 if (system.getSystemShareId() != null) {
346 jsonSystem.addProperty("SystemShareId", system.getSystemShareId());
348 jsonSystemList.add(jsonSystem);
352 JsonObject json = new JsonObject();
354 json.add("SystemList", jsonSystemList);
355 resp = requestPOST("api/portal/GetSystemStateList", json).get();
356 logger.trace("api/portal/GetSystemStateList response: {}", resp);
357 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
358 logger.warn("getSystemStateString failed with {}: {}", e.getCause(), e.getMessage());
359 loginFailedCounter++;
364 private String getFaultMessagesString(Integer systemId, Integer gatewayId) {
367 JsonObject json = new JsonObject();
369 json.addProperty("SystemId", systemId);
370 json.addProperty("GatewayId", gatewayId);
371 resp = requestPOST("api/portal/ReadFaultMessages", json).get();
372 logger.trace("api/portal/ReadFaultMessages response: {}", resp);
373 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
374 logger.warn("getFaultMessagesString failed with {}: {}", e.getCause(), e.getMessage());
375 loginFailedCounter++;
380 private String getGetParameterValuesString(Integer systemId, Integer gatewayId, Long bundleId,
381 List<Long> valueIdList, @Nullable Instant lastAccess) {
384 JsonObject json = new JsonObject();
385 json.addProperty("SystemId", systemId);
386 json.addProperty("GatewayId", gatewayId);
387 json.addProperty("BundleId", bundleId);
388 json.addProperty("IsSubBundle", false);
389 json.add("ValueIdList", gson.toJsonTree(valueIdList));
390 if (lastAccess != null) {
391 json.addProperty("LastAccess", DateTimeFormatter.ISO_INSTANT.format(lastAccess));
393 json.addProperty("LastAccess", (String) null);
395 json.addProperty("GuiIdChanged", false);
396 if (session != null) {
397 json.addProperty("SessionId", session.getBrowserSessionId());
399 resp = requestPOST("api/portal/GetParameterValues", json).get();
400 logger.trace("api/portal/GetParameterValues response: {}", resp);
401 } catch (InterruptedException | ExecutionException | WolfSmartsetCloudException e) {
402 logger.warn("getGetParameterValuesString failed with {}: {}", e.getCause(), e.getMessage());
403 loginFailedCounter++;
408 private CompletableFuture<String> requestGET(String url) throws WolfSmartsetCloudException {
409 return requestGET(url, new HashMap<String, String>());
412 private CompletableFuture<String> requestGET(String url, Map<String, String> params)
413 throws WolfSmartsetCloudException {
414 return rateLimtedRequest(() -> {
415 if (this.serviceToken.isEmpty()) {
416 throw new WolfSmartsetCloudException("Cannot execute request. service token missing");
418 loginFailedCounterCheck();
420 var requestUrl = WOLF_API_URL + url;
421 Request request = httpClient.newRequest(requestUrl).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
423 // using HTTP GET with ContentType application/x-www-form-urlencoded like the iOS App does
424 request.header(HttpHeader.AUTHORIZATION, serviceToken);
425 request.method(HttpMethod.GET);
426 request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
428 for (Entry<String, String> entry : params.entrySet()) {
429 logger.debug("Send request param: {}={} to {}", entry.getKey(), entry.getValue().toString(), url);
430 request.param(entry.getKey(), entry.getValue());
437 private CompletableFuture<String> requestPOST(String url, JsonElement json) throws WolfSmartsetCloudException {
438 return rateLimtedRequest(() -> {
439 if (this.serviceToken.isEmpty()) {
440 throw new WolfSmartsetCloudException("Cannot execute request. service token missing");
442 loginFailedCounterCheck();
444 var request = createPOSTRequest(url, json);
445 request.header(HttpHeader.AUTHORIZATION, serviceToken);
450 private Request createPOSTRequest(String url, JsonElement json) {
451 var requestUrl = WOLF_API_URL + url;
452 Request request = httpClient.newRequest(requestUrl).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
454 request.header(HttpHeader.ACCEPT, "application/json");
455 request.header(HttpHeader.CONTENT_TYPE, "application/json");
456 request.method(HttpMethod.POST);
458 request.content(new StringContentProvider(json.toString()), "application/json");
462 private CompletableFuture<String> rateLimtedRequest(SupplyRequestFunctionalInterface buildRequest) {
463 // if no delay is set, return a completed CompletableFuture
464 CompletableFuture<String> future = new CompletableFuture<>();
465 RequestQueueEntry queueEntry = new RequestQueueEntry(buildRequest, future);
468 queueEntry.completeFuture((r) -> this.getResponse(r));
470 if (!requestQueue.offer(queueEntry)) {
471 future.completeExceptionally(new RejectedExecutionException("Maximum queue size exceeded."));
477 private void stopProcessJob() {
478 ScheduledFuture<?> processJob = this.processJob;
479 if (processJob != null) {
480 processJob.cancel(false);
481 this.processJob = null;
485 private void processQueue() {
486 // No new Requests until blockRequestsUntil, is set when recieved HttpStatus.TOO_MANY_REQUESTS_429
487 if (blockRequestsUntil.isBefore(Instant.now())) {
488 RequestQueueEntry queueEntry = requestQueue.poll();
489 if (queueEntry != null) {
490 queueEntry.completeFuture((r) -> this.getResponse(r));
496 interface SupplyRequestFunctionalInterface {
497 Request get() throws WolfSmartsetCloudException;
501 interface GetResponseFunctionalInterface {
502 String get(Request request) throws WolfSmartsetCloudException;
505 private String getResponse(Request request) throws WolfSmartsetCloudException {
507 logger.debug("execute request {} {}", request.getMethod(), request.getURI());
508 final ContentResponse response = request.send();
509 if (response.getStatus() == HttpStatus.NOT_FOUND_404) {
510 throw new WolfSmartsetCloudException("Invalid request, not found " + request.getURI());
511 } else if (response.getStatus() == HttpStatus.TOO_MANY_REQUESTS_429) {
512 blockRequestsUntil = Instant.now().plusSeconds(30);
513 throw new WolfSmartsetCloudException("Error too many requests: " + response.getContentAsString());
514 } else if (response.getStatus() >= HttpStatus.BAD_REQUEST_400
515 && response.getStatus() < HttpStatus.INTERNAL_SERVER_ERROR_500) {
516 this.serviceToken = "";
517 logger.debug("Status {} while executing request to {} :{}", response.getStatus(), request.getURI(),
518 response.getContentAsString());
520 return response.getContentAsString();
522 } catch (HttpResponseException e) {
524 logger.debug("Error while executing request to {} :{}", request.getURI(), e.getMessage());
525 loginFailedCounter++;
526 } catch (InterruptedException | TimeoutException | ExecutionException /* | IOException */ e) {
527 logger.debug("Error while executing request to {} :{}", request.getURI(), e.getMessage());
528 loginFailedCounter++;
533 void loginFailedCounterCheck() {
534 if (loginFailedCounter > 10) {
535 logger.debug("Repeated errors logging on to Wolf Smartset");
537 loginFailedCounter = 0;
541 protected void loginRequest() throws WolfSmartsetCloudException {
544 logger.trace("Wolf Smartset Login");
546 String url = WOLF_API_URL + "connect/token";
547 Request request = httpClient.POST(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
548 request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
550 // Building Request body exacly the way the iOS App did this
551 var encodedUser = URLEncoder.encode(username, StandardCharsets.UTF_8);
552 var encodedPassword = URLEncoder.encode(password, StandardCharsets.UTF_8);
553 var authRequestBody = "grant_type=password&username=" + encodedUser + "&password=" + encodedPassword;
555 request.content(new StringContentProvider("application/x-www-form-urlencoded", authRequestBody,
556 StandardCharsets.UTF_8));
558 final ContentResponse response;
559 response = request.send();
561 final String content = response.getContentAsString();
562 logger.trace("Wolf smartset Login response= {}", response);
563 logger.trace("Wolf smartset Login content= {}", content);
565 switch (response.getStatus()) {
566 case HttpStatus.FORBIDDEN_403:
567 throw new WolfSmartsetCloudException(
568 "Access denied. Did you set the correct password and/or username?");
569 case HttpStatus.OK_200:
570 LoginResponseDTO jsonResp = gson.fromJson(content, LoginResponseDTO.class);
571 if (jsonResp == null) {
572 throw new WolfSmartsetCloudException("Error getting logon details: " + content);
575 serviceToken = jsonResp.getTokenType() + " " + jsonResp.getAccessToken();
577 logger.trace("Wolf Smartset login scope = {}", jsonResp.getScope());
578 logger.trace("Wolf Smartset login expiresIn = {}", jsonResp.getExpiresIn());
579 logger.trace("Wolf Smartset login tokenType = {}", jsonResp.getTokenType());
582 logger.trace("request returned status '{}', reason: {}, content = {}", response.getStatus(),
583 response.getReason(), response.getContentAsString());
584 throw new WolfSmartsetCloudException(response.getStatus() + response.getReason());
586 } catch (InterruptedException | TimeoutException | ExecutionException | JsonParseException e) {
587 throw new WolfSmartsetCloudException("Cannot logon to Wolf Smartset cloud: " + e.getMessage(), e);
591 private static class RequestQueueEntry {
592 private SupplyRequestFunctionalInterface buildRequest;
593 private CompletableFuture<String> future;
595 public RequestQueueEntry(SupplyRequestFunctionalInterface buildRequest, CompletableFuture<String> future) {
596 this.buildRequest = buildRequest;
597 this.future = future;
600 public void completeFuture(GetResponseFunctionalInterface getResponse) {
602 String response = getResponse.get(this.buildRequest.get());
603 future.complete(response);
604 } catch (WolfSmartsetCloudException e) {
605 future.completeExceptionally(e);