]> git.basschouten.com Git - openhab-addons.git/blob
c72a1cd0d2268dc592698639aa450e4817e0eb99
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.homeconnect.internal.client;
14
15 import static java.util.Collections.singletonList;
16 import static org.openhab.binding.homeconnect.internal.HomeConnectBindingConstants.*;
17 import static org.openhab.binding.homeconnect.internal.client.HttpHelper.*;
18
19 import java.time.ZonedDateTime;
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.TimeoutException;
30
31 import javax.ws.rs.core.HttpHeaders;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.eclipse.jetty.client.api.Request;
38 import org.eclipse.jetty.client.util.StringContentProvider;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.eclipse.jetty.http.HttpStatus;
41 import org.openhab.binding.homeconnect.internal.client.exception.ApplianceOfflineException;
42 import org.openhab.binding.homeconnect.internal.client.exception.AuthorizationException;
43 import org.openhab.binding.homeconnect.internal.client.exception.CommunicationException;
44 import org.openhab.binding.homeconnect.internal.client.model.ApiRequest;
45 import org.openhab.binding.homeconnect.internal.client.model.AvailableProgram;
46 import org.openhab.binding.homeconnect.internal.client.model.AvailableProgramOption;
47 import org.openhab.binding.homeconnect.internal.client.model.Data;
48 import org.openhab.binding.homeconnect.internal.client.model.HomeAppliance;
49 import org.openhab.binding.homeconnect.internal.client.model.HomeConnectRequest;
50 import org.openhab.binding.homeconnect.internal.client.model.HomeConnectResponse;
51 import org.openhab.binding.homeconnect.internal.client.model.Option;
52 import org.openhab.binding.homeconnect.internal.client.model.Program;
53 import org.openhab.binding.homeconnect.internal.configuration.ApiBridgeConfiguration;
54 import org.openhab.core.auth.client.oauth2.OAuthClientService;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
57
58 import com.google.gson.JsonArray;
59 import com.google.gson.JsonObject;
60
61 /**
62  * Client for Home Connect API.
63  *
64  * @author Jonas BrĂ¼stel - Initial contribution
65  * @author Laurent Garnier - Replace okhttp by the Jetty HTTP client provided by the openHAB core framework
66  *
67  */
68 @NonNullByDefault
69 public class HomeConnectApiClient {
70     private static final String BSH_JSON_V1 = "application/vnd.bsh.sdk.v1+json";
71     private static final String BASE = "/api/homeappliances";
72     private static final String BASE_PATH = BASE + "/";
73     private static final int REQUEST_TIMEOUT_SEC = 30;
74     private static final int VALUE_TYPE_STRING = 0;
75     private static final int VALUE_TYPE_INT = 1;
76     private static final int VALUE_TYPE_BOOLEAN = 2;
77     private static final int COMMUNICATION_QUEUE_SIZE = 50;
78
79     private final Logger logger = LoggerFactory.getLogger(HomeConnectApiClient.class);
80     private final HttpClient client;
81     private final String apiUrl;
82     private final Map<String, List<AvailableProgramOption>> availableProgramOptionsCache;
83     private final Map<String, List<AvailableProgram>> programsCache;
84     private final OAuthClientService oAuthClientService;
85     private final CircularQueue<ApiRequest> communicationQueue;
86     private final ApiBridgeConfiguration apiBridgeConfiguration;
87
88     public HomeConnectApiClient(HttpClient httpClient, OAuthClientService oAuthClientService, boolean simulated,
89             @Nullable List<ApiRequest> apiRequestHistory, ApiBridgeConfiguration apiBridgeConfiguration) {
90         this.client = httpClient;
91         this.oAuthClientService = oAuthClientService;
92         this.apiBridgeConfiguration = apiBridgeConfiguration;
93
94         availableProgramOptionsCache = new ConcurrentHashMap<>();
95         programsCache = new ConcurrentHashMap<>();
96         apiUrl = simulated ? API_SIMULATOR_BASE_URL : API_BASE_URL;
97         communicationQueue = new CircularQueue<>(COMMUNICATION_QUEUE_SIZE);
98         if (apiRequestHistory != null) {
99             communicationQueue.addAll(apiRequestHistory);
100         }
101     }
102
103     /**
104      * Get all home appliances
105      *
106      * @return list of {@link HomeAppliance}
107      * @throws CommunicationException API communication exception
108      * @throws AuthorizationException oAuth authorization exception
109      */
110     public List<HomeAppliance> getHomeAppliances() throws CommunicationException, AuthorizationException {
111         Request request = createRequest(HttpMethod.GET, BASE);
112         try {
113             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
114             checkResponseCode(HttpStatus.OK_200, request, response, null, null);
115
116             String responseBody = response.getContentAsString();
117             trackAndLogApiRequest(null, request, null, response, responseBody);
118
119             return mapToHomeAppliances(responseBody);
120         } catch (InterruptedException | TimeoutException | ExecutionException | ApplianceOfflineException e) {
121             logger.warn("Failed to fetch home appliances! error={}", e.getMessage());
122             trackAndLogApiRequest(null, request, null, null, null);
123             throw new CommunicationException(e);
124         }
125     }
126
127     /**
128      * Get home appliance by id
129      *
130      * @param haId home appliance id
131      * @return {@link HomeAppliance}
132      * @throws CommunicationException API communication exception
133      * @throws AuthorizationException oAuth authorization exception
134      */
135     public HomeAppliance getHomeAppliance(String haId) throws CommunicationException, AuthorizationException {
136         Request request = createRequest(HttpMethod.GET, BASE_PATH + haId);
137         try {
138             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
139             checkResponseCode(HttpStatus.OK_200, request, response, haId, null);
140
141             String responseBody = response.getContentAsString();
142             trackAndLogApiRequest(haId, request, null, response, responseBody);
143
144             return mapToHomeAppliance(responseBody);
145         } catch (InterruptedException | TimeoutException | ExecutionException | ApplianceOfflineException e) {
146             logger.warn("Failed to get home appliance! haId={}, error={}", haId, e.getMessage());
147             trackAndLogApiRequest(haId, request, null, null, null);
148             throw new CommunicationException(e);
149         }
150     }
151
152     /**
153      * Get ambient light state of device.
154      *
155      * @param haId home appliance id
156      * @return {@link Data}
157      * @throws CommunicationException API communication exception
158      * @throws AuthorizationException oAuth authorization exception
159      * @throws ApplianceOfflineException appliance is not connected to the cloud
160      */
161     public Data getAmbientLightState(String haId)
162             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
163         return getSetting(haId, SETTING_AMBIENT_LIGHT_ENABLED);
164     }
165
166     /**
167      * Set ambient light state of device.
168      *
169      * @param haId home appliance id
170      * @param enable enable or disable ambient light
171      * @throws CommunicationException API communication exception
172      * @throws AuthorizationException oAuth authorization exception
173      * @throws ApplianceOfflineException appliance is not connected to the cloud
174      */
175     public void setAmbientLightState(String haId, boolean enable)
176             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
177         putSettings(haId, new Data(SETTING_AMBIENT_LIGHT_ENABLED, String.valueOf(enable), null), VALUE_TYPE_BOOLEAN);
178     }
179
180     /**
181      * Get functional light state of device.
182      *
183      * @param haId home appliance id
184      * @return {@link Data}
185      * @throws CommunicationException API communication exception
186      * @throws AuthorizationException oAuth authorization exception
187      * @throws ApplianceOfflineException appliance is not connected to the cloud
188      */
189     public Data getFunctionalLightState(String haId)
190             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
191         return getSetting(haId, SETTING_LIGHTING);
192     }
193
194     /**
195      * Set functional light state of device.
196      *
197      * @param haId home appliance id
198      * @param enable enable or disable functional light
199      * @throws CommunicationException API communication exception
200      * @throws AuthorizationException oAuth authorization exception
201      * @throws ApplianceOfflineException appliance is not connected to the cloud
202      */
203     public void setFunctionalLightState(String haId, boolean enable)
204             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
205         putSettings(haId, new Data(SETTING_LIGHTING, String.valueOf(enable), null), VALUE_TYPE_BOOLEAN);
206     }
207
208     /**
209      * Get functional light brightness state of device.
210      *
211      * @param haId home appliance id
212      * @return {@link Data}
213      * @throws CommunicationException API communication exception
214      * @throws AuthorizationException oAuth authorization exception
215      * @throws ApplianceOfflineException appliance is not connected to the cloud
216      */
217     public Data getFunctionalLightBrightnessState(String haId)
218             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
219         return getSetting(haId, SETTING_LIGHTING_BRIGHTNESS);
220     }
221
222     /**
223      * Set functional light brightness of device.
224      *
225      * @param haId home appliance id
226      * @param value brightness value 10-100
227      * @throws CommunicationException API communication exception
228      * @throws AuthorizationException oAuth authorization exception
229      * @throws ApplianceOfflineException appliance is not connected to the cloud
230      */
231     public void setFunctionalLightBrightnessState(String haId, int value)
232             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
233         putSettings(haId, new Data(SETTING_LIGHTING_BRIGHTNESS, String.valueOf(value), "%"), VALUE_TYPE_INT);
234     }
235
236     /**
237      * Get ambient light brightness state of device.
238      *
239      * @param haId home appliance id
240      * @return {@link Data}
241      * @throws CommunicationException API communication exception
242      * @throws AuthorizationException oAuth authorization exception
243      * @throws ApplianceOfflineException appliance is not connected to the cloud
244      */
245     public Data getAmbientLightBrightnessState(String haId)
246             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
247         return getSetting(haId, SETTING_AMBIENT_LIGHT_BRIGHTNESS);
248     }
249
250     /**
251      * Set ambient light brightness of device.
252      *
253      * @param haId home appliance id
254      * @param value brightness value 10-100
255      * @throws CommunicationException API communication exception
256      * @throws AuthorizationException oAuth authorization exception
257      * @throws ApplianceOfflineException appliance is not connected to the cloud
258      */
259     public void setAmbientLightBrightnessState(String haId, int value)
260             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
261         putSettings(haId, new Data(SETTING_AMBIENT_LIGHT_BRIGHTNESS, String.valueOf(value), "%"), VALUE_TYPE_INT);
262     }
263
264     /**
265      * Get ambient light color state of device.
266      *
267      * @param haId home appliance id
268      * @return {@link Data}
269      * @throws CommunicationException API communication exception
270      * @throws AuthorizationException oAuth authorization exception
271      * @throws ApplianceOfflineException appliance is not connected to the cloud
272      */
273     public Data getAmbientLightColorState(String haId)
274             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
275         return getSetting(haId, SETTING_AMBIENT_LIGHT_COLOR);
276     }
277
278     /**
279      * Set ambient light color of device.
280      *
281      * @param haId home appliance id
282      * @param value color code
283      * @throws CommunicationException API communication exception
284      * @throws AuthorizationException oAuth authorization exception
285      * @throws ApplianceOfflineException appliance is not connected to the cloud
286      */
287     public void setAmbientLightColorState(String haId, String value)
288             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
289         putSettings(haId, new Data(SETTING_AMBIENT_LIGHT_COLOR, value, null));
290     }
291
292     /**
293      * Get ambient light custom color state of device.
294      *
295      * @param haId home appliance id
296      * @return {@link Data}
297      * @throws CommunicationException API communication exception
298      * @throws AuthorizationException oAuth authorization exception
299      * @throws ApplianceOfflineException appliance is not connected to the cloud
300      */
301     public Data getAmbientLightCustomColorState(String haId)
302             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
303         return getSetting(haId, SETTING_AMBIENT_LIGHT_CUSTOM_COLOR);
304     }
305
306     /**
307      * Set ambient light color of device.
308      *
309      * @param haId home appliance id
310      * @param value color code
311      * @throws CommunicationException API communication exception
312      * @throws AuthorizationException oAuth authorization exception
313      * @throws ApplianceOfflineException appliance is not connected to the cloud
314      */
315     public void setAmbientLightCustomColorState(String haId, String value)
316             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
317         putSettings(haId, new Data(SETTING_AMBIENT_LIGHT_CUSTOM_COLOR, value, null));
318     }
319
320     /**
321      * Get power state of device.
322      *
323      * @param haId home appliance id
324      * @return {@link Data}
325      * @throws CommunicationException API communication exception
326      * @throws AuthorizationException oAuth authorization exception
327      * @throws ApplianceOfflineException appliance is not connected to the cloud
328      */
329     public Data getPowerState(String haId)
330             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
331         return getSetting(haId, SETTING_POWER_STATE);
332     }
333
334     /**
335      * Set power state of device.
336      *
337      * @param haId home appliance id
338      * @param state target state
339      * @throws CommunicationException API communication exception
340      * @throws AuthorizationException oAuth authorization exception
341      * @throws ApplianceOfflineException appliance is not connected to the cloud
342      */
343     public void setPowerState(String haId, String state)
344             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
345         putSettings(haId, new Data(SETTING_POWER_STATE, state, null));
346     }
347
348     /**
349      * Get setpoint temperature of freezer
350      *
351      * @param haId home appliance id
352      * @return {@link Data}
353      * @throws CommunicationException API communication exception
354      * @throws AuthorizationException oAuth authorization exception
355      * @throws ApplianceOfflineException appliance is not connected to the cloud
356      */
357     public Data getFreezerSetpointTemperature(String haId)
358             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
359         return getSetting(haId, SETTING_FREEZER_SETPOINT_TEMPERATURE);
360     }
361
362     /**
363      * Set setpoint temperature of freezer
364      *
365      * @param haId home appliance id
366      * @param state new temperature
367      * @param unit temperature unit
368      * @throws CommunicationException API communication exception
369      * @throws AuthorizationException oAuth authorization exception
370      * @throws ApplianceOfflineException appliance is not connected to the cloud
371      */
372     public void setFreezerSetpointTemperature(String haId, String state, String unit)
373             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
374         putSettings(haId, new Data(SETTING_FREEZER_SETPOINT_TEMPERATURE, state, unit), VALUE_TYPE_INT);
375     }
376
377     /**
378      * Get setpoint temperature of fridge
379      *
380      * @param haId home appliance id
381      * @return {@link Data} or null in case of communication error
382      * @throws CommunicationException API communication exception
383      * @throws AuthorizationException oAuth authorization exception
384      * @throws ApplianceOfflineException appliance is not connected to the cloud
385      */
386     public Data getFridgeSetpointTemperature(String haId)
387             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
388         return getSetting(haId, SETTING_REFRIGERATOR_SETPOINT_TEMPERATURE);
389     }
390
391     /**
392      * Set setpoint temperature of fridge
393      *
394      * @param haId home appliance id
395      * @param state new temperature
396      * @param unit temperature unit
397      * @throws CommunicationException API communication exception
398      * @throws AuthorizationException oAuth authorization exception
399      * @throws ApplianceOfflineException appliance is not connected to the cloud
400      */
401     public void setFridgeSetpointTemperature(String haId, String state, String unit)
402             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
403         putSettings(haId, new Data(SETTING_REFRIGERATOR_SETPOINT_TEMPERATURE, state, unit), VALUE_TYPE_INT);
404     }
405
406     /**
407      * Get fridge super mode
408      *
409      * @param haId home appliance id
410      * @return {@link Data}
411      * @throws CommunicationException API communication exception
412      * @throws AuthorizationException oAuth authorization exception
413      * @throws ApplianceOfflineException appliance is not connected to the cloud
414      */
415     public Data getFridgeSuperMode(String haId)
416             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
417         return getSetting(haId, SETTING_REFRIGERATOR_SUPER_MODE);
418     }
419
420     /**
421      * Set fridge super mode
422      *
423      * @param haId home appliance id
424      * @param enable enable or disable fridge super mode
425      * @throws CommunicationException API communication exception
426      * @throws AuthorizationException oAuth authorization exception
427      * @throws ApplianceOfflineException appliance is not connected to the cloud
428      */
429     public void setFridgeSuperMode(String haId, boolean enable)
430             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
431         putSettings(haId, new Data(SETTING_REFRIGERATOR_SUPER_MODE, String.valueOf(enable), null), VALUE_TYPE_BOOLEAN);
432     }
433
434     /**
435      * Get freezer super mode
436      *
437      * @param haId home appliance id
438      * @return {@link Data}
439      * @throws CommunicationException API communication exception
440      * @throws AuthorizationException oAuth authorization exception
441      * @throws ApplianceOfflineException appliance is not connected to the cloud
442      */
443     public Data getFreezerSuperMode(String haId)
444             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
445         return getSetting(haId, SETTING_FREEZER_SUPER_MODE);
446     }
447
448     /**
449      * Set freezer super mode
450      *
451      * @param haId home appliance id
452      * @param enable enable or disable freezer super mode
453      * @throws CommunicationException API communication exception
454      * @throws AuthorizationException oAuth authorization exception
455      * @throws ApplianceOfflineException appliance is not connected to the cloud
456      */
457     public void setFreezerSuperMode(String haId, boolean enable)
458             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
459         putSettings(haId, new Data(SETTING_FREEZER_SUPER_MODE, String.valueOf(enable), null), VALUE_TYPE_BOOLEAN);
460     }
461
462     /**
463      * Get door state of device.
464      *
465      * @param haId home appliance id
466      * @return {@link Data}
467      * @throws CommunicationException API communication exception
468      * @throws AuthorizationException oAuth authorization exception
469      * @throws ApplianceOfflineException appliance is not connected to the cloud
470      */
471     public Data getDoorState(String haId)
472             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
473         return getStatus(haId, STATUS_DOOR_STATE);
474     }
475
476     /**
477      * Get operation state of device.
478      *
479      * @param haId home appliance id
480      * @return {@link Data}
481      * @throws CommunicationException API communication exception
482      * @throws AuthorizationException oAuth authorization exception
483      * @throws ApplianceOfflineException appliance is not connected to the cloud
484      */
485     public Data getOperationState(String haId)
486             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
487         return getStatus(haId, STATUS_OPERATION_STATE);
488     }
489
490     /**
491      * Get current cavity temperature of oven.
492      *
493      * @param haId home appliance id
494      * @return {@link Data}
495      * @throws CommunicationException API communication exception
496      * @throws AuthorizationException oAuth authorization exception
497      * @throws ApplianceOfflineException appliance is not connected to the cloud
498      */
499     public Data getCurrentCavityTemperature(String haId)
500             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
501         return getStatus(haId, STATUS_OVEN_CURRENT_CAVITY_TEMPERATURE);
502     }
503
504     /**
505      * Is remote start allowed?
506      *
507      * @param haId haId home appliance id
508      * @return true or false
509      * @throws CommunicationException API communication exception
510      * @throws AuthorizationException oAuth authorization exception
511      * @throws ApplianceOfflineException appliance is not connected to the cloud
512      */
513     public boolean isRemoteControlStartAllowed(String haId)
514             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
515         Data data = getStatus(haId, STATUS_REMOTE_CONTROL_START_ALLOWED);
516         return Boolean.parseBoolean(data.getValue());
517     }
518
519     /**
520      * Is remote control allowed?
521      *
522      * @param haId haId home appliance id
523      * @return true or false
524      * @throws CommunicationException API communication exception
525      * @throws AuthorizationException oAuth authorization exception
526      * @throws ApplianceOfflineException appliance is not connected to the cloud
527      */
528     public boolean isRemoteControlActive(String haId)
529             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
530         Data data = getStatus(haId, STATUS_REMOTE_CONTROL_ACTIVE);
531         return Boolean.parseBoolean(data.getValue());
532     }
533
534     /**
535      * Is local control allowed?
536      *
537      * @param haId haId home appliance id
538      * @return true or false
539      * @throws CommunicationException API communication exception
540      * @throws AuthorizationException oAuth authorization exception
541      * @throws ApplianceOfflineException appliance is not connected to the cloud
542      */
543     public boolean isLocalControlActive(String haId)
544             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
545         Data data = getStatus(haId, STATUS_LOCAL_CONTROL_ACTIVE);
546         return Boolean.parseBoolean(data.getValue());
547     }
548
549     /**
550      * Get active program of device.
551      *
552      * @param haId home appliance id
553      * @return {@link Data} or null if there is no active program
554      * @throws CommunicationException API communication exception
555      * @throws AuthorizationException oAuth authorization exception
556      * @throws ApplianceOfflineException appliance is not connected to the cloud
557      */
558     public @Nullable Program getActiveProgram(String haId)
559             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
560         return getProgram(haId, BASE_PATH + haId + "/programs/active");
561     }
562
563     /**
564      * Get selected program of device.
565      *
566      * @param haId home appliance id
567      * @return {@link Data} or null if there is no selected program
568      * @throws CommunicationException API communication exception
569      * @throws AuthorizationException oAuth authorization exception
570      * @throws ApplianceOfflineException appliance is not connected to the cloud
571      */
572     public @Nullable Program getSelectedProgram(String haId)
573             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
574         return getProgram(haId, BASE_PATH + haId + "/programs/selected");
575     }
576
577     public void setSelectedProgram(String haId, String program)
578             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
579         putData(haId, BASE_PATH + haId + "/programs/selected", new Data(program, null, null), VALUE_TYPE_STRING);
580     }
581
582     public void startProgram(String haId, String program)
583             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
584         putData(haId, BASE_PATH + haId + "/programs/active", new Data(program, null, null), VALUE_TYPE_STRING);
585     }
586
587     public void startSelectedProgram(String haId)
588             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
589         String selectedProgram = getRaw(haId, BASE_PATH + haId + "/programs/selected");
590         if (selectedProgram != null) {
591             putRaw(haId, BASE_PATH + haId + "/programs/active", selectedProgram);
592         }
593     }
594
595     public void startCustomProgram(String haId, String json)
596             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
597         putRaw(haId, BASE_PATH + haId + "/programs/active", json);
598     }
599
600     public void setProgramOptions(String haId, String key, String value, @Nullable String unit, boolean valueAsInt,
601             boolean isProgramActive) throws CommunicationException, AuthorizationException, ApplianceOfflineException {
602         String programState = isProgramActive ? "active" : "selected";
603
604         putOption(haId, BASE_PATH + haId + "/programs/" + programState + "/options", new Option(key, value, unit),
605                 valueAsInt);
606     }
607
608     public void stopProgram(String haId)
609             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
610         sendDelete(haId, BASE_PATH + haId + "/programs/active");
611     }
612
613     public List<AvailableProgram> getPrograms(String haId)
614             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
615         List<AvailableProgram> programs;
616         if (programsCache.containsKey(haId)) {
617             logger.debug("Returning cached programs for '{}'.", haId);
618             programs = programsCache.get(haId);
619             programs = programs != null ? programs : Collections.emptyList();
620         } else {
621             programs = getAvailablePrograms(haId, BASE_PATH + haId + "/programs");
622             programsCache.put(haId, programs);
623         }
624         return programs;
625     }
626
627     public List<AvailableProgram> getAvailablePrograms(String haId)
628             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
629         return getAvailablePrograms(haId, BASE_PATH + haId + "/programs/available");
630     }
631
632     public List<AvailableProgramOption> getProgramOptions(String haId, String programKey)
633             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
634         if (availableProgramOptionsCache.containsKey(programKey)) {
635             logger.debug("Returning cached options for '{}'.", programKey);
636             List<AvailableProgramOption> availableProgramOptions = availableProgramOptionsCache.get(programKey);
637             return availableProgramOptions != null ? availableProgramOptions : Collections.emptyList();
638         }
639
640         Request request = createRequest(HttpMethod.GET, BASE_PATH + haId + "/programs/available/" + programKey);
641         try {
642             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
643             checkResponseCode(List.of(HttpStatus.OK_200, HttpStatus.NOT_FOUND_404), request, response, haId, null);
644
645             String responseBody = response.getContentAsString();
646             trackAndLogApiRequest(haId, request, null, response, responseBody);
647
648             // Code 404 accepted only if the returned error is "SDK.Error.UnsupportedProgram"
649             if (response.getStatus() == HttpStatus.NOT_FOUND_404
650                     && (responseBody == null || !responseBody.contains("SDK.Error.UnsupportedProgram"))) {
651                 throw new CommunicationException(HttpStatus.NOT_FOUND_404, response.getReason(),
652                         responseBody == null ? "" : responseBody);
653             }
654
655             List<AvailableProgramOption> availableProgramOptions = response.getStatus() == HttpStatus.OK_200
656                     ? mapToAvailableProgramOption(responseBody, haId)
657                     : List.of();
658             availableProgramOptionsCache.put(programKey, availableProgramOptions);
659             return availableProgramOptions;
660         } catch (InterruptedException | TimeoutException | ExecutionException e) {
661             logger.warn("Failed to get program options! haId={}, programKey={}, error={}", haId, programKey,
662                     e.getMessage());
663             trackAndLogApiRequest(haId, request, null, null, null);
664             throw new CommunicationException(e);
665         }
666     }
667
668     /**
669      * Get latest API requests.
670      *
671      * @return communication queue
672      */
673     public Collection<ApiRequest> getLatestApiRequests() {
674         return communicationQueue.getAll();
675     }
676
677     private Data getSetting(String haId, String setting)
678             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
679         return getData(haId, BASE_PATH + haId + "/settings/" + setting);
680     }
681
682     private void putSettings(String haId, Data data)
683             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
684         putSettings(haId, data, VALUE_TYPE_STRING);
685     }
686
687     private void putSettings(String haId, Data data, int valueType)
688             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
689         putData(haId, BASE_PATH + haId + "/settings/" + data.getName(), data, valueType);
690     }
691
692     private Data getStatus(String haId, String status)
693             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
694         return getData(haId, BASE_PATH + haId + "/status/" + status);
695     }
696
697     public @Nullable String getRaw(String haId, String path)
698             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
699         return getRaw(haId, path, false);
700     }
701
702     public @Nullable String getRaw(String haId, String path, boolean ignoreResponseCode)
703             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
704         Request request = createRequest(HttpMethod.GET, path);
705         try {
706             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
707             checkResponseCode(HttpStatus.OK_200, request, response, haId, null);
708
709             String responseBody = response.getContentAsString();
710             trackAndLogApiRequest(haId, request, null, response, responseBody);
711
712             if (ignoreResponseCode || response.getStatus() == HttpStatus.OK_200) {
713                 return responseBody;
714             }
715         } catch (InterruptedException | TimeoutException | ExecutionException e) {
716             logger.warn("Failed to get raw! haId={}, path={}, error={}", haId, path, e.getMessage());
717             trackAndLogApiRequest(haId, request, null, null, null);
718             throw new CommunicationException(e);
719         }
720         return null;
721     }
722
723     public String putRaw(String haId, String path, String requestBodyPayload)
724             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
725         Request request = createRequest(HttpMethod.PUT, path).content(new StringContentProvider(requestBodyPayload),
726                 BSH_JSON_V1);
727         try {
728             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
729             checkResponseCode(HttpStatus.NO_CONTENT_204, request, response, haId, requestBodyPayload);
730
731             String responseBody = response.getContentAsString();
732             trackAndLogApiRequest(haId, request, requestBodyPayload, response, responseBody);
733             return responseBody;
734         } catch (InterruptedException | TimeoutException | ExecutionException e) {
735             logger.warn("Failed to put raw! haId={}, path={}, payload={}, error={}", haId, path, requestBodyPayload,
736                     e.getMessage());
737             trackAndLogApiRequest(haId, request, requestBodyPayload, null, null);
738             throw new CommunicationException(e);
739         }
740     }
741
742     private @Nullable Program getProgram(String haId, String path)
743             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
744         Request request = createRequest(HttpMethod.GET, path);
745         try {
746             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
747             checkResponseCode(List.of(HttpStatus.OK_200, HttpStatus.NOT_FOUND_404), request, response, haId, null);
748
749             String responseBody = response.getContentAsString();
750             trackAndLogApiRequest(haId, request, null, response, responseBody);
751
752             if (response.getStatus() == HttpStatus.OK_200) {
753                 return mapToProgram(responseBody);
754             }
755         } catch (InterruptedException | TimeoutException | ExecutionException e) {
756             logger.warn("Failed to get program! haId={}, path={}, error={}", haId, path, e.getMessage());
757             trackAndLogApiRequest(haId, request, null, null, null);
758             throw new CommunicationException(e);
759         }
760         return null;
761     }
762
763     private List<AvailableProgram> getAvailablePrograms(String haId, String path)
764             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
765         Request request = createRequest(HttpMethod.GET, path);
766         try {
767             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
768             checkResponseCode(HttpStatus.OK_200, request, response, haId, null);
769
770             String responseBody = response.getContentAsString();
771             trackAndLogApiRequest(haId, request, null, response, responseBody);
772
773             return mapToAvailablePrograms(responseBody, haId);
774         } catch (InterruptedException | TimeoutException | ExecutionException e) {
775             logger.warn("Failed to get available programs! haId={}, path={}, error={}", haId, path, e.getMessage());
776             trackAndLogApiRequest(haId, request, null, null, null);
777             throw new CommunicationException(e);
778         }
779     }
780
781     private void sendDelete(String haId, String path)
782             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
783         Request request = createRequest(HttpMethod.DELETE, path);
784         try {
785             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
786             checkResponseCode(HttpStatus.NO_CONTENT_204, request, response, haId, null);
787
788             trackAndLogApiRequest(haId, request, null, response, response.getContentAsString());
789         } catch (InterruptedException | TimeoutException | ExecutionException e) {
790             logger.warn("Failed to send delete! haId={}, path={}, error={}", haId, path, e.getMessage());
791             trackAndLogApiRequest(haId, request, null, null, null);
792             throw new CommunicationException(e);
793         }
794     }
795
796     private Data getData(String haId, String path)
797             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
798         Request request = createRequest(HttpMethod.GET, path);
799         try {
800             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
801             checkResponseCode(HttpStatus.OK_200, request, response, haId, null);
802
803             String responseBody = response.getContentAsString();
804             trackAndLogApiRequest(haId, request, null, response, responseBody);
805
806             return mapToState(responseBody);
807         } catch (InterruptedException | TimeoutException | ExecutionException e) {
808             logger.warn("Failed to get data! haId={}, path={}, error={}", haId, path, e.getMessage());
809             trackAndLogApiRequest(haId, request, null, null, null);
810             throw new CommunicationException(e);
811         }
812     }
813
814     private void putData(String haId, String path, Data data, int valueType)
815             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
816         JsonObject innerObject = new JsonObject();
817         innerObject.addProperty("key", data.getName());
818
819         if (data.getValue() != null) {
820             if (valueType == VALUE_TYPE_INT) {
821                 innerObject.addProperty("value", data.getValueAsInt());
822             } else if (valueType == VALUE_TYPE_BOOLEAN) {
823                 innerObject.addProperty("value", data.getValueAsBoolean());
824             } else {
825                 innerObject.addProperty("value", data.getValue());
826             }
827         }
828
829         if (data.getUnit() != null) {
830             innerObject.addProperty("unit", data.getUnit());
831         }
832
833         JsonObject dataObject = new JsonObject();
834         dataObject.add("data", innerObject);
835         String requestBodyPayload = dataObject.toString();
836
837         Request request = createRequest(HttpMethod.PUT, path).content(new StringContentProvider(requestBodyPayload),
838                 BSH_JSON_V1);
839         try {
840             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
841             checkResponseCode(HttpStatus.NO_CONTENT_204, request, response, haId, requestBodyPayload);
842
843             trackAndLogApiRequest(haId, request, requestBodyPayload, response, response.getContentAsString());
844         } catch (InterruptedException | TimeoutException | ExecutionException e) {
845             logger.warn("Failed to put data! haId={}, path={}, data={}, valueType={}, error={}", haId, path, data,
846                     valueType, e.getMessage());
847             trackAndLogApiRequest(haId, request, requestBodyPayload, null, null);
848             throw new CommunicationException(e);
849         }
850     }
851
852     private void putOption(String haId, String path, Option option, boolean asInt)
853             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
854         JsonObject innerObject = new JsonObject();
855         innerObject.addProperty("key", option.getKey());
856
857         if (option.getValue() != null) {
858             if (asInt) {
859                 innerObject.addProperty("value", option.getValueAsInt());
860             } else {
861                 innerObject.addProperty("value", option.getValue());
862             }
863         }
864
865         if (option.getUnit() != null) {
866             innerObject.addProperty("unit", option.getUnit());
867         }
868
869         JsonArray optionsArray = new JsonArray();
870         optionsArray.add(innerObject);
871
872         JsonObject optionsObject = new JsonObject();
873         optionsObject.add("options", optionsArray);
874
875         JsonObject dataObject = new JsonObject();
876         dataObject.add("data", optionsObject);
877
878         String requestBodyPayload = dataObject.toString();
879
880         Request request = createRequest(HttpMethod.PUT, path).content(new StringContentProvider(requestBodyPayload),
881                 BSH_JSON_V1);
882         try {
883             ContentResponse response = sendRequest(request, apiBridgeConfiguration.getClientId());
884             checkResponseCode(HttpStatus.NO_CONTENT_204, request, response, haId, requestBodyPayload);
885
886             trackAndLogApiRequest(haId, request, requestBodyPayload, response, response.getContentAsString());
887         } catch (InterruptedException | TimeoutException | ExecutionException e) {
888             logger.warn("Failed to put option! haId={}, path={}, option={}, asInt={}, error={}", haId, path, option,
889                     asInt, e.getMessage());
890             trackAndLogApiRequest(haId, request, requestBodyPayload, null, null);
891             throw new CommunicationException(e);
892         }
893     }
894
895     private void checkResponseCode(int desiredCode, Request request, ContentResponse response, @Nullable String haId,
896             @Nullable String requestPayload)
897             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
898         checkResponseCode(singletonList(desiredCode), request, response, haId, requestPayload);
899     }
900
901     private void checkResponseCode(List<Integer> desiredCodes, Request request, ContentResponse response,
902             @Nullable String haId, @Nullable String requestPayload)
903             throws CommunicationException, AuthorizationException, ApplianceOfflineException {
904         if (!desiredCodes.contains(HttpStatus.UNAUTHORIZED_401)
905                 && response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
906             logger.debug("Current access token is invalid.");
907             String responseBody = response.getContentAsString();
908             trackAndLogApiRequest(haId, request, requestPayload, response, responseBody);
909             throw new AuthorizationException("Token invalid!");
910         }
911
912         if (!desiredCodes.contains(response.getStatus())) {
913             int code = response.getStatus();
914             String message = response.getReason();
915
916             logger.debug("Invalid HTTP response code {} (allowed: {})", code, desiredCodes);
917             String responseBody = response.getContentAsString();
918             trackAndLogApiRequest(haId, request, requestPayload, response, responseBody);
919
920             responseBody = responseBody == null ? "" : responseBody;
921             if (code == HttpStatus.CONFLICT_409 && responseBody.toLowerCase().contains("error")
922                     && responseBody.toLowerCase().contains("offline")) {
923                 throw new ApplianceOfflineException(code, message, responseBody);
924             } else {
925                 throw new CommunicationException(code, message, responseBody);
926             }
927         }
928     }
929
930     private Program mapToProgram(String json) {
931         ArrayList<Option> optionList = new ArrayList<>();
932         JsonObject responseObject = parseString(json).getAsJsonObject();
933         JsonObject data = responseObject.getAsJsonObject("data");
934         Program result = new Program(data.get("key").getAsString(), optionList);
935         JsonArray options = data.getAsJsonArray("options");
936
937         options.forEach(option -> {
938             JsonObject obj = (JsonObject) option;
939
940             @Nullable
941             String key = obj.get("key") != null ? obj.get("key").getAsString() : null;
942             @Nullable
943             String value = obj.get("value") != null && !obj.get("value").isJsonNull() ? obj.get("value").getAsString()
944                     : null;
945             @Nullable
946             String unit = obj.get("unit") != null ? obj.get("unit").getAsString() : null;
947
948             optionList.add(new Option(key, value, unit));
949         });
950
951         return result;
952     }
953
954     private List<AvailableProgram> mapToAvailablePrograms(String json, String haId) {
955         ArrayList<AvailableProgram> result = new ArrayList<>();
956
957         try {
958             JsonObject responseObject = parseString(json).getAsJsonObject();
959
960             JsonArray programs = responseObject.getAsJsonObject("data").getAsJsonArray("programs");
961             programs.forEach(program -> {
962                 JsonObject obj = (JsonObject) program;
963                 @Nullable
964                 String key = obj.get("key") != null ? obj.get("key").getAsString() : null;
965                 JsonObject constraints = obj.getAsJsonObject("constraints");
966                 boolean available = constraints.get("available") != null && constraints.get("available").getAsBoolean();
967                 @Nullable
968                 String execution = constraints.get("execution") != null ? constraints.get("execution").getAsString()
969                         : null;
970
971                 if (key != null && execution != null) {
972                     result.add(new AvailableProgram(key, available, execution));
973                 }
974             });
975         } catch (Exception e) {
976             logger.warn("Could not parse available programs response! haId={}, error={}", haId, e.getMessage());
977         }
978
979         return result;
980     }
981
982     private List<AvailableProgramOption> mapToAvailableProgramOption(String json, String haId) {
983         ArrayList<AvailableProgramOption> result = new ArrayList<>();
984
985         try {
986             JsonObject responseObject = parseString(json).getAsJsonObject();
987
988             JsonArray options = responseObject.getAsJsonObject("data").getAsJsonArray("options");
989             options.forEach(option -> {
990                 JsonObject obj = (JsonObject) option;
991                 @Nullable
992                 String key = obj.get("key") != null ? obj.get("key").getAsString() : null;
993                 ArrayList<String> allowedValues = new ArrayList<>();
994                 obj.getAsJsonObject("constraints").getAsJsonArray("allowedvalues")
995                         .forEach(value -> allowedValues.add(value.getAsString()));
996
997                 if (key != null) {
998                     result.add(new AvailableProgramOption(key, allowedValues));
999                 }
1000             });
1001         } catch (Exception e) {
1002             logger.warn("Could not parse available program options response! haId={}, error={}", haId, e.getMessage());
1003         }
1004
1005         return result;
1006     }
1007
1008     private HomeAppliance mapToHomeAppliance(String json) {
1009         JsonObject responseObject = parseString(json).getAsJsonObject();
1010
1011         JsonObject data = responseObject.getAsJsonObject("data");
1012
1013         return new HomeAppliance(data.get("haId").getAsString(), data.get("name").getAsString(),
1014                 data.get("brand").getAsString(), data.get("vib").getAsString(), data.get("connected").getAsBoolean(),
1015                 data.get("type").getAsString(), data.get("enumber").getAsString());
1016     }
1017
1018     private ArrayList<HomeAppliance> mapToHomeAppliances(String json) {
1019         final ArrayList<HomeAppliance> result = new ArrayList<>();
1020         JsonObject responseObject = parseString(json).getAsJsonObject();
1021
1022         JsonObject data = responseObject.getAsJsonObject("data");
1023         JsonArray homeappliances = data.getAsJsonArray("homeappliances");
1024
1025         homeappliances.forEach(appliance -> {
1026             JsonObject obj = (JsonObject) appliance;
1027
1028             result.add(new HomeAppliance(obj.get("haId").getAsString(), obj.get("name").getAsString(),
1029                     obj.get("brand").getAsString(), obj.get("vib").getAsString(), obj.get("connected").getAsBoolean(),
1030                     obj.get("type").getAsString(), obj.get("enumber").getAsString()));
1031         });
1032
1033         return result;
1034     }
1035
1036     private Data mapToState(String json) {
1037         JsonObject responseObject = parseString(json).getAsJsonObject();
1038
1039         JsonObject data = responseObject.getAsJsonObject("data");
1040
1041         @Nullable
1042         String unit = data.get("unit") != null ? data.get("unit").getAsString() : null;
1043
1044         return new Data(data.get("key").getAsString(), data.get("value").getAsString(), unit);
1045     }
1046
1047     private Request createRequest(HttpMethod method, String path)
1048             throws AuthorizationException, CommunicationException {
1049         return client.newRequest(apiUrl + path)
1050                 .header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(oAuthClientService))
1051                 .header(HttpHeaders.ACCEPT, BSH_JSON_V1).method(method).timeout(REQUEST_TIMEOUT_SEC, TimeUnit.SECONDS);
1052     }
1053
1054     private void trackAndLogApiRequest(@Nullable String haId, Request request, @Nullable String requestBody,
1055             @Nullable ContentResponse response, @Nullable String responseBody) {
1056         HomeConnectRequest homeConnectRequest = map(request, requestBody);
1057         @Nullable
1058         HomeConnectResponse homeConnectResponse = response != null ? map(response, responseBody) : null;
1059
1060         logApiRequest(haId, homeConnectRequest, homeConnectResponse);
1061         trackApiRequest(homeConnectRequest, homeConnectResponse);
1062     }
1063
1064     private void logApiRequest(@Nullable String haId, HomeConnectRequest homeConnectRequest,
1065             @Nullable HomeConnectResponse homeConnectResponse) {
1066         if (logger.isDebugEnabled()) {
1067             StringBuilder sb = new StringBuilder();
1068
1069             if (haId != null) {
1070                 sb.append("[").append(haId).append("] ");
1071             }
1072
1073             sb.append(homeConnectRequest.getMethod()).append(" ");
1074             if (homeConnectResponse != null) {
1075                 sb.append(homeConnectResponse.getCode()).append(" ");
1076             }
1077             sb.append(homeConnectRequest.getUrl()).append("\n");
1078             homeConnectRequest.getHeader()
1079                     .forEach((key, value) -> sb.append("> ").append(key).append(": ").append(value).append("\n"));
1080
1081             if (homeConnectRequest.getBody() != null) {
1082                 sb.append(homeConnectRequest.getBody()).append("\n");
1083             }
1084
1085             if (homeConnectResponse != null) {
1086                 sb.append("\n");
1087                 homeConnectResponse.getHeader()
1088                         .forEach((key, value) -> sb.append("< ").append(key).append(": ").append(value).append("\n"));
1089             }
1090             if (homeConnectResponse != null && homeConnectResponse.getBody() != null) {
1091                 sb.append(homeConnectResponse.getBody()).append("\n");
1092             }
1093
1094             logger.debug("{}", sb.toString());
1095         }
1096     }
1097
1098     private void trackApiRequest(HomeConnectRequest homeConnectRequest,
1099             @Nullable HomeConnectResponse homeConnectResponse) {
1100         communicationQueue.add(new ApiRequest(ZonedDateTime.now(), homeConnectRequest, homeConnectResponse));
1101     }
1102
1103     private HomeConnectRequest map(Request request, @Nullable String requestBody) {
1104         Map<String, String> headers = new HashMap<>();
1105         request.getHeaders().forEach(field -> headers.put(field.getName(), field.getValue()));
1106
1107         return new HomeConnectRequest(request.getURI().toString(), request.getMethod(), headers,
1108                 requestBody != null ? formatJsonBody(requestBody) : null);
1109     }
1110
1111     private HomeConnectResponse map(ContentResponse response, @Nullable String responseBody) {
1112         Map<String, String> headers = new HashMap<>();
1113         response.getHeaders().forEach(field -> headers.put(field.getName(), field.getValue()));
1114
1115         return new HomeConnectResponse(response.getStatus(), headers,
1116                 responseBody != null ? formatJsonBody(responseBody) : null);
1117     }
1118 }