]> git.basschouten.com Git - openhab-addons.git/blob
9328713d8d8de5d36fb00e1597a9334af8fa16d0
[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.surepetcare.internal;
14
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.TimeoutException;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.eclipse.jetty.client.api.ContentResponse;
32 import org.eclipse.jetty.client.api.Request;
33 import org.eclipse.jetty.client.util.StringContentProvider;
34 import org.eclipse.jetty.http.HttpHeader;
35 import org.eclipse.jetty.http.HttpMethod;
36 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDevice;
37 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDeviceControl;
38 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDeviceCurfewList;
39 import org.openhab.binding.surepetcare.internal.dto.SurePetcareDeviceStatus;
40 import org.openhab.binding.surepetcare.internal.dto.SurePetcareHousehold;
41 import org.openhab.binding.surepetcare.internal.dto.SurePetcareLoginCredentials;
42 import org.openhab.binding.surepetcare.internal.dto.SurePetcareLoginResponse;
43 import org.openhab.binding.surepetcare.internal.dto.SurePetcarePet;
44 import org.openhab.binding.surepetcare.internal.dto.SurePetcarePetStatus;
45 import org.openhab.binding.surepetcare.internal.dto.SurePetcareTag;
46 import org.openhab.binding.surepetcare.internal.dto.SurePetcareTopology;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49
50 import com.google.gson.JsonElement;
51 import com.google.gson.JsonObject;
52 import com.google.gson.JsonParser;
53 import com.google.gson.JsonSyntaxException;
54
55 /**
56  * The {@link SurePetcareAPIHelper} is a helper class to abstract the Sure Petcare API. It handles authentication and
57  * all JSON API calls. If an API call fails it automatically refreshes the authentication token and retries.
58  *
59  * @author Rene Scherer - Initial contribution
60  */
61 @NonNullByDefault
62 public class SurePetcareAPIHelper {
63
64     private final Logger logger = LoggerFactory.getLogger(SurePetcareAPIHelper.class);
65
66     private static final String API_USER_AGENT = "Mozilla/5.0 (Linux; Android 7.0; SM-G930F Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/64.0.3282.137 Mobile Safari/537.36";
67
68     private static final String API_URL = "https://app.api.surehub.io/api";
69     private static final String TOPOLOGY_URL = API_URL + "/me/start";
70     private static final String PET_BASE_URL = API_URL + "/pet";
71     private static final String PET_STATUS_URL = API_URL + "/pet/?with[]=status&with[]=photo";
72     private static final String DEVICE_BASE_URL = API_URL + "/device";
73     private static final String LOGIN_URL = API_URL + "/auth/login";
74
75     public static final int DEFAULT_DEVICE_ID = 12344711;
76
77     private String authenticationToken = "";
78     private String username = "";
79     private String password = "";
80
81     private @NonNullByDefault({}) HttpClient httpClient;
82     private SurePetcareTopology topologyCache = new SurePetcareTopology();
83
84     /**
85      * Sets the httpClient object to be used for API calls to Sure Petcare.
86      *
87      * @param httpClient the client to be used.
88      */
89     public void setHttpClient(@Nullable HttpClient httpClient) {
90         this.httpClient = httpClient;
91     }
92
93     /**
94      * This method uses the provided username and password to obtain an authentication token used for subsequent API
95      * calls.
96      *
97      * @param username The Sure Petcare username (email address) to be used
98      * @param password The password
99      * @throws AuthenticationException
100      */
101     public synchronized void login(String username, String password) throws AuthenticationException {
102         try {
103             Request request = httpClient.POST(LOGIN_URL);
104             setConnectionHeaders(request);
105             request.content(new StringContentProvider(SurePetcareConstants.GSON
106                     .toJson(new SurePetcareLoginCredentials(username, password, getDeviceId().toString()))));
107             ContentResponse response = request.send();
108             if (response.getStatus() == HttpURLConnection.HTTP_OK) {
109                 SurePetcareLoginResponse loginResponse = SurePetcareConstants.GSON
110                         .fromJson(response.getContentAsString(), SurePetcareLoginResponse.class);
111                 if (loginResponse != null) {
112                     authenticationToken = loginResponse.getToken();
113                     this.username = username;
114                     this.password = password;
115                     logger.debug("Login successful");
116                 } else {
117                     throw new AuthenticationException("Invalid JSON response from login");
118                 }
119             } else {
120                 logger.debug("HTTP Response Code: {}", response.getStatus());
121                 logger.debug("HTTP Response Msg: {}", response.getReason());
122                 throw new AuthenticationException(
123                         "HTTP response " + response.getStatus() + " - " + response.getReason());
124             }
125         } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
126             throw new AuthenticationException(e);
127         }
128     }
129
130     /**
131      * Refreshes the whole topology, i.e. all devices, pets etc. through a call to the Sure Petcare API. The APi call is
132      * quite resource intensive and should be used very infrequently.
133      */
134     public synchronized void updateTopologyCache() {
135         try {
136             SurePetcareTopology tc = SurePetcareConstants.GSON.fromJson(getDataFromApi(TOPOLOGY_URL),
137                     SurePetcareTopology.class);
138             if (tc != null) {
139                 topologyCache = tc;
140             }
141         } catch (JsonSyntaxException | SurePetcareApiException e) {
142             logger.warn("Exception caught during topology cache update", e);
143         }
144     }
145
146     /**
147      * Refreshes the pet information. This API call can be used more frequently.
148      * Unlike for the "position" API endpoint, there is none for the "status" (activity/feeding).
149      * We also dont need to specify a "petId" in the call, so we just need to call the API once.
150      */
151     public synchronized void updatePetStatus() {
152         try {
153             String url = PET_STATUS_URL;
154             topologyCache.pets = Arrays
155                     .asList(SurePetcareConstants.GSON.fromJson(getDataFromApi(url), SurePetcarePet[].class));
156         } catch (JsonSyntaxException | SurePetcareApiException e) {
157             logger.warn("Exception caught during pet status update", e);
158         }
159     }
160
161     /**
162      * Returns the whole topology.
163      *
164      * @return the topology
165      */
166     public final SurePetcareTopology getTopology() {
167         return topologyCache;
168     }
169
170     /**
171      * Returns a household object if one exists with the given id, otherwise null.
172      *
173      * @param id the household id
174      * @return the household with the given id
175      */
176     public final @Nullable SurePetcareHousehold getHousehold(String id) {
177         return topologyCache.getById(topologyCache.households, id);
178     }
179
180     /**
181      * Returns a device object if one exists with the given id, otherwise null.
182      *
183      * @param id the device id
184      * @return the device with the given id
185      */
186     public final @Nullable SurePetcareDevice getDevice(String id) {
187         return topologyCache.getById(topologyCache.devices, id);
188     }
189
190     /**
191      * Returns a pet object if one exists with the given id, otherwise null.
192      *
193      * @param id the pet id
194      * @return the pet with the given id
195      */
196     public final @Nullable SurePetcarePet getPet(String id) {
197         return topologyCache.getById(topologyCache.pets, id);
198     }
199
200     /**
201      * Returns a tag object if one exists with the given id, otherwise null.
202      *
203      * @param id the tag id
204      * @return the tag with the given id
205      */
206     public final @Nullable SurePetcareTag getTag(String id) {
207         return topologyCache.getById(topologyCache.tags, id);
208     }
209
210     /**
211      * Returns the status object if a pet exists with the given id, otherwise null.
212      *
213      * @param id the pet id
214      * @return the status of the pet with the given id
215      */
216     public final @Nullable SurePetcarePetStatus getPetStatus(String id) {
217         SurePetcarePet pet = topologyCache.getById(topologyCache.pets, id);
218         return pet == null ? null : pet.status;
219     }
220
221     /**
222      * Updates the pet location through an API call to the Sure Petcare API.
223      *
224      * @param pet the pet
225      * @param newLocationId the id of the new location
226      * @throws SurePetcareApiException
227      */
228     public synchronized void setPetLocation(SurePetcarePet pet, Integer newLocationId, ZonedDateTime newSince)
229             throws SurePetcareApiException {
230         pet.status.activity.where = newLocationId;
231         pet.status.activity.since = newSince;
232         String url = PET_BASE_URL + "/" + pet.id.toString() + "/position";
233         setDataThroughApi(url, HttpMethod.POST, pet.status.activity);
234     }
235
236     /**
237      * Updates the device locking mode through an API call to the Sure Petcare API.
238      *
239      * @param device the device
240      * @param newLockingModeId the id of the new locking mode
241      * @throws SurePetcareApiException
242      */
243     public synchronized void setDeviceLockingMode(SurePetcareDevice device, Integer newLockingModeId)
244             throws SurePetcareApiException {
245         // post new JSON control structure to API
246         SurePetcareDeviceControl control = new SurePetcareDeviceControl();
247         control.lockingModeId = newLockingModeId;
248         String ctrlurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
249         setDataThroughApi(ctrlurl, HttpMethod.PUT, control);
250
251         // now we're fetching the new state back for the cache
252         String devurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/status";
253         SurePetcareDeviceStatus newStatus = SurePetcareConstants.GSON.fromJson(getDataFromApi(devurl),
254                 SurePetcareDeviceStatus.class);
255         device.status.assign(newStatus);
256     }
257
258     /**
259      * Updates the device led mode through an API call to the Sure Petcare API.
260      *
261      * @param device the device
262      * @param newLedModeId the id of the new led mode
263      * @throws SurePetcareApiException
264      */
265     public synchronized void setDeviceLedMode(SurePetcareDevice device, Integer newLedModeId)
266             throws SurePetcareApiException {
267         // post new JSON control structure to API
268         SurePetcareDeviceControl control = new SurePetcareDeviceControl();
269         control.ledModeId = newLedModeId;
270         String ctrlurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
271         setDataThroughApi(ctrlurl, HttpMethod.PUT, control);
272
273         // now we're fetching the new state back for the cache
274         String devurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/status";
275         SurePetcareDeviceStatus newStatus = SurePetcareConstants.GSON.fromJson(getDataFromApi(devurl),
276                 SurePetcareDeviceStatus.class);
277         device.status.assign(newStatus);
278     }
279
280     /**
281      * Updates all curfews through an API call to the Sure Petcare API.
282      *
283      * @param device the device
284      * @param curfewList the list of curfews
285      * @throws SurePetcareApiException
286      */
287     public synchronized void setCurfews(SurePetcareDevice device, SurePetcareDeviceCurfewList curfewList)
288             throws SurePetcareApiException {
289         // post new JSON control structure to API
290         SurePetcareDeviceControl control = new SurePetcareDeviceControl();
291         control.curfewList = curfewList.compact();
292         String ctrlurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
293         setDataThroughApi(ctrlurl, HttpMethod.PUT, control);
294
295         // now we're fetching the new state back for the cache
296         String devurl = DEVICE_BASE_URL + "/" + device.id.toString() + "/control";
297         SurePetcareDeviceControl newControl = SurePetcareConstants.GSON.fromJson(getDataFromApi(devurl),
298                 SurePetcareDeviceControl.class);
299         if (newControl != null) {
300             newControl.curfewList = newControl.curfewList.order();
301         }
302         device.control = newControl;
303     }
304
305     /**
306      * Returns a unique device id used during the authentication process with the Sure Petcare API. The id is derived
307      * from the local MAC address or hostname.
308      *
309      * @return a unique device id
310      */
311     public final Integer getDeviceId() {
312         try {
313             return getDeviceId(NetworkInterface.getNetworkInterfaces(), InetAddress.getLocalHost());
314         } catch (UnknownHostException | SocketException e) {
315             logger.warn("unable to discover mac or hostname, assigning default device id {}", DEFAULT_DEVICE_ID);
316             return DEFAULT_DEVICE_ID;
317         }
318     }
319
320     /**
321      * Returns a unique device id used during the authentication process with the Sure Petcare API. The id is derived
322      * from the local MAC address or hostname provided as arguments
323      *
324      * @param interfaces a list of interface of this host
325      * @param localHostAddress the ip address of the localhost
326      * @return a unique device id
327      */
328     public final int getDeviceId(Enumeration<NetworkInterface> interfaces, InetAddress localHostAddress) {
329         int decimal = DEFAULT_DEVICE_ID;
330         try {
331             if (interfaces.hasMoreElements()) {
332                 NetworkInterface netif = interfaces.nextElement();
333
334                 byte[] mac = netif.getHardwareAddress();
335                 if (mac != null) {
336                     StringBuilder sb = new StringBuilder();
337                     for (int i = 0; i < mac.length; i++) {
338                         sb.append(String.format("%02x", mac[i]));
339                     }
340                     String hex = sb.toString();
341                     decimal = Math.abs((int) (Long.parseUnsignedLong(hex, 16) % Integer.MAX_VALUE));
342                     logger.debug("current MAC address: {}, device id: {}", hex, decimal);
343                 } else {
344                     String hostname = localHostAddress.getHostName();
345                     decimal = hostname.hashCode();
346                     logger.debug("current hostname: {}, device id: {}", hostname, decimal);
347                 }
348             } else {
349                 String hostname = localHostAddress.getHostName();
350                 decimal = hostname.hashCode();
351                 logger.debug("current hostname: {}, device id: {}", hostname, decimal);
352             }
353         } catch (SocketException e) {
354             logger.debug("Socket Exception", e);
355         }
356         return decimal;
357     }
358
359     /**
360      * Sets a set of required HTTP headers for the JSON API calls.
361      *
362      * @param request the HTTP connection
363      * @throws ProtocolException
364      */
365     private void setConnectionHeaders(Request request) throws ProtocolException {
366         // headers
367         request.header(HttpHeader.ACCEPT, "application/json, text/plain, */*");
368         request.header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate");
369         request.header(HttpHeader.AUTHORIZATION, "Bearer " + authenticationToken);
370         request.header(HttpHeader.CONNECTION, "keep-alive");
371         request.header(HttpHeader.CONTENT_TYPE, "application/json; utf-8");
372         request.header(HttpHeader.USER_AGENT, API_USER_AGENT);
373         request.header(HttpHeader.REFERER, "https://surepetcare.io/");
374         request.header("Origin", "https://surepetcare.io");
375         request.header("Referer", "https://surepetcare.io");
376         request.header("X-Requested-With", "com.sureflap.surepetcare");
377     }
378
379     /**
380      * Return the "data" element of the API result as a JsonElement.
381      *
382      * @param url The URL of the API call.
383      * @return The "data" element of the API result.
384      * @throws SurePetcareApiException
385      */
386     private JsonElement getDataFromApi(String url) throws SurePetcareApiException {
387         String apiResult = getResultFromApi(url);
388         JsonObject object = (JsonObject) JsonParser.parseString(apiResult);
389         return object.get("data");
390     }
391
392     /**
393      * Sends a given object as a JSON payload to the API.
394      *
395      * @param url the URL
396      * @param requestMethod the request method (POST, PUT etc.)
397      * @param payload an object used for the payload
398      * @throws SurePetcareApiException
399      */
400     private void setDataThroughApi(String url, HttpMethod method, Object payload) throws SurePetcareApiException {
401         String jsonPayload = SurePetcareConstants.GSON.toJson(payload);
402         postDataThroughAPI(url, method, jsonPayload);
403     }
404
405     /**
406      * Returns the result of a GET API call as a string.
407      *
408      * @param url the URL
409      * @return a JSON string with the API result
410      * @throws SurePetcareApiException
411      */
412     private String getResultFromApi(String url) throws SurePetcareApiException {
413         Request request = httpClient.newRequest(url).method(HttpMethod.GET);
414         ContentResponse response = executeAPICall(request);
415         String responseData = response.getContentAsString();
416         logger.debug("API execution successful, response: {}", responseData);
417         return responseData;
418     }
419
420     /**
421      * Uses the given request method to send a JSON string to an API.
422      *
423      * @param url the URL
424      * @param method the required request method (POST, PUT etc.)
425      * @param jsonPayload the JSON string
426      * @throws SurePetcareApiException
427      */
428     private void postDataThroughAPI(String url, HttpMethod method, String jsonPayload) throws SurePetcareApiException {
429         logger.debug("postDataThroughAPI URL: {}", url);
430         logger.debug("postDataThroughAPI Payload: {}", jsonPayload);
431         Request request = httpClient.newRequest(url).method(method);
432         request.content(new StringContentProvider(jsonPayload));
433         executeAPICall(request);
434     }
435
436     /**
437      * Uses the given request execute the API call. If it receives an HTTP_UNAUTHORIZED response, it will automatically
438      * login again and retry.
439      *
440      * @param request the Request
441      * @return the response from the API
442      * @throws SurePetcareApiException
443      */
444     private ContentResponse executeAPICall(Request request) throws SurePetcareApiException {
445         int retries = 3;
446         while (retries > 0) {
447             try {
448                 setConnectionHeaders(request);
449                 ContentResponse response = request.send();
450                 if ((response.getStatus() == HttpURLConnection.HTTP_OK)
451                         || (response.getStatus() == HttpURLConnection.HTTP_CREATED)) {
452                     return response;
453                 } else {
454                     logger.debug("HTTP Response Code: {}", response.getStatus());
455                     logger.debug("HTTP Response Msg: {}", response.getReason());
456                     if (response.getStatus() == HttpURLConnection.HTTP_UNAUTHORIZED) {
457                         // authentication token has expired, login again and retry
458                         login(username, password);
459                         retries--;
460                     } else {
461                         throw new SurePetcareApiException(
462                                 "Http error: " + response.getStatus() + " - " + response.getReason());
463                     }
464                 }
465             } catch (AuthenticationException | InterruptedException | ExecutionException | TimeoutException
466                     | ProtocolException e) {
467                 throw new SurePetcareApiException("Exception caught during API execution.", e);
468             }
469         }
470         throw new SurePetcareApiException("Can't execute API after 3 retries");
471     }
472 }