2 * Copyright (c) 2010-2022 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.livisismarthome.internal.client;
15 import java.io.BufferedReader;
16 import java.io.IOException;
17 import java.io.InputStreamReader;
18 import java.io.OutputStream;
19 import java.net.HttpURLConnection;
20 import java.nio.charset.StandardCharsets;
21 import java.util.Arrays;
22 import java.util.Collection;
23 import java.util.Collections;
24 import java.util.List;
25 import java.util.Optional;
26 import java.util.stream.Collectors;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.http.HttpHeader;
31 import org.eclipse.jetty.http.HttpMethod;
32 import org.eclipse.jetty.http.HttpStatus;
33 import org.openhab.binding.livisismarthome.internal.LivisiBindingConstants;
34 import org.openhab.binding.livisismarthome.internal.client.api.entity.StatusResponseDTO;
35 import org.openhab.binding.livisismarthome.internal.client.api.entity.action.ActionDTO;
36 import org.openhab.binding.livisismarthome.internal.client.api.entity.action.ShutterActionDTO;
37 import org.openhab.binding.livisismarthome.internal.client.api.entity.action.ShutterActionType;
38 import org.openhab.binding.livisismarthome.internal.client.api.entity.action.StateActionSetterDTO;
39 import org.openhab.binding.livisismarthome.internal.client.api.entity.capability.CapabilityDTO;
40 import org.openhab.binding.livisismarthome.internal.client.api.entity.capability.CapabilityStateDTO;
41 import org.openhab.binding.livisismarthome.internal.client.api.entity.device.DeviceDTO;
42 import org.openhab.binding.livisismarthome.internal.client.api.entity.device.DeviceStateDTO;
43 import org.openhab.binding.livisismarthome.internal.client.api.entity.device.StateDTO;
44 import org.openhab.binding.livisismarthome.internal.client.api.entity.error.ErrorResponseDTO;
45 import org.openhab.binding.livisismarthome.internal.client.api.entity.location.LocationDTO;
46 import org.openhab.binding.livisismarthome.internal.client.api.entity.message.MessageDTO;
47 import org.openhab.binding.livisismarthome.internal.client.exception.ApiException;
48 import org.openhab.binding.livisismarthome.internal.client.exception.AuthenticationException;
49 import org.openhab.binding.livisismarthome.internal.client.exception.ControllerOfflineException;
50 import org.openhab.binding.livisismarthome.internal.client.exception.InvalidActionTriggeredException;
51 import org.openhab.binding.livisismarthome.internal.client.exception.RemoteAccessNotAllowedException;
52 import org.openhab.binding.livisismarthome.internal.client.exception.ServiceUnavailableException;
53 import org.openhab.binding.livisismarthome.internal.client.exception.SessionExistsException;
54 import org.openhab.binding.livisismarthome.internal.client.exception.SessionNotFoundException;
55 import org.openhab.binding.livisismarthome.internal.handler.LivisiBridgeConfiguration;
56 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
57 import org.openhab.core.auth.client.oauth2.OAuthClientService;
58 import org.openhab.core.auth.client.oauth2.OAuthException;
59 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
63 import com.google.gson.JsonSyntaxException;
66 * The main client that handles the communication with the LIVISI SmartHome API service.
68 * @author Oliver Kuhl - Initial contribution
69 * @author Hilbrand Bouwkamp - Refactored to use openHAB http and oauth2 libraries
70 * @author Sven Strohschein - Renamed from Innogy to Livisi and refactored
73 public class LivisiClient {
75 private final Logger logger = LoggerFactory.getLogger(LivisiClient.class);
77 private final GsonOptional gson;
78 private final LivisiBridgeConfiguration bridgeConfiguration;
79 private final OAuthClientService oAuthService;
80 private final URLConnectionFactory connectionFactory;
82 public LivisiClient(final LivisiBridgeConfiguration bridgeConfiguration, final OAuthClientService oAuthService,
83 final URLConnectionFactory connectionFactory) {
84 this.bridgeConfiguration = bridgeConfiguration;
85 this.oAuthService = oAuthService;
86 this.connectionFactory = connectionFactory;
87 this.gson = new GsonOptional();
92 * As the API returns the details of the SmartHome controller (SHC), the config version is returned.
94 * @return config version
96 public String refreshStatus() throws IOException {
97 logger.debug("Get LIVISI SmartHome status...");
98 final Optional<StatusResponseDTO> status = executeGet(URLCreator.createStatusURL(bridgeConfiguration.host),
99 StatusResponseDTO.class);
101 if (status.isPresent()) {
102 String configVersion = status.get().getConfigVersion();
103 logger.debug("LIVISI SmartHome status loaded. Configuration version is {}.", configVersion);
104 return configVersion;
110 * Executes a HTTP GET request with default headers and returns data as object of type T.
112 * @param url request URL
113 * @param clazz type of data to return
114 * @return response content
116 private <T> Optional<T> executeGet(final String url, final Class<T> clazz) throws IOException {
118 HttpURLConnection connection = createBaseRequest(url, HttpMethod.GET);
119 String responseContent = executeRequest(connection);
120 return gson.fromJson(responseContent, clazz);
124 * Executes a HTTP GET request with default headers and returns data as List of type T.
126 * @param url request URL
127 * @param clazz array type of data to return as list
128 * @return response content (as a List)
130 private <T> List<T> executeGetList(final String url, final Class<T[]> clazz) throws IOException {
131 Optional<T[]> objects = executeGet(url, clazz);
132 if (objects.isPresent()) {
133 return Arrays.asList(objects.get());
135 return Collections.emptyList();
139 * Executes a HTTP POST request with the given {@link ActionDTO} as content.
141 * @param url request URL
142 * @param action action to execute
144 private void executePost(final String url, final ActionDTO action) throws IOException {
145 final String json = gson.toJson(action);
146 final byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
147 logger.debug("Action {} JSON: {}", action.getType(), json);
149 HttpURLConnection connection = createBaseRequest(url, HttpMethod.POST);
150 connection.setDoOutput(true);
151 connection.setRequestProperty(HttpHeader.CONTENT_LENGTH.asString(), String.valueOf(jsonBytes.length));
152 try (OutputStream outputStream = connection.getOutputStream()) {
153 outputStream.write(jsonBytes);
156 executeRequest(connection);
159 private String executeRequest(HttpURLConnection connection) throws IOException {
160 StringBuilder stringBuilder = new StringBuilder();
161 try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
163 while ((line = reader.readLine()) != null) {
164 stringBuilder.append(line);
168 String responseContent = stringBuilder.toString();
169 logger.trace("RAW-RESPONSE: {}", responseContent);
170 handleResponseErrors(connection, responseContent);
171 return normalizeResponseContent(responseContent);
174 private HttpURLConnection createBaseRequest(String url, HttpMethod httpMethod) throws IOException {
176 final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
177 return connectionFactory.createBaseRequest(url, httpMethod, accessTokenResponse);
180 public AccessTokenResponse getAccessTokenResponse() throws IOException {
183 final AccessTokenResponse accessTokenResponse = oAuthService.getAccessTokenResponse();
184 if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null
185 || accessTokenResponse.getAccessToken().isBlank()) {
186 throw new AuthenticationException("No LIVISI SmartHome access token. Is this thing authorized?");
188 return accessTokenResponse;
189 } catch (OAuthException | OAuthResponseException e) {
190 throw new AuthenticationException("Error fetching access token: " + e.getMessage());
195 * Handles errors from the {@link org.eclipse.jetty.client.api.ContentResponse} and throws the following errors:
197 * @param connection connection
198 * @param responseContent response content
199 * @throws ControllerOfflineException thrown, if the LIVISI SmartHome controller (SHC) is offline.
201 private void handleResponseErrors(final HttpURLConnection connection, final String responseContent)
204 final int status = connection.getResponseCode();
205 if (HttpStatus.OK_200 == status) {
206 logger.debug("Statuscode is OK: [{}]", connection.getURL());
207 } else if (HttpStatus.SERVICE_UNAVAILABLE_503 == status) {
208 throw new ServiceUnavailableException("LIVISI SmartHome service is unavailable (503).");
210 logger.debug("Statuscode {} is NOT OK: [{}]", status, connection.getURL());
211 String content = normalizeResponseContent(responseContent);
213 logger.trace("Response error content: {}", content);
214 final Optional<ErrorResponseDTO> errorOptional = gson.fromJson(content, ErrorResponseDTO.class);
215 if (errorOptional.isPresent()) {
216 ErrorResponseDTO error = errorOptional.get();
217 switch (error.getCode()) {
218 case ErrorResponseDTO.ERR_SESSION_EXISTS:
219 throw new SessionExistsException("Session exists: " + error.getDescription());
220 case ErrorResponseDTO.ERR_SESSION_NOT_FOUND:
221 throw new SessionNotFoundException("Session not found: " + error.getDescription());
222 case ErrorResponseDTO.ERR_CONTROLLER_OFFLINE:
223 throw new ControllerOfflineException("Controller offline: " + error.getDescription());
224 case ErrorResponseDTO.ERR_REMOTE_ACCESS_NOT_ALLOWED:
225 throw new RemoteAccessNotAllowedException(
226 "Remote access not allowed. Access is allowed only from the SHC device network.");
227 case ErrorResponseDTO.ERR_INVALID_ACTION_TRIGGERED:
228 throw new InvalidActionTriggeredException(
229 "Invalid action triggered. Message: " + error.getDescription());
231 throw new ApiException("Unknown error: " + error);
233 } catch (final JsonSyntaxException e) {
234 throw new ApiException("Invalid JSON syntax in error response: " + content, e);
240 * Sets a new state of a SwitchActuator.
242 public void setSwitchActuatorState(final String capabilityId, final boolean state) throws IOException {
243 executePost(createActionURL(),
244 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_SWITCHACTUATOR, state));
248 * Sets the dimmer level of a DimmerActuator.
250 public void setDimmerActuatorState(final String capabilityId, final int dimLevel) throws IOException {
251 executePost(createActionURL(),
252 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_DIMMERACTUATOR, dimLevel));
256 * Sets the roller shutter level of a RollerShutterActuator.
258 public void setRollerShutterActuatorState(final String capabilityId, final int rollerShutterLevel)
260 executePost(createActionURL(),
261 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_ROLLERSHUTTERACTUATOR, rollerShutterLevel));
265 * Starts or stops moving a RollerShutterActuator
267 public void setRollerShutterAction(final String capabilityId, final ShutterActionType rollerShutterAction)
269 executePost(createActionURL(), new ShutterActionDTO(capabilityId, rollerShutterAction));
273 * Sets a new state of a VariableActuator.
275 public void setVariableActuatorState(final String capabilityId, final boolean state) throws IOException {
276 executePost(createActionURL(),
277 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_VARIABLEACTUATOR, state));
281 * Sets the point temperature.
283 public void setPointTemperatureState(final String capabilityId, final double pointTemperature) throws IOException {
284 executePost(createActionURL(),
285 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_THERMOSTATACTUATOR, pointTemperature));
289 * Sets the operation mode to "Auto" or "Manu".
291 public void setOperationMode(final String capabilityId, final boolean isAutoMode) throws IOException {
292 executePost(createActionURL(),
293 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_THERMOSTATACTUATOR, isAutoMode));
297 * Sets the alarm state.
299 public void setAlarmActuatorState(final String capabilityId, final boolean alarmState) throws IOException {
300 executePost(createActionURL(),
301 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_ALARMACTUATOR, alarmState));
305 * Load the device and returns a {@link List} of {@link DeviceDTO}s.
306 * VariableActuators are returned additionally (independent from the device ids),
307 * because VariableActuators are everytime available and never have a device state.
309 * @param deviceIds Ids of the devices to return
310 * @return List of Devices
312 public List<DeviceDTO> getDevices(Collection<String> deviceIds) throws IOException {
313 logger.debug("Loading LIVISI SmartHome devices...");
314 List<DeviceDTO> devices = executeGetList(URLCreator.createDevicesURL(bridgeConfiguration.host),
316 return devices.stream().filter(d -> isDeviceUsable(d, deviceIds)).collect(Collectors.toList());
320 * Loads the {@link DeviceDTO} with the given deviceId.
322 public Optional<DeviceDTO> getDeviceById(final String deviceId) throws IOException {
323 logger.debug("Loading device with id {}...", deviceId);
324 return executeGet(URLCreator.createDeviceURL(bridgeConfiguration.host, deviceId), DeviceDTO.class);
328 * Loads the states for all {@link DeviceDTO}s.
330 public List<DeviceStateDTO> getDeviceStates() throws IOException {
331 logger.debug("Loading device states...");
332 return executeGetList(URLCreator.createDeviceStatesURL(bridgeConfiguration.host), DeviceStateDTO[].class);
336 * Loads the device state for the given deviceId.
338 public @Nullable StateDTO getDeviceStateByDeviceId(final String deviceId, final boolean isSHCClassic)
340 logger.debug("Loading device states for device id {}...", deviceId);
342 Optional<DeviceStateDTO> deviceState = executeGet(
343 URLCreator.createDeviceStateURL(bridgeConfiguration.host, deviceId), DeviceStateDTO.class);
344 return deviceState.map(DeviceStateDTO::getState).orElse(null);
346 return executeGet(URLCreator.createDeviceStateURL(bridgeConfiguration.host, deviceId), StateDTO.class)
351 * Loads the locations and returns a {@link List} of {@link LocationDTO}s.
353 * @return a List of Devices
355 public List<LocationDTO> getLocations() throws IOException {
356 logger.debug("Loading locations...");
357 return executeGetList(URLCreator.createLocationURL(bridgeConfiguration.host), LocationDTO[].class);
361 * Loads and returns a {@link List} of {@link CapabilityDTO}s for the given deviceId.
363 * @param deviceId the id of the {@link DeviceDTO}
364 * @return capabilities of the device
366 public List<CapabilityDTO> getCapabilitiesForDevice(final String deviceId) throws IOException {
367 logger.debug("Loading capabilities for device {}...", deviceId);
368 return executeGetList(URLCreator.createDeviceCapabilitiesURL(bridgeConfiguration.host, deviceId),
369 CapabilityDTO[].class);
373 * Loads and returns a {@link List} of all {@link CapabilityDTO}s.
375 public List<CapabilityDTO> getCapabilities() throws IOException {
376 logger.debug("Loading capabilities...");
377 return executeGetList(URLCreator.createCapabilityURL(bridgeConfiguration.host), CapabilityDTO[].class);
381 * Loads and returns a {@link List} of all {@link CapabilityDTO}States.
383 public List<CapabilityStateDTO> getCapabilityStates() throws IOException {
384 logger.debug("Loading capability states...");
385 return executeGetList(URLCreator.createCapabilityStatesURL(bridgeConfiguration.host),
386 CapabilityStateDTO[].class);
390 * Returns a {@link List} of all {@link MessageDTO}s.
392 public List<MessageDTO> getMessages() throws IOException {
393 logger.debug("Loading messages...");
394 return executeGetList(URLCreator.createMessageURL(bridgeConfiguration.host), MessageDTO[].class);
397 private String createActionURL() {
398 return URLCreator.createActionURL(bridgeConfiguration.host);
402 * Decides if a (discovered) device is usable (available and supported).
404 * @param device device to check
405 * @param activeDeviceIds active device id (devices with an according available device state)
406 * @return true when usable, otherwise false
408 private static boolean isDeviceUsable(DeviceDTO device, Collection<String> activeDeviceIds) {
409 return activeDeviceIds.contains(device.getId())
410 || LivisiBindingConstants.DEVICE_VARIABLE_ACTUATOR.equals(device.getType());
414 * Normalizes the JSON response content.
415 * The LIVISI SmartHome local API returns "[]" for missing objects instead of "null". This method fixes
418 * @param responseContent response
419 * @return normalized response content
421 private static String normalizeResponseContent(String responseContent) {
422 return responseContent.replace("[]", "null");