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