2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.nest.internal.sdm.api;
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;
19 import java.io.IOException;
20 import java.math.BigDecimal;
21 import java.time.Duration;
22 import java.util.List;
24 import java.util.concurrent.ConcurrentHashMap;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
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;
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.
62 * @author Wouter Born - Initial contribution
64 * @see https://developers.google.com/nest/device-access/reference/rest
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";
73 private static final String SDM_HANDLE_FORMAT = "%s.sdm";
74 private static final String SDM_SCOPE = "https://www.googleapis.com/auth/sdm.service";
76 private static final String SDM_URL_PREFIX = "https://smartdevicemanagement.googleapis.com/v1/enterprises/";
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";
82 private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
84 private final Logger logger = LoggerFactory.getLogger(SDMAPI.class);
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;
92 private final Set<SDMAPIRequestListener> requestListeners = ConcurrentHashMap.newKeySet();
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;
104 public void dispose() {
105 requestListeners.clear();
106 oAuthFactory.ungetOAuthService(oAuthServiceHandleId);
109 public void deleteOAuthServiceAndAccessToken() {
110 oAuthFactory.deleteServiceAndAccessToken(oAuthServiceHandleId);
113 public void authorizeClient(String authorizationCode) throws InvalidSDMAuthorizationCodeException, IOException {
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);
122 public void checkAccessTokenValidity() throws InvalidSDMAccessTokenException, IOException {
123 getAuthorizationHeader();
126 public void addRequestListener(SDMAPIRequestListener listener) {
127 requestListeners.add(listener);
130 public void removeRequestListener(SDMAPIRequestListener listener) {
131 requestListeners.remove(listener);
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());
142 private String getAuthorizationHeader() throws InvalidSDMAccessTokenException, IOException {
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.");
148 if (response.getRefreshToken() == null || response.getRefreshToken().isEmpty()) {
149 throw new InvalidSDMAccessTokenException(
150 "No SDM refresh token. Delete and readd credentials, then reauthorize.");
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);
159 public byte[] getCameraImage(String url, String token, @Nullable BigDecimal imageWidth,
160 @Nullable BigDecimal imageHeight) throws FailedSendingSDMDataException {
162 logger.debug("Getting camera image from: {}", url);
164 Request request = httpClient.newRequest(url) //
166 .header(ACCEPT, IMAGE_JPEG) //
167 .header(AUTHORIZATION, token) //
168 .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
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()));
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",
185 requestListeners.forEach(listener -> listener.onError(exception));
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);
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);
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);
208 private String getProjectUrl() {
209 return SDM_URL_PREFIX + projectId;
212 private String getDevicesUrl() {
213 return getProjectUrl() + "/devices";
216 private String getDevicesUrl(String pageToken) {
217 return getDevicesUrl() + "?pageToken=" + pageToken;
220 private String getDeviceUrl(String deviceId) {
221 return getDevicesUrl() + "/" + deviceId;
224 private String getStructuresUrl() {
225 return getProjectUrl() + "/structures";
228 private String getStructuresUrl(String pageToken) {
229 return getStructuresUrl() + "?pageToken=" + pageToken;
232 private String getStructureUrl(String structureId) {
233 return getStructuresUrl() + "/" + structureId;
236 private String getRoomsUrl(String structureId) {
237 return getStructureUrl(structureId) + "/rooms";
240 private String getRoomsUrl(String structureId, String pageToken) {
241 return getRoomsUrl(structureId) + "?pageToken=" + pageToken;
244 private String getRoomUrl(String structureId, String roomId) {
245 return getRoomsUrl(structureId) + "/" + roomId;
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);
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);
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);
291 private void logResponseErrors(ContentResponse contentResponse) {
292 if (contentResponse.getStatus() >= 400) {
293 logger.debug("SDM API error: {}", contentResponse.getContentAsString());
295 SDMError error = GSON.fromJson(contentResponse.getContentAsString(), SDMError.class);
296 SDMErrorDetails details = error == null ? null : error.error;
298 if (details != null && !details.message.isBlank()) {
299 logger.warn("SDM API error: {}", details.message);
301 logger.warn("SDM API error: {} (HTTP {})", contentResponse.getReason(), contentResponse.getStatus());
306 private String getJson(String url) throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
308 logger.debug("Getting JSON from: {}", url);
309 ContentResponse contentResponse = httpClient.newRequest(url) //
311 .header(ACCEPT, APPLICATION_JSON) //
312 .header(AUTHORIZATION, getAuthorizationHeader()) //
313 .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
315 logResponseErrors(contentResponse);
316 String response = contentResponse.getContentAsString();
317 logger.debug("Response: {}", response);
318 requestListeners.forEach(SDMAPIRequestListener::onSuccess);
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));
329 private String postJson(String url, String requestContent)
330 throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
332 logger.debug("Posting JSON to: {}", url);
333 ContentResponse contentResponse = httpClient.newRequest(url) //
335 .header(ACCEPT, APPLICATION_JSON) //
336 .header(AUTHORIZATION, getAuthorizationHeader()) //
337 .content(new StringContentProvider(requestContent), APPLICATION_JSON) //
338 .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
340 logResponseErrors(contentResponse);
341 String response = contentResponse.getContentAsString();
342 logger.debug("Response: {}", response);
343 requestListeners.forEach(SDMAPIRequestListener::onSuccess);
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));