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.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 = new GsonOptional();
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;
91 * As the API returns the details of the SmartHome controller (SHC), the config version is returned.
93 * @return config version
95 public String refreshStatus() throws IOException {
96 logger.debug("Get LIVISI SmartHome status...");
97 final Optional<StatusResponseDTO> status = executeGet(URLCreator.createStatusURL(bridgeConfiguration.host),
98 StatusResponseDTO.class);
100 if (status.isPresent()) {
101 String configVersion = status.get().getConfigVersion();
102 logger.debug("LIVISI SmartHome status loaded. Configuration version is {}.", configVersion);
103 return configVersion;
109 * Executes a HTTP GET request with default headers and returns data as object of type T.
111 * @param url request URL
112 * @param clazz type of data to return
113 * @return response content
115 private <T> Optional<T> executeGet(final String url, final Class<T> clazz) throws IOException {
116 HttpURLConnection connection = createBaseRequest(url, HttpMethod.GET);
117 String responseContent = executeRequest(connection);
118 return gson.fromJson(responseContent, clazz);
122 * Executes a HTTP GET request with default headers and returns data as List of type T.
124 * @param url request URL
125 * @param clazz array type of data to return as list
126 * @return response content (as a List)
128 private <T> List<T> executeGetList(final String url, final Class<T[]> clazz) throws IOException {
129 Optional<T[]> objects = executeGet(url, clazz);
130 if (objects.isPresent()) {
131 return Arrays.asList(objects.get());
133 return Collections.emptyList();
137 * Executes a HTTP POST request with the given {@link ActionDTO} as content.
139 * @param url request URL
140 * @param action action to execute
142 private void executePost(final String url, final ActionDTO action) throws IOException {
143 final String json = gson.toJson(action);
144 final byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
145 logger.debug("Action {} JSON: {}", action.getType(), json);
147 HttpURLConnection connection = createBaseRequest(url, HttpMethod.POST);
148 connection.setDoOutput(true);
149 connection.setRequestProperty(HttpHeader.CONTENT_LENGTH.asString(), String.valueOf(jsonBytes.length));
150 try (OutputStream outputStream = connection.getOutputStream()) {
151 outputStream.write(jsonBytes);
154 executeRequest(connection);
157 private String executeRequest(HttpURLConnection connection) throws IOException {
158 StringBuilder stringBuilder = new StringBuilder();
159 try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
161 while ((line = reader.readLine()) != null) {
162 stringBuilder.append(line);
166 String responseContent = stringBuilder.toString();
167 logger.trace("RAW-RESPONSE: {}", responseContent);
168 handleResponseErrors(connection, responseContent);
169 return normalizeResponseContent(responseContent);
172 private HttpURLConnection createBaseRequest(String url, HttpMethod httpMethod) throws IOException {
173 final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
174 return connectionFactory.createBaseRequest(url, httpMethod, accessTokenResponse);
177 public AccessTokenResponse getAccessTokenResponse() throws IOException {
180 final AccessTokenResponse accessTokenResponse = oAuthService.getAccessTokenResponse();
181 if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null
182 || accessTokenResponse.getAccessToken().isBlank()) {
183 throw new AuthenticationException("No LIVISI SmartHome access token. Is this thing authorized?");
185 return accessTokenResponse;
186 } catch (OAuthException | OAuthResponseException e) {
187 throw new AuthenticationException("Error fetching access token: " + e.getMessage());
192 * Handles errors from the {@link org.eclipse.jetty.client.api.ContentResponse} and throws the following errors:
194 * @param connection connection
195 * @param responseContent response content
196 * @throws ControllerOfflineException thrown, if the LIVISI SmartHome controller (SHC) is offline.
198 private void handleResponseErrors(final HttpURLConnection connection, final String responseContent)
200 final int status = connection.getResponseCode();
201 if (HttpStatus.OK_200 == status) {
202 logger.debug("Statuscode is OK: [{}]", connection.getURL());
203 } else if (HttpStatus.SERVICE_UNAVAILABLE_503 == status) {
204 throw new ServiceUnavailableException("LIVISI SmartHome service is unavailable (503).");
206 logger.debug("Statuscode {} is NOT OK: [{}]", status, connection.getURL());
207 String content = normalizeResponseContent(responseContent);
209 logger.trace("Response error content: {}", content);
210 final Optional<ErrorResponseDTO> errorOptional = gson.fromJson(content, ErrorResponseDTO.class);
211 if (errorOptional.isPresent()) {
212 ErrorResponseDTO error = errorOptional.get();
213 switch (error.getCode()) {
214 case ErrorResponseDTO.ERR_SESSION_EXISTS:
215 throw new SessionExistsException("Session exists: " + error.getDescription());
216 case ErrorResponseDTO.ERR_SESSION_NOT_FOUND:
217 throw new SessionNotFoundException("Session not found: " + error.getDescription());
218 case ErrorResponseDTO.ERR_CONTROLLER_OFFLINE:
219 throw new ControllerOfflineException("Controller offline: " + error.getDescription());
220 case ErrorResponseDTO.ERR_REMOTE_ACCESS_NOT_ALLOWED:
221 throw new RemoteAccessNotAllowedException(
222 "Remote access not allowed. Access is allowed only from the SHC device network.");
223 case ErrorResponseDTO.ERR_INVALID_ACTION_TRIGGERED:
224 throw new InvalidActionTriggeredException(
225 "Invalid action triggered. Message: " + error.getDescription());
227 throw new ApiException("Unknown error: " + error);
229 } catch (final JsonSyntaxException e) {
230 throw new ApiException("Invalid JSON syntax in error response: " + content, e);
236 * Sets a new state of a SwitchActuator.
238 public void setSwitchActuatorState(final String capabilityId, final boolean state) throws IOException {
239 executePost(createActionURL(),
240 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_SWITCHACTUATOR, state));
244 * Sets the dimmer level of a DimmerActuator.
246 public void setDimmerActuatorState(final String capabilityId, final int dimLevel) throws IOException {
247 executePost(createActionURL(),
248 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_DIMMERACTUATOR, dimLevel));
252 * Sets the roller shutter level of a RollerShutterActuator.
254 public void setRollerShutterActuatorState(final String capabilityId, final int rollerShutterLevel)
256 executePost(createActionURL(),
257 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_ROLLERSHUTTERACTUATOR, rollerShutterLevel));
261 * Starts or stops moving a RollerShutterActuator
263 public void setRollerShutterAction(final String capabilityId, final ShutterActionType rollerShutterAction)
265 executePost(createActionURL(), new ShutterActionDTO(capabilityId, rollerShutterAction));
269 * Sets a new state of a VariableActuator.
271 public void setVariableActuatorState(final String capabilityId, final boolean state) throws IOException {
272 executePost(createActionURL(),
273 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_VARIABLEACTUATOR, state));
277 * Sets the point temperature.
279 public void setPointTemperatureState(final String capabilityId, final double pointTemperature) throws IOException {
280 executePost(createActionURL(),
281 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_THERMOSTATACTUATOR, pointTemperature));
285 * Sets the operation mode to "Auto" or "Manu".
287 public void setOperationMode(final String capabilityId, final boolean isAutoMode) throws IOException {
288 executePost(createActionURL(),
289 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_THERMOSTATACTUATOR, isAutoMode));
293 * Sets the alarm state.
295 public void setAlarmActuatorState(final String capabilityId, final boolean alarmState) throws IOException {
296 executePost(createActionURL(),
297 new StateActionSetterDTO(capabilityId, CapabilityDTO.TYPE_ALARMACTUATOR, alarmState));
301 * Load the device and returns a {@link List} of {@link DeviceDTO}s.
302 * VariableActuators are returned additionally (independent from the device ids),
303 * because VariableActuators are everytime available and never have a device state.
305 * @param deviceIds Ids of the devices to return
306 * @return List of Devices
308 public List<DeviceDTO> getDevices(Collection<String> deviceIds) throws IOException {
309 logger.debug("Loading LIVISI SmartHome devices...");
310 List<DeviceDTO> devices = executeGetList(URLCreator.createDevicesURL(bridgeConfiguration.host),
312 return devices.stream().filter(d -> isDeviceUsable(d, deviceIds)).collect(Collectors.toList());
316 * Loads the {@link DeviceDTO} with the given deviceId.
318 public Optional<DeviceDTO> getDeviceById(final String deviceId) throws IOException {
319 logger.debug("Loading device with id {}...", deviceId);
320 return executeGet(URLCreator.createDeviceURL(bridgeConfiguration.host, deviceId), DeviceDTO.class);
324 * Loads the states for all {@link DeviceDTO}s.
326 public List<DeviceStateDTO> getDeviceStates() throws IOException {
327 logger.debug("Loading device states...");
328 return executeGetList(URLCreator.createDeviceStatesURL(bridgeConfiguration.host), DeviceStateDTO[].class);
332 * Loads the device state for the given deviceId.
334 public @Nullable StateDTO getDeviceStateByDeviceId(final String deviceId, final boolean isSHCClassic)
336 logger.debug("Loading device states for device id {}...", deviceId);
338 Optional<DeviceStateDTO> deviceState = executeGet(
339 URLCreator.createDeviceStateURL(bridgeConfiguration.host, deviceId), DeviceStateDTO.class);
340 return deviceState.map(DeviceStateDTO::getState).orElse(null);
342 return executeGet(URLCreator.createDeviceStateURL(bridgeConfiguration.host, deviceId), StateDTO.class)
347 * Loads the locations and returns a {@link List} of {@link LocationDTO}s.
349 * @return a List of Devices
351 public List<LocationDTO> getLocations() throws IOException {
352 logger.debug("Loading locations...");
353 return executeGetList(URLCreator.createLocationURL(bridgeConfiguration.host), LocationDTO[].class);
357 * Loads and returns a {@link List} of {@link CapabilityDTO}s for the given deviceId.
359 * @param deviceId the id of the {@link DeviceDTO}
360 * @return capabilities of the device
362 public List<CapabilityDTO> getCapabilitiesForDevice(final String deviceId) throws IOException {
363 logger.debug("Loading capabilities for device {}...", deviceId);
364 return executeGetList(URLCreator.createDeviceCapabilitiesURL(bridgeConfiguration.host, deviceId),
365 CapabilityDTO[].class);
369 * Loads and returns a {@link List} of all {@link CapabilityDTO}s.
371 public List<CapabilityDTO> getCapabilities() throws IOException {
372 logger.debug("Loading capabilities...");
373 return executeGetList(URLCreator.createCapabilityURL(bridgeConfiguration.host), CapabilityDTO[].class);
377 * Loads and returns a {@link List} of all {@link CapabilityDTO}States.
379 public List<CapabilityStateDTO> getCapabilityStates() throws IOException {
380 logger.debug("Loading capability states...");
381 return executeGetList(URLCreator.createCapabilityStatesURL(bridgeConfiguration.host),
382 CapabilityStateDTO[].class);
386 * Returns a {@link List} of all {@link MessageDTO}s.
388 public List<MessageDTO> getMessages() throws IOException {
389 logger.debug("Loading messages...");
390 return executeGetList(URLCreator.createMessageURL(bridgeConfiguration.host), MessageDTO[].class);
393 private String createActionURL() {
394 return URLCreator.createActionURL(bridgeConfiguration.host);
398 * Decides if a (discovered) device is usable (available and supported).
400 * @param device device to check
401 * @param activeDeviceIds active device id (devices with an according available device state)
402 * @return true when usable, otherwise false
404 private static boolean isDeviceUsable(DeviceDTO device, Collection<String> activeDeviceIds) {
405 return activeDeviceIds.contains(device.getId())
406 || LivisiBindingConstants.DEVICE_VARIABLE_ACTUATOR.equals(device.getType());
410 * Normalizes the JSON response content.
411 * The LIVISI SmartHome local API returns "[]" for missing objects instead of "null". This method fixes
414 * @param responseContent response
415 * @return normalized response content
417 private static String normalizeResponseContent(String responseContent) {
418 return responseContent.replace("[]", "null");