]> git.basschouten.com Git - openhab-addons.git/blob
590231951bdcf753838b19f6c249d3df609f7bb8
[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.TimeUnit;
27 import java.util.concurrent.TimeoutException;
28
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;
50
51 import com.google.gson.JsonElement;
52 import com.google.gson.JsonObject;
53 import com.google.gson.JsonParser;
54 import com.google.gson.JsonSyntaxException;
55
56 /**
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.
59  *
60  * @author Rene Scherer - Initial contribution
61  */
62 @NonNullByDefault
63 public class SurePetcareAPIHelper {
64
65     private final Logger logger = LoggerFactory.getLogger(SurePetcareAPIHelper.class);
66
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";
73
74     public static final int DEFAULT_DEVICE_ID = 12344711;
75
76     private String authenticationToken = "";
77     private String username = "";
78     private String password = "";
79
80     private final String userAgent;
81
82     private @NonNullByDefault({}) HttpClient httpClient;
83     private SurePetcareTopology topologyCache = new SurePetcareTopology();
84
85     public SurePetcareAPIHelper() {
86         userAgent = "openHAB/" + org.openhab.core.OpenHAB.getVersion();
87     }
88
89     /**
90      * Sets the httpClient object to be used for API calls to Sure Petcare.
91      *
92      * @param httpClient the client to be used.
93      */
94     public void setHttpClient(@Nullable HttpClient httpClient) {
95         this.httpClient = httpClient;
96     }
97
98     /**
99      * This method uses the provided username and password to obtain an authentication token used for subsequent API
100      * calls.
101      *
102      * @param username The Sure Petcare username (email address) to be used
103      * @param password The password
104      * @throws AuthenticationException
105      */
106     public synchronized void login(String username, String password) throws AuthenticationException {
107         try {
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)
113                     .send();
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");
122                 } else {
123                     throw new AuthenticationException("Invalid JSON response from login");
124                 }
125             } else {
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());
130             }
131         } catch (IOException | InterruptedException | TimeoutException | ExecutionException e) {
132             throw new AuthenticationException(e);
133         }
134     }
135
136     /**
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.
139      */
140     public synchronized void updateTopologyCache() {
141         try {
142             SurePetcareTopology tc = SurePetcareConstants.GSON.fromJson(getDataFromApi(TOPOLOGY_URL),
143                     SurePetcareTopology.class);
144             if (tc != null) {
145                 topologyCache = tc;
146             }
147         } catch (JsonSyntaxException | SurePetcareApiException e) {
148             logger.warn("Exception caught during topology cache update", e);
149         }
150     }
151
152     /**
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.
156      */
157     public synchronized void updatePetStatus() {
158         try {
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);
164         }
165     }
166
167     /**
168      * Returns the whole topology.
169      *
170      * @return the topology
171      */
172     public final SurePetcareTopology getTopology() {
173         return topologyCache;
174     }
175
176     /**
177      * Returns a household object if one exists with the given id, otherwise null.
178      *
179      * @param id the household id
180      * @return the household with the given id
181      */
182     public final @Nullable SurePetcareHousehold getHousehold(String id) {
183         return topologyCache.getById(topologyCache.households, id);
184     }
185
186     /**
187      * Returns a device object if one exists with the given id, otherwise null.
188      *
189      * @param id the device id
190      * @return the device with the given id
191      */
192     public final @Nullable SurePetcareDevice getDevice(String id) {
193         return topologyCache.getById(topologyCache.devices, id);
194     }
195
196     /**
197      * Returns a pet object if one exists with the given id, otherwise null.
198      *
199      * @param id the pet id
200      * @return the pet with the given id
201      */
202     public final @Nullable SurePetcarePet getPet(String id) {
203         return topologyCache.getById(topologyCache.pets, id);
204     }
205
206     /**
207      * Returns a tag object if one exists with the given id, otherwise null.
208      *
209      * @param id the tag id
210      * @return the tag with the given id
211      */
212     public final @Nullable SurePetcareTag getTag(String id) {
213         return topologyCache.getById(topologyCache.tags, id);
214     }
215
216     /**
217      * Returns the status object if a pet exists with the given id, otherwise null.
218      *
219      * @param id the pet id
220      * @return the status of the pet with the given id
221      */
222     public final @Nullable SurePetcarePetStatus getPetStatus(String id) {
223         SurePetcarePet pet = topologyCache.getById(topologyCache.pets, id);
224         return pet == null ? null : pet.status;
225     }
226
227     /**
228      * Updates the pet location through an API call to the Sure Petcare API.
229      *
230      * @param pet the pet
231      * @param newLocationId the id of the new location
232      * @throws SurePetcareApiException
233      */
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);
240     }
241
242     /**
243      * Updates the device locking mode through an API call to the Sure Petcare API.
244      *
245      * @param device the device
246      * @param newLockingModeId the id of the new locking mode
247      * @throws SurePetcareApiException
248      */
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);
256
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);
262     }
263
264     /**
265      * Updates the device led mode through an API call to the Sure Petcare API.
266      *
267      * @param device the device
268      * @param newLedModeId the id of the new led mode
269      * @throws SurePetcareApiException
270      */
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);
278
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);
284     }
285
286     /**
287      * Updates all curfews through an API call to the Sure Petcare API.
288      *
289      * @param device the device
290      * @param curfewList the list of curfews
291      * @throws SurePetcareApiException
292      */
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);
300
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();
307         }
308         device.control = newControl;
309     }
310
311     /**
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.
314      *
315      * @return a unique device id
316      */
317     public final Integer getDeviceId() {
318         try {
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;
323         }
324     }
325
326     /**
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
329      *
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
333      */
334     public final int getDeviceId(Enumeration<NetworkInterface> interfaces, InetAddress localHostAddress) {
335         int decimal = DEFAULT_DEVICE_ID;
336         try {
337             if (interfaces.hasMoreElements()) {
338                 NetworkInterface netif = interfaces.nextElement();
339
340                 byte[] mac = netif.getHardwareAddress();
341                 if (mac != null) {
342                     StringBuilder sb = new StringBuilder();
343                     for (int i = 0; i < mac.length; i++) {
344                         sb.append(String.format("%02x", mac[i]));
345                     }
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);
349                 } else {
350                     String hostname = localHostAddress.getHostName();
351                     decimal = hostname.hashCode();
352                     logger.debug("current hostname: {}, device id: {}", hostname, decimal);
353                 }
354             } else {
355                 String hostname = localHostAddress.getHostName();
356                 decimal = hostname.hashCode();
357                 logger.debug("current hostname: {}, device id: {}", hostname, decimal);
358             }
359         } catch (SocketException e) {
360             logger.debug("Socket Exception", e);
361         }
362         return decimal;
363     }
364
365     /**
366      * Sets a set of required HTTP headers for the JSON API calls.
367      *
368      * @param request the HTTP connection
369      * @throws ProtocolException
370      */
371     private void setConnectionHeaders(Request request) throws ProtocolException {
372         // headers
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");
383     }
384
385     /**
386      * Return the "data" element of the API result as a JsonElement.
387      *
388      * @param url The URL of the API call.
389      * @return The "data" element of the API result.
390      * @throws SurePetcareApiException
391      */
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");
396     }
397
398     /**
399      * Sends a given object as a JSON payload to the API.
400      *
401      * @param url the URL
402      * @param requestMethod the request method (POST, PUT etc.)
403      * @param payload an object used for the payload
404      * @throws SurePetcareApiException
405      */
406     private void setDataThroughApi(String url, HttpMethod method, Object payload) throws SurePetcareApiException {
407         String jsonPayload = SurePetcareConstants.GSON.toJson(payload);
408         postDataThroughAPI(url, method, jsonPayload);
409     }
410
411     /**
412      * Returns the result of a GET API call as a string.
413      *
414      * @param url the URL
415      * @return a JSON string with the API result
416      * @throws SurePetcareApiException
417      */
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);
423         return responseData;
424     }
425
426     /**
427      * Uses the given request method to send a JSON string to an API.
428      *
429      * @param url the URL
430      * @param method the required request method (POST, PUT etc.)
431      * @param jsonPayload the JSON string
432      * @throws SurePetcareApiException
433      */
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);
440     }
441
442     /**
443      * Uses the given request execute the API call. If it receives an HTTP_UNAUTHORIZED response, it will automatically
444      * login again and retry.
445      *
446      * @param request the Request
447      * @return the response from the API
448      * @throws SurePetcareApiException
449      */
450     private ContentResponse executeAPICall(Request request) throws SurePetcareApiException {
451         int retries = 3;
452         while (retries > 0) {
453             try {
454                 setConnectionHeaders(request);
455                 ContentResponse response = request.timeout(SurePetcareConstants.DEFAULT_HTTP_TIMEOUT, TimeUnit.SECONDS)
456                         .send();
457                 if ((response.getStatus() == HttpURLConnection.HTTP_OK)
458                         || (response.getStatus() == HttpURLConnection.HTTP_CREATED)) {
459                     return response;
460                 } else {
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);
466                         retries--;
467                     } else {
468                         throw new SurePetcareApiException(
469                                 "Http error: " + response.getStatus() + " - " + response.getReason());
470                     }
471                 }
472             } catch (AuthenticationException | InterruptedException | ExecutionException | TimeoutException
473                     | ProtocolException e) {
474                 throw new SurePetcareApiException("Exception caught during API execution.", e);
475             }
476         }
477         throw new SurePetcareApiException("Can't execute API after 3 retries");
478     }
479 }