2 * Copyright (c) 2010-2021 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.somfymylink.internal.handler;
15 import static org.openhab.binding.somfymylink.internal.SomfyMyLinkBindingConstants.*;
17 import java.io.BufferedReader;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.io.OutputStreamWriter;
21 import java.io.Reader;
22 import java.io.Writer;
23 import java.net.Socket;
24 import java.net.SocketTimeoutException;
25 import java.nio.charset.StandardCharsets;
27 import java.util.concurrent.CompletableFuture;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.ExecutorService;
30 import java.util.concurrent.Executors;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.somfymylink.internal.SomfyMyLinkBindingConstants;
37 import org.openhab.binding.somfymylink.internal.config.SomfyMyLinkConfiguration;
38 import org.openhab.binding.somfymylink.internal.discovery.SomfyMyLinkDeviceDiscoveryService;
39 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandBase;
40 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandSceneList;
41 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandSceneSet;
42 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeDown;
43 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeList;
44 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadePing;
45 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeStop;
46 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeUp;
47 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkErrorResponse;
48 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkPingResponse;
49 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkResponseBase;
50 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkScene;
51 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkScenesResponse;
52 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkShade;
53 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkShadesResponse;
54 import org.openhab.core.common.NamedThreadFactory;
55 import org.openhab.core.library.types.StringType;
56 import org.openhab.core.thing.Bridge;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.binding.BaseBridgeHandler;
62 import org.openhab.core.thing.binding.ThingHandlerService;
63 import org.openhab.core.types.Command;
64 import org.openhab.core.types.RefreshType;
65 import org.openhab.core.types.StateOption;
66 import org.slf4j.Logger;
67 import org.slf4j.LoggerFactory;
69 import com.google.gson.Gson;
70 import com.google.gson.JsonObject;
71 import com.google.gson.JsonParser;
74 * The {@link SomfyMyLinkBridgeHandler} is responsible for handling commands, which are
75 * sent to one of the channels.
77 * @author Chris Johnson - Initial contribution
80 public class SomfyMyLinkBridgeHandler extends BaseBridgeHandler {
82 private final Logger logger = LoggerFactory.getLogger(SomfyMyLinkBridgeHandler.class);
83 private static final int HEARTBEAT_MINUTES = 2;
84 private static final int MYLINK_PORT = 44100;
85 private static final int MYLINK_DEFAULT_TIMEOUT = 5000;
86 private static final int CONNECTION_DELAY = 1000;
87 private static final SomfyMyLinkShade[] EMPTY_SHADE_LIST = new SomfyMyLinkShade[0];
88 private static final SomfyMyLinkScene[] EMPTY_SCENE_LIST = new SomfyMyLinkScene[0];
90 private SomfyMyLinkConfiguration config = new SomfyMyLinkConfiguration();
91 private @Nullable ScheduledFuture<?> heartbeat;
92 private @Nullable SomfyMyLinkStateDescriptionOptionsProvider stateDescriptionProvider;
93 private @Nullable ExecutorService commandExecutor;
96 private final Gson gson = new Gson();
98 public SomfyMyLinkBridgeHandler(Bridge bridge,
99 @Nullable SomfyMyLinkStateDescriptionOptionsProvider stateDescriptionProvider) {
101 this.stateDescriptionProvider = stateDescriptionProvider;
105 public void handleCommand(ChannelUID channelUID, Command command) {
106 logger.debug("Command received on mylink {}", command);
109 if (CHANNEL_SCENES.equals(channelUID.getId())) {
110 if (command instanceof RefreshType) {
114 if (command instanceof StringType) {
115 Integer sceneId = Integer.decode(command.toString());
116 commandScene(sceneId);
119 } catch (SomfyMyLinkException e) {
120 logger.info("Error handling command: {}", e.getMessage());
121 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
126 public void initialize() {
127 logger.info("Initializing mylink");
128 config = getThing().getConfiguration().as(SomfyMyLinkConfiguration.class);
130 commandExecutor = Executors.newSingleThreadExecutor(new NamedThreadFactory(thing.getUID().getAsString(), true));
132 if (validConfiguration(config)) {
133 // start the keepalive process
134 if (heartbeat == null) {
135 logger.info("Starting heartbeat job every {} min", HEARTBEAT_MINUTES);
136 heartbeat = this.scheduler.scheduleWithFixedDelay(this::sendHeartbeat, 0, HEARTBEAT_MINUTES,
143 public Collection<Class<? extends ThingHandlerService>> getServices() {
144 return Collections.singleton(SomfyMyLinkDeviceDiscoveryService.class);
147 private boolean validConfiguration(@Nullable SomfyMyLinkConfiguration config) {
148 if (config == null) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "mylink configuration missing");
153 if (config.ipAddress.isEmpty() || config.systemId.isEmpty()) {
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
155 "mylink address or system id not specified");
162 private void cancelHeartbeat() {
163 logger.debug("Stopping heartbeat");
164 ScheduledFuture<?> heartbeat = this.heartbeat;
166 if (heartbeat != null) {
167 logger.debug("Cancelling heartbeat job");
168 heartbeat.cancel(true);
169 this.heartbeat = null;
171 logger.debug("Heartbeat was not active");
175 private void sendHeartbeat() {
177 logger.debug("Sending heartbeat");
179 SomfyMyLinkCommandShadePing command = new SomfyMyLinkCommandShadePing(config.systemId);
180 sendCommandWithResponse(command, SomfyMyLinkPingResponse.class).get();
181 updateStatus(ThingStatus.ONLINE);
183 } catch (SomfyMyLinkException | InterruptedException | ExecutionException e) {
184 logger.warn("Problem with mylink during heartbeat: {}", e.getMessage());
185 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
189 public SomfyMyLinkShade[] getShadeList() throws SomfyMyLinkException {
190 SomfyMyLinkCommandShadeList command = new SomfyMyLinkCommandShadeList(config.systemId);
193 SomfyMyLinkShadesResponse response = sendCommandWithResponse(command, SomfyMyLinkShadesResponse.class)
196 if (response != null) {
197 return response.getResult();
199 return EMPTY_SHADE_LIST;
201 } catch (InterruptedException | ExecutionException e) {
202 throw new SomfyMyLinkException("Problem while getting shade list.", e);
206 public SomfyMyLinkScene[] getSceneList() throws SomfyMyLinkException {
207 SomfyMyLinkCommandSceneList command = new SomfyMyLinkCommandSceneList(config.systemId);
208 SomfyMyLinkStateDescriptionOptionsProvider stateDescriptionProvider = this.stateDescriptionProvider;
211 SomfyMyLinkScenesResponse response = sendCommandWithResponse(command, SomfyMyLinkScenesResponse.class)
214 if (response != null && stateDescriptionProvider != null) {
215 List<StateOption> options = new ArrayList<>();
216 for (SomfyMyLinkScene scene : response.result) {
217 options.add(new StateOption(scene.getTargetID(), scene.getName()));
220 logger.debug("Setting {} options on bridge", options.size());
222 stateDescriptionProvider.setStateOptions(
223 new ChannelUID(getThing().getUID(), SomfyMyLinkBindingConstants.CHANNEL_SCENES), options);
225 return response.getResult();
227 return EMPTY_SCENE_LIST;
229 } catch (InterruptedException | ExecutionException e) {
230 throw new SomfyMyLinkException("Problem getting scene list.", e);
234 public void commandShadeUp(String targetId) throws SomfyMyLinkException {
235 SomfyMyLinkCommandShadeUp cmd = new SomfyMyLinkCommandShadeUp(targetId, config.systemId);
239 public void commandShadeDown(String targetId) throws SomfyMyLinkException {
240 SomfyMyLinkCommandShadeDown cmd = new SomfyMyLinkCommandShadeDown(targetId, config.systemId);
244 public void commandShadeStop(String targetId) throws SomfyMyLinkException {
245 SomfyMyLinkCommandShadeStop cmd = new SomfyMyLinkCommandShadeStop(targetId, config.systemId);
249 public void commandScene(Integer sceneId) throws SomfyMyLinkException {
250 SomfyMyLinkCommandSceneSet cmd = new SomfyMyLinkCommandSceneSet(sceneId, config.systemId);
254 private CompletableFuture<@Nullable Void> sendCommand(SomfyMyLinkCommandBase command) {
255 CompletableFuture<@Nullable Void> future = new CompletableFuture<>();
256 ExecutorService commandExecutor = this.commandExecutor;
257 if (commandExecutor != null) {
258 commandExecutor.execute(() -> {
259 String json = gson.toJson(command);
260 try (Socket socket = getConnection();
261 Writer out = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII)) {
262 logger.debug("Sending: {}", json);
265 logger.debug("Sent: {}", json);
267 // give time for mylink to process
268 Thread.sleep(CONNECTION_DELAY);
269 } catch (SocketTimeoutException e) {
270 logger.warn("Timeout sending command to mylink: {} Message: {}", json, e.getMessage());
271 } catch (IOException e) {
272 logger.warn("Problem sending command to mylink: {} Message: {}", json, e.getMessage());
273 } catch (InterruptedException e) {
274 logger.warn("Interrupted while waiting after sending command to mylink: {} Message: {}", json,
276 } catch (Exception e) {
277 logger.warn("Unexpected exception while sending command to mylink: {} Message: {}", json,
280 future.complete(null);
283 future.complete(null);
289 private <T extends SomfyMyLinkResponseBase> CompletableFuture<@Nullable T> sendCommandWithResponse(
290 SomfyMyLinkCommandBase command, Class<T> responseType) {
291 CompletableFuture<@Nullable T> future = new CompletableFuture<>();
292 ExecutorService commandExecutor = this.commandExecutor;
293 if (commandExecutor != null) {
294 commandExecutor.submit(() -> {
295 String json = gson.toJson(command);
297 try (Socket socket = getConnection();
298 Writer out = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII);
299 BufferedReader in = new BufferedReader(
300 new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII))) {
303 logger.debug("Sending: {}", json);
309 T response = parseResponse(in, responseType);
310 future.complete(response);
311 Thread.sleep(CONNECTION_DELAY);
313 } catch (SomfyMyLinkException e) {
314 future.completeExceptionally(e);
317 } catch (SocketTimeoutException e) {
318 logger.warn("Timeout sending command to mylink: {} Message: {}", json, e.getMessage());
319 future.completeExceptionally(new SomfyMyLinkException("Timeout sending command to mylink", e));
320 } catch (IOException e) {
321 logger.warn("Problem sending command to mylink: {} Message: {}", json, e.getMessage());
322 future.completeExceptionally(new SomfyMyLinkException("Problem sending command to mylink", e));
323 } catch (InterruptedException e) {
324 logger.warn("Interrupted while waiting after sending command to mylink: {} Message: {}", json,
326 future.complete(null);
327 } catch (Exception e) {
328 logger.warn("Unexpected exception while sending command to mylink: {} Message: {}", json,
330 future.completeExceptionally(e);
334 future.complete(null);
339 private <T extends SomfyMyLinkResponseBase> T parseResponse(Reader reader, Class<T> responseType) {
340 JsonParser parser = new JsonParser();
341 JsonObject jsonObj = parser.parse(gson.newJsonReader(reader)).getAsJsonObject();
343 logger.debug("Got full message: {}", jsonObj.toString());
345 if (jsonObj.has("error")) {
346 SomfyMyLinkErrorResponse errorResponse = gson.fromJson(jsonObj, SomfyMyLinkErrorResponse.class);
347 logger.info("Error parsing mylink response: {}", errorResponse.error.message);
348 throw new SomfyMyLinkException("Incomplete message.");
351 return Objects.requireNonNull(gson.fromJson(jsonObj, responseType));
354 private Socket getConnection() throws IOException, SomfyMyLinkException {
356 logger.debug("Getting connection to mylink on: {} Post: {}", config.ipAddress, MYLINK_PORT);
357 String myLinkAddress = config.ipAddress;
358 Socket socket = new Socket(myLinkAddress, MYLINK_PORT);
359 socket.setSoTimeout(MYLINK_DEFAULT_TIMEOUT);
361 } catch (IOException e) {
362 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
368 public void thingUpdated(Thing thing) {
369 SomfyMyLinkConfiguration newConfig = thing.getConfiguration().as(SomfyMyLinkConfiguration.class);
374 public void dispose() {
376 dispose(commandExecutor);
379 private static void dispose(@Nullable ExecutorService executor) {
380 if (executor != null) {
381 executor.shutdownNow();