2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.sleepiq.internal.api.impl;
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;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
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;
67 import com.google.gson.Gson;
68 import com.google.gson.JsonSyntaxException;
71 * The {@link SleepIQImpl} class handles all interactions with the sleepiq service.
73 * @author Gregory Moyer - Initial contribution
74 * @author Mark Hilbush - Added foundation functionality
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";
81 private static final Gson GSON = GsonGenerator.create(false);
83 private final Logger logger = LoggerFactory.getLogger(SleepIQImpl.class);
85 private final HttpClient httpClient;
86 private final CookieStore cookieStore;
88 protected final Configuration config;
90 private final LoginRequest loginRequest = new LoginRequest();
91 private volatile @Nullable LoginInfo loginInfo;
93 public SleepIQImpl(Configuration config, HttpClient httpClient) {
95 this.httpClient = httpClient;
96 cookieStore = httpClient.getCookieStore();
97 loginRequest.setLogin(config.getUsername());
98 loginRequest.setPassword(config.getPassword());
102 public void shutdown() {
103 cookieStore.removeAll();
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());
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);
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);
133 loginInfo = GSON.fromJson(response.getContentAsString(), LoginInfo.class);
134 } catch (JsonSyntaxException e) {
135 throw new LoginException("Failed to parse 'login' response");
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");
148 public List<Bed> getBeds() throws LoginException, CommunicationException, ResponseFormatException {
150 String contentResponse = cloudRequest(Endpoints.bed());
151 BedsResponse response = GSON.fromJson(contentResponse, BedsResponse.class);
152 if (response != null) {
153 return response.getBeds();
155 throw new ResponseFormatException("Failed to get a valid 'beds' response from cloud");
157 } catch (JsonSyntaxException e) {
158 throw new ResponseFormatException("Failed to parse 'beds' response");
163 public FamilyStatusResponse getFamilyStatus()
164 throws LoginException, ResponseFormatException, CommunicationException {
166 String contentResponse = cloudRequest(Endpoints.familyStatus());
167 FamilyStatusResponse response = GSON.fromJson(contentResponse, FamilyStatusResponse.class);
168 if (response != null) {
171 throw new ResponseFormatException("Failed to get a valid 'familyStatus' response from cloud");
173 } catch (JsonSyntaxException e) {
174 throw new ResponseFormatException("Failed to parse 'familyStatus' response");
179 public List<Sleeper> getSleepers() throws LoginException, ResponseFormatException, CommunicationException {
181 String contentResponse = cloudRequest(Endpoints.sleeper());
182 SleepersResponse response = GSON.fromJson(contentResponse, SleepersResponse.class);
183 if (response != null) {
184 return response.getSleepers();
186 throw new ResponseFormatException("Failed to get a valid 'sleepers' response from cloud");
188 } catch (JsonSyntaxException e) {
189 throw new ResponseFormatException("Failed to parse 'sleepers' response");
194 public PauseModeResponse getPauseMode(String bedId)
195 throws LoginException, ResponseFormatException, CommunicationException {
197 String contentResponse = cloudRequest(Endpoints.pauseMode(bedId));
198 PauseModeResponse response = GSON.fromJson(contentResponse, PauseModeResponse.class);
199 if (response != null) {
202 throw new ResponseFormatException("Failed to get a valid 'pauseMode' response from cloud");
204 } catch (JsonSyntaxException e) {
205 throw new ResponseFormatException("Failed to parse 'pauseMode' response");
210 public SleepDataResponse getSleepData(String sleeperId, SleepDataInterval interval)
211 throws LoginException, ResponseFormatException, CommunicationException {
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) {
223 throw new ResponseFormatException("Failed to get a valid 'sleepData' response from cloud");
225 } catch (JsonSyntaxException e) {
226 throw new ResponseFormatException("Failed to parse 'sleepData' response");
230 private String formatSleepDataDate(ZonedDateTime zonedDateTime) {
231 return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(zonedDateTime);
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);
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, "");
253 public FoundationFeaturesResponse getFoundationFeatures(String bedId)
254 throws LoginException, ResponseFormatException, CommunicationException {
256 String contentResponse = cloudRequest(Endpoints.foundationFeatures(bedId));
257 FoundationFeaturesResponse response = GSON.fromJson(contentResponse, FoundationFeaturesResponse.class);
258 if (response != null) {
259 logger.debug("SleepIQ: {}", response);
262 throw new ResponseFormatException("Failed to get a valid 'foundationFeatures' response from cloud");
264 } catch (JsonSyntaxException e) {
265 throw new ResponseFormatException("Failed to parse 'foundationFeatures' response");
270 public FoundationStatusResponse getFoundationStatus(String bedId) throws LoginException, SleepIQException {
272 String contentResponse = cloudRequest(Endpoints.foundationStatus(bedId));
273 FoundationStatusResponse response = GSON.fromJson(contentResponse, FoundationStatusResponse.class);
274 if (response != null) {
275 logger.debug("SleepIQ: {}", response);
278 throw new ResponseFormatException("Failed to get a valid 'foundationStatus' response from cloud");
280 } catch (JsonSyntaxException e) {
281 throw new ResponseFormatException("Failed to parse 'foundationStatus' response");
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);
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);
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);
313 private String cloudRequest(String endpoint)
314 throws LoginException, ResponseFormatException, CommunicationException {
315 return cloudRequest(endpoint, null, null);
318 private String cloudRequest(String endpoint, Map<String, String> parameters)
319 throws LoginException, ResponseFormatException, CommunicationException {
320 return cloudRequest(endpoint, parameters, null);
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
331 response = (body == null ? doGet(endpoint, parameters) : doPut(endpoint, parameters, body));
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()));
338 return response.getContentAsString();
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);
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);
359 private synchronized ContentResponse doRequest(Request request, @Nullable Map<String, String> parameters)
360 throws CommunicationException {
362 if (parameters != null) {
363 for (String key : parameters.keySet()) {
364 request.param(key, parameters.get(key));
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());
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());
379 private void addCookiesToRequest(Request request) {
380 cookieStore.get(config.getBaseUri()).forEach(cookie -> {
381 request.cookie(cookie);
385 private boolean isUnauthorized(ContentResponse response) {
386 return response.getStatus() == HttpStatus.Code.UNAUTHORIZED.getCode();
389 private boolean isNotOk(ContentResponse response) {
390 return response.getStatus() != HttpStatus.Code.OK.getCode();
393 private synchronized void resetLogin() {
394 logger.debug("SleepIQ: resetLogin: Set loginInfo=null to force login on next transaction");