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.surepetcare.internal;
15 import java.io.IOException;
16 import java.net.HttpURLConnection;
17 import java.net.InetAddress;
18 import java.net.NetworkInterface;
19 import java.net.ProtocolException;
20 import java.net.SocketException;
21 import java.net.UnknownHostException;
22 import java.time.ZonedDateTime;
23 import java.util.Arrays;
24 import java.util.Enumeration;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.eclipse.jetty.client.api.ContentResponse;
33 import org.eclipse.jetty.client.api.Request;
34 import org.eclipse.jetty.client.util.StringContentProvider;
35 import org.eclipse.jetty.http.HttpHeader;
36 import org.eclipse.jetty.http.HttpMethod;
37 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDevice;
38 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDeviceControl;
39 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDeviceCurfewList;
40 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDeviceStatus;
41 import org.openhab.binding.surepetcare.internal.dto.SurePetcareHousehold;
42 import org.openhab.binding.surepetcare.internal.dto.SurePetcareLoginCredentials;
43 import org.openhab.binding.surepetcare.internal.dto.SurePetcareLoginResponse;
44 import org.openhab.binding.surepetcare.internal.dto.SurePetcarePet;
45 import org.openhab.binding.surepetcare.internal.dto.SurePetcarePetStatus;
46 import org.openhab.binding.surepetcare.internal.dto.SurePetcareTag;
47 import org.openhab.binding.surepetcare.internal.dto.SurePetcareTopology;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
51 import com.google.gson.JsonElement;
52 import com.google.gson.JsonObject;
53 import com.google.gson.JsonParser;
54 import com.google.gson.JsonSyntaxException;
57 * The {@link SurePetcareAPIHelper} is a helper class to abstract the Sure Petcare API. It handles authentication and
58 * all JSON API calls. If an API call fails it automatically refreshes the authentication token and retries.
60 * @author Rene Scherer - Initial contribution
63 public class SurePetcareAPIHelper {
65 private final Logger logger = LoggerFactory.getLogger(SurePetcareAPIHelper.class);
67 private static final String API_URL = "https://app.api.surehub.io/api";
68 private static final String TOPOLOGY_URL = API_URL + "/me/start";
69 private static final String PET_BASE_URL = API_URL + "/pet";
70 private static final String PET_STATUS_URL = API_URL + "/pet/?with[]=status&with[]=photo";
71 private static final String DEVICE_BASE_URL = API_URL + "/device";
72 private static final String LOGIN_URL = API_URL + "/auth/login";
74 public static final int DEFAULT_DEVICE_ID = 12344711;
76 private String authenticationToken = "";
77 private String username = "";
78 private String password = "";
80 private final String userAgent;
82 private @NonNullByDefault({}) HttpClient httpClient;
83 private SurePetcareTopology topologyCache = new SurePetcareTopology();
85 public SurePetcareAPIHelper() {
86 userAgent = "openHAB/" + org.openhab.core.OpenHAB.getVersion();
90 * Sets the httpClient object to be used for API calls to Sure Petcare.
92 * @param httpClient the client to be used.
94 public void setHttpClient(@Nullable HttpClient httpClient) {
95 this.httpClient = httpClient;
99 * This method uses the provided username and password to obtain an authentication token used for subsequent API
102 * @param username The Sure Petcare username (email address) to be used
103 * @param password The password
104 * @throws AuthenticationException
106 public synchronized void login(String username, String password) throws AuthenticationException {
108 Request request = httpClient.POST(LOGIN_URL);
109 setConnectionHeaders(request);
110 request.content(new StringContentProvider(SurePetcareConstants.GSON
111 .toJson(new SurePetcareLoginCredentials(username, password, getDeviceId().toString()))));
112 ContentResponse response = request.timeout(SurePetcareConstants.DEFAULT_HTTP_TIMEOUT, TimeUnit.SECONDS)
114 if (response.getStatus() == HttpURLConnection.HTTP_OK) {
115 SurePetcareLoginResponse loginResponse = SurePetcareConstants.GSON
116 .fromJson(response.getContentAsString(), SurePetcareLoginResponse.class);
117 if (loginResponse != null) {
118 authenticationToken = loginResponse.getToken();
119 this.username = username;
120 this.password = password;
121 logger.debug("Login successful");
123 throw new AuthenticationException("Invalid JSON response from login");
126 logger.debug("HTTP Response Code: {}", response.getStatus());
127 logger.debug("HTTP Response Msg: {}", response.getReason());
128 throw new AuthenticationException(
129 "HTTP response " + response.getStatus() + " - " + response.getReason());
131 } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
132 throw new AuthenticationException(e);
137 * Refreshes the whole topology, i.e. all devices, pets etc. through a call to the Sure Petcare API. The APi call is
138 * quite resource intensive and should be used very infrequently.
140 public synchronized void updateTopologyCache() {
142 SurePetcareTopology tc = SurePetcareConstants.GSON.fromJson(getDataFromApi(TOPOLOGY_URL),
143 SurePetcareTopology.class);
147 } catch (JsonSyntaxException | SurePetcareApiException e) {
148 logger.warn("Exception caught during topology cache update", e);
153 * Refreshes the pet information. This API call can be used more frequently.
154 * Unlike for the "position" API endpoint, there is none for the "status" (activity/feeding).
155 * We also dont need to specify a "petId" in the call, so we just need to call the API once.
157 public synchronized void updatePetStatus() {
159 String url = PET_STATUS_URL;
160 topologyCache.pets = Arrays
161 .asList(SurePetcareConstants.GSON.fromJson(getDataFromApi(url), SurePetcarePet[].class));
162 } catch (JsonSyntaxException | SurePetcareApiException e) {
163 logger.warn("Exception caught during pet status update", e);
168 * Returns the whole topology.
170 * @return the topology
172 public final SurePetcareTopology getTopology() {
173 return topologyCache;
177 * Returns a household object if one exists with the given id, otherwise null.
179 * @param id the household id
180 * @return the household with the given id
182 public final @Nullable SurePetcareHousehold getHousehold(String id) {
183 return topologyCache.getById(topologyCache.households, id);
187 * Returns a device object if one exists with the given id, otherwise null.
189 * @param id the device id
190 * @return the device with the given id
192 public final @Nullable SurePetcareDevice getDevice(String id) {
193 return topologyCache.getById(topologyCache.devices, id);
197 * Returns a pet object if one exists with the given id, otherwise null.
199 * @param id the pet id
200 * @return the pet with the given id
202 public final @Nullable SurePetcarePet getPet(String id) {
203 return topologyCache.getById(topologyCache.pets, id);
207 * Returns a tag object if one exists with the given id, otherwise null.
209 * @param id the tag id
210 * @return the tag with the given id
212 public final @Nullable SurePetcareTag getTag(String id) {
213 return topologyCache.getById(topologyCache.tags, id);
217 * Returns the status object if a pet exists with the given id, otherwise null.
219 * @param id the pet id
220 * @return the status of the pet with the given id
222 public final @Nullable SurePetcarePetStatus getPetStatus(String id) {
223 SurePetcarePet pet = topologyCache.getById(topologyCache.pets, id);
224 return pet == null ? null : pet.status;
228 * Updates the pet location through an API call to the Sure Petcare API.
231 * @param newLocationId the id of the new location
232 * @throws SurePetcareApiException
234 public synchronized void setPetLocation(SurePetcarePet pet, Integer newLocationId, ZonedDateTime newSince)
235 throws SurePetcareApiException {
236 pet.status.activity.where = newLocationId;
237 pet.status.activity.since = newSince;
238 String url = PET_BASE_URL + "/" + pet.id.toString() + "/position";
239 setDataThroughApi(url, HttpMethod.POST, pet.status.activity);
243 * Updates the device locking mode through an API call to the Sure Petcare API.
245 * @param device the device
246 * @param newLockingModeId the id of the new locking mode
247 * @throws SurePetcareApiException
249 public synchronized void setDeviceLockingMode(SurePetcareDevice device, Integer newLockingModeId)
250 throws SurePetcareApiException {
251 // post new JSON control structure to API
252 SurePetcareDeviceControl control = new SurePetcareDeviceControl();
253 control.lockingModeId = newLockingModeId;
254 String ctrlurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
255 setDataThroughApi(ctrlurl, HttpMethod.PUT, control);
257 // now we're fetching the new state back for the cache
258 String devurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/status";
259 SurePetcareDeviceStatus newStatus = SurePetcareConstants.GSON.fromJson(getDataFromApi(devurl),
260 SurePetcareDeviceStatus.class);
261 device.status.assign(newStatus);
265 * Updates the device led mode through an API call to the Sure Petcare API.
267 * @param device the device
268 * @param newLedModeId the id of the new led mode
269 * @throws SurePetcareApiException
271 public synchronized void setDeviceLedMode(SurePetcareDevice device, Integer newLedModeId)
272 throws SurePetcareApiException {
273 // post new JSON control structure to API
274 SurePetcareDeviceControl control = new SurePetcareDeviceControl();
275 control.ledModeId = newLedModeId;
276 String ctrlurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
277 setDataThroughApi(ctrlurl, HttpMethod.PUT, control);
279 // now we're fetching the new state back for the cache
280 String devurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/status";
281 SurePetcareDeviceStatus newStatus = SurePetcareConstants.GSON.fromJson(getDataFromApi(devurl),
282 SurePetcareDeviceStatus.class);
283 device.status.assign(newStatus);
287 * Updates all curfews through an API call to the Sure Petcare API.
289 * @param device the device
290 * @param curfewList the list of curfews
291 * @throws SurePetcareApiException
293 public synchronized void setCurfews(SurePetcareDevice device, SurePetcareDeviceCurfewList curfewList)
294 throws SurePetcareApiException {
295 // post new JSON control structure to API
296 SurePetcareDeviceControl control = new SurePetcareDeviceControl();
297 control.curfewList = curfewList.compact();
298 String ctrlurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
299 setDataThroughApi(ctrlurl, HttpMethod.PUT, control);
301 // now we're fetching the new state back for the cache
302 String devurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
303 SurePetcareDeviceControl newControl = SurePetcareConstants.GSON.fromJson(getDataFromApi(devurl),
304 SurePetcareDeviceControl.class);
305 if (newControl != null) {
306 newControl.curfewList = newControl.curfewList.order();
308 device.control = newControl;
312 * Returns a unique device id used during the authentication process with the Sure Petcare API. The id is derived
313 * from the local MAC address or hostname.
315 * @return a unique device id
317 public final Integer getDeviceId() {
319 return getDeviceId(NetworkInterface.getNetworkInterfaces(), InetAddress.getLocalHost());
320 } catch (UnknownHostException | SocketException e) {
321 logger.warn("unable to discover mac or hostname, assigning default device id {}", DEFAULT_DEVICE_ID);
322 return DEFAULT_DEVICE_ID;
327 * Returns a unique device id used during the authentication process with the Sure Petcare API. The id is derived
328 * from the local MAC address or hostname provided as arguments
330 * @param interfaces a list of interface of this host
331 * @param localHostAddress the ip address of the localhost
332 * @return a unique device id
334 public final int getDeviceId(Enumeration<NetworkInterface> interfaces, InetAddress localHostAddress) {
335 int decimal = DEFAULT_DEVICE_ID;
337 if (interfaces.hasMoreElements()) {
338 NetworkInterface netif = interfaces.nextElement();
340 byte[] mac = netif.getHardwareAddress();
342 StringBuilder sb = new StringBuilder();
343 for (int i = 0; i < mac.length; i++) {
344 sb.append(String.format("%02x", mac[i]));
346 String hex = sb.toString();
347 decimal = Math.abs((int) (Long.parseUnsignedLong(hex, 16) % Integer.MAX_VALUE));
348 logger.debug("current MAC address: {}, device id: {}", hex, decimal);
350 String hostname = localHostAddress.getHostName();
351 decimal = hostname.hashCode();
352 logger.debug("current hostname: {}, device id: {}", hostname, decimal);
355 String hostname = localHostAddress.getHostName();
356 decimal = hostname.hashCode();
357 logger.debug("current hostname: {}, device id: {}", hostname, decimal);
359 } catch (SocketException e) {
360 logger.debug("Socket Exception", e);
366 * Sets a set of required HTTP headers for the JSON API calls.
368 * @param request the HTTP connection
369 * @throws ProtocolException
371 private void setConnectionHeaders(Request request) throws ProtocolException {
373 request.header(HttpHeader.ACCEPT, "application/json, text/plain, */*");
374 request.header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate");
375 request.header(HttpHeader.AUTHORIZATION, "Bearer " + authenticationToken);
376 request.header(HttpHeader.CONNECTION, "keep-alive");
377 request.header(HttpHeader.CONTENT_TYPE, "application/json; utf-8");
378 request.header(HttpHeader.USER_AGENT, userAgent);
379 request.header(HttpHeader.REFERER, "https://surepetcare.io/");
380 request.header("Origin", "https://surepetcare.io");
381 request.header("Referer", "https://surepetcare.io");
382 request.header("X-Requested-With", "com.sureflap.surepetcare");
386 * Return the "data" element of the API result as a JsonElement.
388 * @param url The URL of the API call.
389 * @return The "data" element of the API result.
390 * @throws SurePetcareApiException
392 private JsonElement getDataFromApi(String url) throws SurePetcareApiException {
393 String apiResult = getResultFromApi(url);
394 JsonObject object = (JsonObject) JsonParser.parseString(apiResult);
395 return object.get("data");
399 * Sends a given object as a JSON payload to the API.
402 * @param requestMethod the request method (POST, PUT etc.)
403 * @param payload an object used for the payload
404 * @throws SurePetcareApiException
406 private void setDataThroughApi(String url, HttpMethod method, Object payload) throws SurePetcareApiException {
407 String jsonPayload = SurePetcareConstants.GSON.toJson(payload);
408 postDataThroughAPI(url, method, jsonPayload);
412 * Returns the result of a GET API call as a string.
415 * @return a JSON string with the API result
416 * @throws SurePetcareApiException
418 private String getResultFromApi(String url) throws SurePetcareApiException {
419 Request request = httpClient.newRequest(url).method(HttpMethod.GET);
420 ContentResponse response = executeAPICall(request);
421 String responseData = response.getContentAsString();
422 logger.debug("API execution successful, response: {}", responseData);
427 * Uses the given request method to send a JSON string to an API.
430 * @param method the required request method (POST, PUT etc.)
431 * @param jsonPayload the JSON string
432 * @throws SurePetcareApiException
434 private void postDataThroughAPI(String url, HttpMethod method, String jsonPayload) throws SurePetcareApiException {
435 logger.debug("postDataThroughAPI URL: {}", url);
436 logger.debug("postDataThroughAPI Payload: {}", jsonPayload);
437 Request request = httpClient.newRequest(url).method(method);
438 request.content(new StringContentProvider(jsonPayload));
439 executeAPICall(request);
443 * Uses the given request execute the API call. If it receives an HTTP_UNAUTHORIZED response, it will automatically
444 * login again and retry.
446 * @param request the Request
447 * @return the response from the API
448 * @throws SurePetcareApiException
450 private ContentResponse executeAPICall(Request request) throws SurePetcareApiException {
452 while (retries > 0) {
454 setConnectionHeaders(request);
455 ContentResponse response = request.timeout(SurePetcareConstants.DEFAULT_HTTP_TIMEOUT, TimeUnit.SECONDS)
457 if ((response.getStatus() == HttpURLConnection.HTTP_OK)
458 || (response.getStatus() == HttpURLConnection.HTTP_CREATED)) {
461 logger.debug("HTTP Response Code: {}", response.getStatus());
462 logger.debug("HTTP Response Msg: {}", response.getReason());
463 if (response.getStatus() == HttpURLConnection.HTTP_UNAUTHORIZED) {
464 // authentication token has expired, login again and retry
465 login(username, password);
468 throw new SurePetcareApiException(
469 "Http error: " + response.getStatus() + " - " + response.getReason());
472 } catch (AuthenticationException | InterruptedException | ExecutionException | TimeoutException
473 | ProtocolException e) {
474 throw new SurePetcareApiException("Exception caught during API execution.", e);
477 throw new SurePetcareApiException("Can't execute API after 3 retries");