]> git.basschouten.com Git - openhab-addons.git/blob
ac29111967967cb15ba3ae03052912d3b1010491
[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.nest.internal.sdm.api;
14
15 import static org.eclipse.jetty.http.HttpHeader.*;
16 import static org.eclipse.jetty.http.HttpMethod.*;
17 import static org.openhab.binding.nest.internal.sdm.dto.SDMGson.GSON;
18
19 import java.io.IOException;
20 import java.math.BigDecimal;
21 import java.time.Duration;
22 import java.util.List;
23 import java.util.Set;
24 import java.util.concurrent.ConcurrentHashMap;
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.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMCommandRequest;
36 import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMCommandResponse;
37 import org.openhab.binding.nest.internal.sdm.dto.SDMDevice;
38 import org.openhab.binding.nest.internal.sdm.dto.SDMError;
39 import org.openhab.binding.nest.internal.sdm.dto.SDMError.SDMErrorDetails;
40 import org.openhab.binding.nest.internal.sdm.dto.SDMListDevicesResponse;
41 import org.openhab.binding.nest.internal.sdm.dto.SDMListRoomsResponse;
42 import org.openhab.binding.nest.internal.sdm.dto.SDMListStructuresResponse;
43 import org.openhab.binding.nest.internal.sdm.dto.SDMRoom;
44 import org.openhab.binding.nest.internal.sdm.dto.SDMStructure;
45 import org.openhab.binding.nest.internal.sdm.exception.FailedSendingSDMDataException;
46 import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAccessTokenException;
47 import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAuthorizationCodeException;
48 import org.openhab.binding.nest.internal.sdm.listener.SDMAPIRequestListener;
49 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
50 import org.openhab.core.auth.client.oauth2.OAuthClientService;
51 import org.openhab.core.auth.client.oauth2.OAuthException;
52 import org.openhab.core.auth.client.oauth2.OAuthFactory;
53 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
54 import org.openhab.core.io.net.http.HttpClientFactory;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
57
58 /**
59  * The {@link SDMAPI} implements the SDM REST API which allows for querying Nest device, structure and room information
60  * as well as executing device commands.
61  *
62  * @author Wouter Born - Initial contribution
63  *
64  * @see <a href="https://developers.google.com/nest/device-access/reference/rest">
65  *      https://developers.google.com/nest/device-access/reference/rest</a>
66  */
67 @NonNullByDefault
68 public class SDMAPI {
69
70     private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/auth";
71     private static final String TOKEN_URL = "https://accounts.google.com/o/oauth2/token";
72     private static final String REDIRECT_URI = "https://www.google.com";
73
74     private static final String SDM_HANDLE_FORMAT = "%s.sdm";
75     private static final String SDM_SCOPE = "https://www.googleapis.com/auth/sdm.service";
76
77     private static final String SDM_URL_PREFIX = "https://smartdevicemanagement.googleapis.com/v1/enterprises/";
78
79     private static final String APPLICATION_JSON = "application/json";
80     private static final String BEARER = "Bearer ";
81     private static final String IMAGE_JPEG = "image/jpeg";
82
83     private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
84
85     private final Logger logger = LoggerFactory.getLogger(SDMAPI.class);
86
87     private final HttpClient httpClient;
88     private final OAuthFactory oAuthFactory;
89     private final OAuthClientService oAuthService;
90     private final String oAuthServiceHandleId;
91     private final String projectId;
92
93     private final Set<SDMAPIRequestListener> requestListeners = ConcurrentHashMap.newKeySet();
94
95     public SDMAPI(HttpClientFactory httpClientFactory, OAuthFactory oAuthFactory, String ownerId, String projectId,
96             String clientId, String clientSecret) {
97         this.httpClient = httpClientFactory.getCommonHttpClient();
98         this.oAuthFactory = oAuthFactory;
99         this.oAuthServiceHandleId = String.format(SDM_HANDLE_FORMAT, ownerId);
100         this.oAuthService = oAuthFactory.createOAuthClientService(oAuthServiceHandleId, TOKEN_URL, AUTH_URL, clientId,
101                 clientSecret, SDM_SCOPE, false);
102         this.projectId = projectId;
103     }
104
105     public void dispose() {
106         requestListeners.clear();
107         oAuthFactory.ungetOAuthService(oAuthServiceHandleId);
108     }
109
110     public void deleteOAuthServiceAndAccessToken() {
111         oAuthFactory.deleteServiceAndAccessToken(oAuthServiceHandleId);
112     }
113
114     public void authorizeClient(String authorizationCode) throws InvalidSDMAuthorizationCodeException, IOException {
115         try {
116             oAuthService.getAccessTokenResponseByAuthorizationCode(authorizationCode, REDIRECT_URI);
117         } catch (OAuthException | OAuthResponseException e) {
118             throw new InvalidSDMAuthorizationCodeException(
119                     "Failed to authorize SDM client. Check the authorization code or generate a new one.", e);
120         }
121     }
122
123     public void checkAccessTokenValidity() throws InvalidSDMAccessTokenException, IOException {
124         getAuthorizationHeader();
125     }
126
127     public void addRequestListener(SDMAPIRequestListener listener) {
128         requestListeners.add(listener);
129     }
130
131     public void removeRequestListener(SDMAPIRequestListener listener) {
132         requestListeners.remove(listener);
133     }
134
135     public <T extends SDMCommandResponse> @Nullable T executeDeviceCommand(String deviceId,
136             SDMCommandRequest<T> request) throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
137         logger.debug("Executing device command for: {}", deviceId);
138         String requestContent = GSON.toJson(request);
139         String responseContent = postJson(getDeviceUrl(deviceId) + ":executeCommand", requestContent);
140         return GSON.fromJson(responseContent, request.getResponseClass());
141     }
142
143     private String getAuthorizationHeader() throws InvalidSDMAccessTokenException, IOException {
144         try {
145             AccessTokenResponse response = oAuthService.getAccessTokenResponse();
146             if (response == null || response.getAccessToken() == null || response.getAccessToken().isEmpty()) {
147                 throw new InvalidSDMAccessTokenException("No SDM access token. Client may not have been authorized.");
148             }
149             if (response.getRefreshToken() == null || response.getRefreshToken().isEmpty()) {
150                 throw new InvalidSDMAccessTokenException(
151                         "No SDM refresh token. Delete and readd credentials, then reauthorize.");
152             }
153             return BEARER + response.getAccessToken();
154         } catch (OAuthException | OAuthResponseException e) {
155             throw new InvalidSDMAccessTokenException(
156                     "Error fetching SDM access token. Check the authorization code or generate a new one.", e);
157         }
158     }
159
160     public byte[] getCameraImage(String url, String token, @Nullable BigDecimal imageWidth,
161             @Nullable BigDecimal imageHeight) throws FailedSendingSDMDataException {
162         try {
163             logger.debug("Getting camera image from: {}", url);
164
165             Request request = httpClient.newRequest(url) //
166                     .method(GET) //
167                     .header(ACCEPT, IMAGE_JPEG) //
168                     .header(AUTHORIZATION, token) //
169                     .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
170
171             if (imageWidth != null) {
172                 request = request.param("width", Long.toString(imageWidth.longValue()));
173             } else if (imageHeight != null) {
174                 request = request.param("height", Long.toString(imageHeight.longValue()));
175             }
176
177             ContentResponse contentResponse = request.send();
178             logResponseErrors(contentResponse);
179             logger.debug("Retrieved camera image from: {}", url);
180             requestListeners.forEach(SDMAPIRequestListener::onSuccess);
181             return contentResponse.getContent();
182         } catch (ExecutionException | InterruptedException | TimeoutException e) {
183             logger.debug("Failed to get camera image", e);
184             FailedSendingSDMDataException exception = new FailedSendingSDMDataException("Failed to get camera image",
185                     e);
186             requestListeners.forEach(listener -> listener.onError(exception));
187             throw exception;
188         }
189     }
190
191     public @Nullable SDMDevice getDevice(String deviceId)
192             throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
193         logger.debug("Getting device: {}", deviceId);
194         return GSON.fromJson(getJson(getDeviceUrl(deviceId)), SDMDevice.class);
195     }
196
197     public @Nullable SDMStructure getStructure(String structureId)
198             throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
199         logger.debug("Getting structure: {}", structureId);
200         return GSON.fromJson(getJson(getStructureUrl(structureId)), SDMStructure.class);
201     }
202
203     public @Nullable SDMRoom getRoom(String structureId, String roomId)
204             throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
205         logger.debug("Getting structure {} room: {}", structureId, roomId);
206         return GSON.fromJson(getJson(getRoomUrl(structureId, roomId)), SDMRoom.class);
207     }
208
209     private String getProjectUrl() {
210         return SDM_URL_PREFIX + projectId;
211     }
212
213     private String getDevicesUrl() {
214         return getProjectUrl() + "/devices";
215     }
216
217     private String getDevicesUrl(String pageToken) {
218         return getDevicesUrl() + "?pageToken=" + pageToken;
219     }
220
221     private String getDeviceUrl(String deviceId) {
222         return getDevicesUrl() + "/" + deviceId;
223     }
224
225     private String getStructuresUrl() {
226         return getProjectUrl() + "/structures";
227     }
228
229     private String getStructuresUrl(String pageToken) {
230         return getStructuresUrl() + "?pageToken=" + pageToken;
231     }
232
233     private String getStructureUrl(String structureId) {
234         return getStructuresUrl() + "/" + structureId;
235     }
236
237     private String getRoomsUrl(String structureId) {
238         return getStructureUrl(structureId) + "/rooms";
239     }
240
241     private String getRoomsUrl(String structureId, String pageToken) {
242         return getRoomsUrl(structureId) + "?pageToken=" + pageToken;
243     }
244
245     private String getRoomUrl(String structureId, String roomId) {
246         return getRoomsUrl(structureId) + "/" + roomId;
247     }
248
249     public List<SDMDevice> listDevices() throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
250         logger.debug("Listing devices");
251         SDMListDevicesResponse response = GSON.fromJson(getJson(getDevicesUrl()), SDMListDevicesResponse.class);
252         List<SDMDevice> result = response == null ? List.of() : response.devices;
253         while (response != null && !response.nextPageToken.isEmpty()) {
254             response = GSON.fromJson(getJson(getDevicesUrl(response.nextPageToken)), SDMListDevicesResponse.class);
255             if (response != null) {
256                 result.addAll(response.devices);
257             }
258         }
259         return result;
260     }
261
262     public List<SDMStructure> listStructures() throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
263         logger.debug("Listing structures");
264         SDMListStructuresResponse response = GSON.fromJson(getJson(getStructuresUrl()),
265                 SDMListStructuresResponse.class);
266         List<SDMStructure> result = response == null ? List.of() : response.structures;
267         while (response != null && !response.nextPageToken.isEmpty()) {
268             response = GSON.fromJson(getJson(getStructuresUrl(response.nextPageToken)),
269                     SDMListStructuresResponse.class);
270             if (response != null) {
271                 result.addAll(response.structures);
272             }
273         }
274         return result;
275     }
276
277     public List<SDMRoom> listRooms(String structureId)
278             throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
279         logger.debug("Listing rooms for structure: {}", structureId);
280         SDMListRoomsResponse response = GSON.fromJson(getJson(getRoomsUrl(structureId)), SDMListRoomsResponse.class);
281         List<SDMRoom> result = response == null ? List.of() : response.rooms;
282         while (response != null && !response.nextPageToken.isEmpty()) {
283             response = GSON.fromJson(getJson(getRoomsUrl(structureId, response.nextPageToken)),
284                     SDMListRoomsResponse.class);
285             if (response != null) {
286                 result.addAll(response.rooms);
287             }
288         }
289         return result;
290     }
291
292     private void logResponseErrors(ContentResponse contentResponse) {
293         if (contentResponse.getStatus() >= 400) {
294             logger.debug("SDM API error: {}", contentResponse.getContentAsString());
295
296             SDMError error = GSON.fromJson(contentResponse.getContentAsString(), SDMError.class);
297             SDMErrorDetails details = error == null ? null : error.error;
298
299             if (details != null && !details.message.isBlank()) {
300                 logger.warn("SDM API error: {}", details.message);
301             } else {
302                 logger.warn("SDM API error: {} (HTTP {})", contentResponse.getReason(), contentResponse.getStatus());
303             }
304         }
305     }
306
307     private String getJson(String url) throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
308         try {
309             logger.debug("Getting JSON from: {}", url);
310             ContentResponse contentResponse = httpClient.newRequest(url) //
311                     .method(GET) //
312                     .header(ACCEPT, APPLICATION_JSON) //
313                     .header(AUTHORIZATION, getAuthorizationHeader()) //
314                     .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
315                     .send();
316             logResponseErrors(contentResponse);
317             String response = contentResponse.getContentAsString();
318             logger.debug("Response: {}", response);
319             requestListeners.forEach(SDMAPIRequestListener::onSuccess);
320             return response;
321         } catch (ExecutionException | InterruptedException | IOException | TimeoutException e) {
322             logger.debug("Failed to send JSON GET request", e);
323             FailedSendingSDMDataException exception = new FailedSendingSDMDataException(
324                     "Failed to send JSON GET request", e);
325             requestListeners.forEach(listener -> listener.onError(exception));
326             throw exception;
327         }
328     }
329
330     private String postJson(String url, String requestContent)
331             throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
332         try {
333             logger.debug("Posting JSON to: {}", url);
334             ContentResponse contentResponse = httpClient.newRequest(url) //
335                     .method(POST) //
336                     .header(ACCEPT, APPLICATION_JSON) //
337                     .header(AUTHORIZATION, getAuthorizationHeader()) //
338                     .content(new StringContentProvider(requestContent), APPLICATION_JSON) //
339                     .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
340                     .send();
341             logResponseErrors(contentResponse);
342             String response = contentResponse.getContentAsString();
343             logger.debug("Response: {}", response);
344             requestListeners.forEach(SDMAPIRequestListener::onSuccess);
345             return response;
346         } catch (ExecutionException | InterruptedException | IOException | TimeoutException e) {
347             logger.debug("Failed to send JSON POST request", e);
348             FailedSendingSDMDataException exception = new FailedSendingSDMDataException(
349                     "Failed to send JSON POST request", e);
350             requestListeners.forEach(listener -> listener.onError(exception));
351             throw exception;
352         }
353     }
354 }