]> git.basschouten.com Git - openhab-addons.git/blob
48f891c2fd5132fc2ca886fa3c0a02602eb841f0
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.sleepiq.internal.api.impl;
14
15 import java.net.CookieStore;
16 import java.time.ZonedDateTime;
17 import java.time.format.DateTimeFormatter;
18 import java.util.HashMap;
19 import java.util.List;
20 import java.util.Map;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
24
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.client.api.ContentResponse;
29 import org.eclipse.jetty.client.api.Request;
30 import org.eclipse.jetty.client.util.StringContentProvider;
31 import org.eclipse.jetty.http.HttpHeader;
32 import org.eclipse.jetty.http.HttpMethod;
33 import org.eclipse.jetty.http.HttpStatus;
34 import org.openhab.binding.sleepiq.internal.api.CommunicationException;
35 import org.openhab.binding.sleepiq.internal.api.Configuration;
36 import org.openhab.binding.sleepiq.internal.api.LoginException;
37 import org.openhab.binding.sleepiq.internal.api.ResponseFormatException;
38 import org.openhab.binding.sleepiq.internal.api.SleepIQ;
39 import org.openhab.binding.sleepiq.internal.api.SleepIQException;
40 import org.openhab.binding.sleepiq.internal.api.UnauthorizedException;
41 import org.openhab.binding.sleepiq.internal.api.dto.Bed;
42 import org.openhab.binding.sleepiq.internal.api.dto.BedsResponse;
43 import org.openhab.binding.sleepiq.internal.api.dto.Failure;
44 import org.openhab.binding.sleepiq.internal.api.dto.FamilyStatusResponse;
45 import org.openhab.binding.sleepiq.internal.api.dto.FoundationFeaturesResponse;
46 import org.openhab.binding.sleepiq.internal.api.dto.FoundationOutletRequest;
47 import org.openhab.binding.sleepiq.internal.api.dto.FoundationPositionRequest;
48 import org.openhab.binding.sleepiq.internal.api.dto.FoundationPresetRequest;
49 import org.openhab.binding.sleepiq.internal.api.dto.FoundationStatusResponse;
50 import org.openhab.binding.sleepiq.internal.api.dto.LoginInfo;
51 import org.openhab.binding.sleepiq.internal.api.dto.LoginRequest;
52 import org.openhab.binding.sleepiq.internal.api.dto.PauseModeResponse;
53 import org.openhab.binding.sleepiq.internal.api.dto.SleepDataResponse;
54 import org.openhab.binding.sleepiq.internal.api.dto.SleepNumberRequest;
55 import org.openhab.binding.sleepiq.internal.api.dto.Sleeper;
56 import org.openhab.binding.sleepiq.internal.api.dto.SleepersResponse;
57 import org.openhab.binding.sleepiq.internal.api.enums.FoundationActuator;
58 import org.openhab.binding.sleepiq.internal.api.enums.FoundationActuatorSpeed;
59 import org.openhab.binding.sleepiq.internal.api.enums.FoundationOutlet;
60 import org.openhab.binding.sleepiq.internal.api.enums.FoundationOutletOperation;
61 import org.openhab.binding.sleepiq.internal.api.enums.FoundationPreset;
62 import org.openhab.binding.sleepiq.internal.api.enums.Side;
63 import org.openhab.binding.sleepiq.internal.api.enums.SleepDataInterval;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
66
67 import com.google.gson.Gson;
68 import com.google.gson.JsonSyntaxException;
69
70 /**
71  * The {@link SleepIQImpl} class handles all interactions with the sleepiq service.
72  *
73  * @author Gregory Moyer - Initial contribution
74  * @author Mark Hilbush - Added foundation functionality
75  */
76 @NonNullByDefault
77 public class SleepIQImpl implements SleepIQ {
78     private static final String PARAM_KEY = "_k";
79     private static final String USER_AGENT = "SleepIQ/1593766370 CFNetwork/1185.2 Darwin/20.0.0";
80
81     private static final Gson GSON = GsonGenerator.create(false);
82
83     private final Logger logger = LoggerFactory.getLogger(SleepIQImpl.class);
84
85     private final HttpClient httpClient;
86     private final CookieStore cookieStore;
87
88     protected final Configuration config;
89
90     private final LoginRequest loginRequest = new LoginRequest();
91     private volatile @Nullable LoginInfo loginInfo;
92
93     public SleepIQImpl(Configuration config, HttpClient httpClient) {
94         this.config = config;
95         this.httpClient = httpClient;
96         cookieStore = httpClient.getCookieStore();
97         loginRequest.setLogin(config.getUsername());
98         loginRequest.setPassword(config.getPassword());
99     }
100
101     @Override
102     public void shutdown() {
103         cookieStore.removeAll();
104     }
105
106     @Override
107     public @Nullable LoginInfo login() throws LoginException, UnauthorizedException {
108         logger.trace("SleepIQ: login: loginInfo={}", loginInfo);
109         if (loginInfo == null) {
110             synchronized (this) {
111                 if (loginInfo == null) {
112                     Request request = httpClient.newRequest(config.getBaseUri()).path(Endpoints.login())
113                             .agent(USER_AGENT).header(HttpHeader.CONTENT_TYPE, "application/json")
114                             .timeout(10, TimeUnit.SECONDS).method(HttpMethod.PUT)
115                             .content(new StringContentProvider(GSON.toJson(loginRequest)), "application/json");
116                     logger.trace("SleepIQ: login: request url={}", request.getURI());
117
118                     try {
119                         ContentResponse response = request.send();
120                         logger.debug("SleepIQ: login: status={}, content={}", response.getStatus(),
121                                 response.getContentAsString());
122                         if (isUnauthorized(response)) {
123                             Failure failure = GSON.fromJson(response.getContentAsString(), Failure.class);
124                             String message = failure != null ? failure.getError().getMessage() : "Login unauthorized";
125                             throw new UnauthorizedException(message);
126                         }
127                         if (isNotOk(response)) {
128                             Failure failure = GSON.fromJson(response.getContentAsString(), Failure.class);
129                             String message = failure != null ? failure.getError().getMessage() : "Login failed";
130                             throw new LoginException(message);
131                         }
132                         try {
133                             loginInfo = GSON.fromJson(response.getContentAsString(), LoginInfo.class);
134                         } catch (JsonSyntaxException e) {
135                             throw new LoginException("Failed to parse 'login' response");
136                         }
137                     } catch (InterruptedException | TimeoutException | ExecutionException e) {
138                         logger.info("SleepIQ: login: Login failed message={}", e.getMessage(), e);
139                         throw new LoginException("Problem communicating with SleepIQ cloud service");
140                     }
141                 }
142             }
143         }
144         return loginInfo;
145     }
146
147     @Override
148     public List<Bed> getBeds() throws LoginException, CommunicationException, ResponseFormatException {
149         try {
150             String contentResponse = cloudRequest(Endpoints.bed());
151             BedsResponse response = GSON.fromJson(contentResponse, BedsResponse.class);
152             if (response != null) {
153                 return response.getBeds();
154             } else {
155                 throw new ResponseFormatException("Failed to get a valid 'beds' response from cloud");
156             }
157         } catch (JsonSyntaxException e) {
158             throw new ResponseFormatException("Failed to parse 'beds' response");
159         }
160     }
161
162     @Override
163     public FamilyStatusResponse getFamilyStatus()
164             throws LoginException, ResponseFormatException, CommunicationException {
165         try {
166             String contentResponse = cloudRequest(Endpoints.familyStatus());
167             FamilyStatusResponse response = GSON.fromJson(contentResponse, FamilyStatusResponse.class);
168             if (response != null) {
169                 return response;
170             } else {
171                 throw new ResponseFormatException("Failed to get a valid 'familyStatus' response from cloud");
172             }
173         } catch (JsonSyntaxException e) {
174             throw new ResponseFormatException("Failed to parse 'familyStatus' response");
175         }
176     }
177
178     @Override
179     public List<Sleeper> getSleepers() throws LoginException, ResponseFormatException, CommunicationException {
180         try {
181             String contentResponse = cloudRequest(Endpoints.sleeper());
182             SleepersResponse response = GSON.fromJson(contentResponse, SleepersResponse.class);
183             if (response != null) {
184                 return response.getSleepers();
185             } else {
186                 throw new ResponseFormatException("Failed to get a valid 'sleepers' response from cloud");
187             }
188         } catch (JsonSyntaxException e) {
189             throw new ResponseFormatException("Failed to parse 'sleepers' response");
190         }
191     }
192
193     @Override
194     public PauseModeResponse getPauseMode(String bedId)
195             throws LoginException, ResponseFormatException, CommunicationException {
196         try {
197             String contentResponse = cloudRequest(Endpoints.pauseMode(bedId));
198             PauseModeResponse response = GSON.fromJson(contentResponse, PauseModeResponse.class);
199             if (response != null) {
200                 return response;
201             } else {
202                 throw new ResponseFormatException("Failed to get a valid 'pauseMode' response from cloud");
203             }
204         } catch (JsonSyntaxException e) {
205             throw new ResponseFormatException("Failed to parse 'pauseMode' response");
206         }
207     }
208
209     @Override
210     public SleepDataResponse getSleepData(String sleeperId, SleepDataInterval interval)
211             throws LoginException, ResponseFormatException, CommunicationException {
212         try {
213             Map<String, String> parameters = new HashMap<>();
214             parameters.put("interval", interval.value());
215             parameters.put("sleeper", sleeperId);
216             parameters.put("includeSlices", "false");
217             parameters.put("date", formatSleepDataDate(ZonedDateTime.now()));
218             String contentResponse = cloudRequest(Endpoints.sleepData(), parameters);
219             SleepDataResponse response = GSON.fromJson(contentResponse, SleepDataResponse.class);
220             if (response != null) {
221                 return response;
222             } else {
223                 throw new ResponseFormatException("Failed to get a valid 'sleepData' response from cloud");
224             }
225         } catch (JsonSyntaxException e) {
226             throw new ResponseFormatException("Failed to parse 'sleepData' response");
227         }
228     }
229
230     private String formatSleepDataDate(ZonedDateTime zonedDateTime) {
231         return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(zonedDateTime);
232     }
233
234     @Override
235     public void setSleepNumber(String bedId, Side side, int sleepNumber)
236             throws LoginException, ResponseFormatException, CommunicationException {
237         String body = GSON.toJson(new SleepNumberRequest().withBedId(bedId).withSleepNumber(sleepNumber).withSide(side),
238                 SleepNumberRequest.class);
239         logger.debug("SleepIQ: setSleepNumber: Request body={}", body);
240         cloudRequest(Endpoints.sleepNumber(bedId), null, body);
241     }
242
243     @Override
244     public void setPauseMode(String bedId, boolean pauseMode)
245             throws LoginException, ResponseFormatException, CommunicationException {
246         logger.debug("SleepIQ: setPauseMode: command={}", pauseMode);
247         Map<String, String> requestParameters = new HashMap<>();
248         requestParameters.put("mode", pauseMode ? "on" : "off");
249         cloudRequest(Endpoints.pauseMode(bedId), requestParameters, "");
250     }
251
252     @Override
253     public FoundationFeaturesResponse getFoundationFeatures(String bedId)
254             throws LoginException, ResponseFormatException, CommunicationException {
255         try {
256             String contentResponse = cloudRequest(Endpoints.foundationFeatures(bedId));
257             FoundationFeaturesResponse response = GSON.fromJson(contentResponse, FoundationFeaturesResponse.class);
258             if (response != null) {
259                 logger.debug("SleepIQ: {}", response);
260                 return response;
261             } else {
262                 throw new ResponseFormatException("Failed to get a valid 'foundationFeatures' response from cloud");
263             }
264         } catch (JsonSyntaxException e) {
265             throw new ResponseFormatException("Failed to parse 'foundationFeatures' response");
266         }
267     }
268
269     @Override
270     public FoundationStatusResponse getFoundationStatus(String bedId) throws LoginException, SleepIQException {
271         try {
272             String contentResponse = cloudRequest(Endpoints.foundationStatus(bedId));
273             FoundationStatusResponse response = GSON.fromJson(contentResponse, FoundationStatusResponse.class);
274             if (response != null) {
275                 logger.debug("SleepIQ: {}", response);
276                 return response;
277             } else {
278                 throw new ResponseFormatException("Failed to get a valid 'foundationStatus' response from cloud");
279             }
280         } catch (JsonSyntaxException e) {
281             throw new ResponseFormatException("Failed to parse 'foundationStatus' response");
282         }
283     }
284
285     @Override
286     public void setFoundationPreset(String bedId, Side side, FoundationPreset preset, FoundationActuatorSpeed speed)
287             throws LoginException, SleepIQException {
288         String body = GSON.toJson(new FoundationPresetRequest().withSide(side).withFoundationPreset(preset)
289                 .withFoundationActuatorSpeed(speed), FoundationPresetRequest.class);
290         logger.debug("SleepIQ: setFoundationPreset: Request body={}", body);
291         cloudRequest(Endpoints.foundationPreset(bedId), null, body);
292     }
293
294     @Override
295     public void setFoundationPosition(String bedId, Side side, FoundationActuator actuator, int position,
296             FoundationActuatorSpeed speed) throws LoginException, SleepIQException {
297         String body = GSON.toJson(new FoundationPositionRequest().withSide(side).withPosition(position)
298                 .withFoundationActuator(actuator).withFoundationActuatorSpeed(speed), FoundationPositionRequest.class);
299         logger.debug("SleepIQ: setFoundationPosition: Request body={}", body);
300         cloudRequest(Endpoints.foundationPosition(bedId), null, body);
301     }
302
303     @Override
304     public void setFoundationOutlet(String bedId, FoundationOutlet outlet, FoundationOutletOperation operation)
305             throws LoginException, SleepIQException {
306         String body = GSON.toJson(
307                 new FoundationOutletRequest().withFoundationOutlet(outlet).withFoundationOutletOperation(operation),
308                 FoundationOutletRequest.class);
309         logger.debug("SleepIQ: setFoundationOutlet: Request body={}", body);
310         cloudRequest(Endpoints.foundationOutlet(bedId), null, body);
311     }
312
313     private String cloudRequest(String endpoint)
314             throws LoginException, ResponseFormatException, CommunicationException {
315         return cloudRequest(endpoint, null, null);
316     }
317
318     private String cloudRequest(String endpoint, Map<String, String> parameters)
319             throws LoginException, ResponseFormatException, CommunicationException {
320         return cloudRequest(endpoint, parameters, null);
321     }
322
323     private String cloudRequest(String endpoint, @Nullable Map<String, String> parameters, @Nullable String body)
324             throws LoginException, ResponseFormatException, CommunicationException {
325         logger.debug("SleepIQ: cloudRequest: Invoke endpoint={}", endpoint);
326         ContentResponse response = (body == null ? doGet(endpoint, parameters) : doPut(endpoint, parameters, body));
327         if (isUnauthorized(response)) {
328             logger.debug("SleepIQ: cloudGetRequest: UNAUTHORIZED, reset login");
329             // Force new login and try again
330             resetLogin();
331             response = (body == null ? doGet(endpoint, parameters) : doPut(endpoint, parameters, body));
332         }
333         if (isNotOk(response)) {
334             logger.debug("SleepIQ.cloudRequest: ResponseFormatException on call to endpoint {}", endpoint);
335             throw new ResponseFormatException(String.format("Cloud API returned error: status=%d, message=%s",
336                     response.getStatus(), HttpStatus.getCode(response.getStatus()).getMessage()));
337         }
338         return response.getContentAsString();
339     }
340
341     private ContentResponse doGet(String endpoint, @Nullable Map<String, String> parameters)
342             throws CommunicationException, LoginException {
343         LoginInfo login = login();
344         Request request = httpClient.newRequest(config.getBaseUri()).path(endpoint).param(PARAM_KEY, login.getKey())
345                 .agent(USER_AGENT).header(HttpHeader.CONTENT_TYPE, "application/json").timeout(10, TimeUnit.SECONDS)
346                 .method(HttpMethod.GET);
347         return doRequest(request, parameters);
348     }
349
350     private ContentResponse doPut(String endpoint, @Nullable Map<String, String> parameters, String body)
351             throws CommunicationException, LoginException {
352         LoginInfo login = login();
353         Request request = httpClient.newRequest(config.getBaseUri()).path(endpoint).param(PARAM_KEY, login.getKey())
354                 .agent(USER_AGENT).header(HttpHeader.CONTENT_TYPE, "application/json").timeout(10, TimeUnit.SECONDS)
355                 .method(HttpMethod.PUT).content(new StringContentProvider(body), "application/json");
356         return doRequest(request, parameters);
357     }
358
359     private synchronized ContentResponse doRequest(Request request, @Nullable Map<String, String> parameters)
360             throws CommunicationException {
361         try {
362             if (parameters != null) {
363                 for (String key : parameters.keySet()) {
364                     request.param(key, parameters.get(key));
365                 }
366             }
367             addCookiesToRequest(request);
368             logger.debug("SleepIQ: doRequest: request url={}", request.getURI());
369             ContentResponse response = request.send();
370             logger.trace("SleepIQ: doRequest: status={} response={}", response.getStatus(),
371                     response.getContentAsString());
372             return response;
373         } catch (InterruptedException | TimeoutException | ExecutionException e) {
374             logger.debug("SleepIQ: doRequest: Exception message={}", e.getMessage(), e);
375             throw new CommunicationException("Communication error while accessing API: " + e.getMessage());
376         }
377     }
378
379     private void addCookiesToRequest(Request request) {
380         cookieStore.get(config.getBaseUri()).forEach(cookie -> {
381             request.cookie(cookie);
382         });
383     }
384
385     private boolean isUnauthorized(ContentResponse response) {
386         return response.getStatus() == HttpStatus.Code.UNAUTHORIZED.getCode();
387     }
388
389     private boolean isNotOk(ContentResponse response) {
390         return response.getStatus() != HttpStatus.Code.OK.getCode();
391     }
392
393     private synchronized void resetLogin() {
394         logger.debug("SleepIQ: resetLogin: Set loginInfo=null to force login on next transaction");
395         loginInfo = null;
396     }
397 }