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