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.somfymylink.internal.handler;
15 import static org.openhab.binding.somfymylink.internal.SomfyMyLinkBindingConstants.CHANNEL_SCENES;
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;
26 import java.util.ArrayList;
27 import java.util.Collection;
28 import java.util.Collections;
29 import java.util.List;
30 import java.util.Objects;
31 import java.util.concurrent.CompletableFuture;
32 import java.util.concurrent.ExecutionException;
33 import java.util.concurrent.ExecutorService;
34 import java.util.concurrent.Executors;
35 import java.util.concurrent.ScheduledFuture;
36 import java.util.concurrent.TimeUnit;
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.openhab.binding.somfymylink.internal.SomfyMyLinkBindingConstants;
41 import org.openhab.binding.somfymylink.internal.config.SomfyMyLinkConfiguration;
42 import org.openhab.binding.somfymylink.internal.discovery.SomfyMyLinkDeviceDiscoveryService;
43 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandBase;
44 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandSceneList;
45 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandSceneSet;
46 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeDown;
47 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeList;
48 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadePing;
49 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeStop;
50 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkCommandShadeUp;
51 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkErrorResponse;
52 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkPingResponse;
53 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkResponseBase;
54 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkScene;
55 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkScenesResponse;
56 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkShade;
57 import org.openhab.binding.somfymylink.internal.model.SomfyMyLinkShadesResponse;
58 import org.openhab.core.common.NamedThreadFactory;
59 import org.openhab.core.library.types.StringType;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.binding.BaseBridgeHandler;
66 import org.openhab.core.thing.binding.ThingHandlerService;
67 import org.openhab.core.types.Command;
68 import org.openhab.core.types.RefreshType;
69 import org.openhab.core.types.StateOption;
70 import org.slf4j.Logger;
71 import org.slf4j.LoggerFactory;
73 import com.google.gson.Gson;
74 import com.google.gson.JsonObject;
75 import com.google.gson.JsonParser;
78 * The {@link SomfyMyLinkBridgeHandler} is responsible for handling commands, which are
79 * sent to one of the channels.
81 * @author Chris Johnson - Initial contribution
84 public class SomfyMyLinkBridgeHandler extends BaseBridgeHandler {
86 private final Logger logger = LoggerFactory.getLogger(SomfyMyLinkBridgeHandler.class);
87 private static final int HEARTBEAT_MINUTES = 2;
88 private static final int MYLINK_PORT = 44100;
89 private static final int MYLINK_DEFAULT_TIMEOUT = 5000;
90 private static final int CONNECTION_DELAY = 1000;
91 private static final SomfyMyLinkShade[] EMPTY_SHADE_LIST = new SomfyMyLinkShade[0];
92 private static final SomfyMyLinkScene[] EMPTY_SCENE_LIST = new SomfyMyLinkScene[0];
94 private SomfyMyLinkConfiguration config = new SomfyMyLinkConfiguration();
95 private @Nullable ScheduledFuture<?> heartbeat;
96 private @Nullable SomfyMyLinkStateDescriptionOptionsProvider stateDescriptionProvider;
97 private @Nullable ExecutorService commandExecutor;
100 private final Gson gson = new Gson();
102 public SomfyMyLinkBridgeHandler(Bridge bridge,
103 @Nullable SomfyMyLinkStateDescriptionOptionsProvider stateDescriptionProvider) {
105 this.stateDescriptionProvider = stateDescriptionProvider;
109 public void handleCommand(ChannelUID channelUID, Command command) {
110 logger.debug("Command received on mylink {}", command);
113 if (CHANNEL_SCENES.equals(channelUID.getId())) {
114 if (command instanceof RefreshType) {
118 if (command instanceof StringType) {
119 Integer sceneId = Integer.decode(command.toString());
120 commandScene(sceneId);
123 } catch (SomfyMyLinkException e) {
124 logger.info("Error handling command: {}", e.getMessage());
125 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
130 public void initialize() {
131 logger.info("Initializing mylink");
132 config = getThing().getConfiguration().as(SomfyMyLinkConfiguration.class);
134 commandExecutor = Executors.newSingleThreadExecutor(new NamedThreadFactory(thing.getUID().getAsString(), true));
136 if (validConfiguration(config)) {
137 // start the keepalive process
138 if (heartbeat == null) {
139 logger.info("Starting heartbeat job every {} min", HEARTBEAT_MINUTES);
140 heartbeat = this.scheduler.scheduleWithFixedDelay(this::sendHeartbeat, 0, HEARTBEAT_MINUTES,
147 public Collection<Class<? extends ThingHandlerService>> getServices() {
148 return Collections.singleton(SomfyMyLinkDeviceDiscoveryService.class);
151 private boolean validConfiguration(@Nullable SomfyMyLinkConfiguration config) {
152 if (config == null) {
153 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "mylink configuration missing");
157 if (config.ipAddress.isEmpty() || config.systemId.isEmpty()) {
158 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
159 "mylink address or system id not specified");
166 private void cancelHeartbeat() {
167 logger.debug("Stopping heartbeat");
168 ScheduledFuture<?> heartbeat = this.heartbeat;
170 if (heartbeat != null) {
171 logger.debug("Cancelling heartbeat job");
172 heartbeat.cancel(true);
173 this.heartbeat = null;
175 logger.debug("Heartbeat was not active");
179 private void sendHeartbeat() {
181 logger.debug("Sending heartbeat");
183 SomfyMyLinkCommandShadePing command = new SomfyMyLinkCommandShadePing(config.systemId);
184 sendCommandWithResponse(command, SomfyMyLinkPingResponse.class).get();
185 updateStatus(ThingStatus.ONLINE);
186 } catch (SomfyMyLinkException | InterruptedException | ExecutionException e) {
187 logger.warn("Problem with mylink during heartbeat: {}", e.getMessage());
188 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
192 public SomfyMyLinkShade[] getShadeList() throws SomfyMyLinkException {
193 SomfyMyLinkCommandShadeList command = new SomfyMyLinkCommandShadeList(config.systemId);
196 SomfyMyLinkShadesResponse response = sendCommandWithResponse(command, SomfyMyLinkShadesResponse.class)
199 if (response != null) {
200 return response.getResult();
202 return EMPTY_SHADE_LIST;
204 } catch (InterruptedException | ExecutionException e) {
205 throw new SomfyMyLinkException("Problem while getting shade list.", e);
209 public SomfyMyLinkScene[] getSceneList() throws SomfyMyLinkException {
210 SomfyMyLinkCommandSceneList command = new SomfyMyLinkCommandSceneList(config.systemId);
211 SomfyMyLinkStateDescriptionOptionsProvider stateDescriptionProvider = this.stateDescriptionProvider;
214 SomfyMyLinkScenesResponse response = sendCommandWithResponse(command, SomfyMyLinkScenesResponse.class)
217 if (response != null && stateDescriptionProvider != null) {
218 List<StateOption> options = new ArrayList<>();
219 for (SomfyMyLinkScene scene : response.result) {
220 options.add(new StateOption(scene.getTargetID(), scene.getName()));
223 logger.debug("Setting {} options on bridge", options.size());
225 stateDescriptionProvider.setStateOptions(
226 new ChannelUID(getThing().getUID(), SomfyMyLinkBindingConstants.CHANNEL_SCENES), options);
228 return response.getResult();
230 return EMPTY_SCENE_LIST;
232 } catch (InterruptedException | ExecutionException e) {
233 throw new SomfyMyLinkException("Problem getting scene list.", e);
237 public void commandShadeUp(String targetId) throws SomfyMyLinkException {
238 SomfyMyLinkCommandShadeUp cmd = new SomfyMyLinkCommandShadeUp(targetId, config.systemId);
242 public void commandShadeDown(String targetId) throws SomfyMyLinkException {
243 SomfyMyLinkCommandShadeDown cmd = new SomfyMyLinkCommandShadeDown(targetId, config.systemId);
247 public void commandShadeStop(String targetId) throws SomfyMyLinkException {
248 SomfyMyLinkCommandShadeStop cmd = new SomfyMyLinkCommandShadeStop(targetId, config.systemId);
252 public void commandScene(Integer sceneId) throws SomfyMyLinkException {
253 SomfyMyLinkCommandSceneSet cmd = new SomfyMyLinkCommandSceneSet(sceneId, config.systemId);
257 private CompletableFuture<@Nullable Void> sendCommand(SomfyMyLinkCommandBase command) {
258 CompletableFuture<@Nullable Void> future = new CompletableFuture<>();
259 ExecutorService commandExecutor = this.commandExecutor;
260 if (commandExecutor != null) {
261 commandExecutor.execute(() -> {
262 String json = gson.toJson(command);
263 try (Socket socket = getConnection();
264 Writer out = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII)) {
265 logger.debug("Sending: {}", json);
268 logger.debug("Sent: {}", json);
270 // give time for mylink to process
271 Thread.sleep(CONNECTION_DELAY);
272 } catch (SocketTimeoutException e) {
273 logger.warn("Timeout sending command to mylink: {} Message: {}", json, e.getMessage());
274 } catch (IOException e) {
275 logger.warn("Problem sending command to mylink: {} Message: {}", json, e.getMessage());
276 } catch (InterruptedException e) {
277 logger.warn("Interrupted while waiting after sending command to mylink: {} Message: {}", json,
279 } catch (Exception e) {
280 logger.warn("Unexpected exception while sending command to mylink: {} Message: {}", json,
283 future.complete(null);
286 future.complete(null);
292 private <T extends SomfyMyLinkResponseBase> CompletableFuture<@Nullable T> sendCommandWithResponse(
293 SomfyMyLinkCommandBase command, Class<T> responseType) {
294 CompletableFuture<@Nullable T> future = new CompletableFuture<>();
295 ExecutorService commandExecutor = this.commandExecutor;
296 if (commandExecutor != null) {
297 commandExecutor.submit(() -> {
298 String json = gson.toJson(command);
300 try (Socket socket = getConnection();
301 Writer out = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII);
302 BufferedReader in = new BufferedReader(
303 new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII))) {
305 logger.debug("Sending: {}", json);
311 T response = parseResponse(in, responseType);
312 future.complete(response);
313 Thread.sleep(CONNECTION_DELAY);
315 } catch (SomfyMyLinkException e) {
316 future.completeExceptionally(e);
319 } catch (SocketTimeoutException e) {
320 logger.warn("Timeout sending command to mylink: {} Message: {}", json, e.getMessage());
321 future.completeExceptionally(new SomfyMyLinkException("Timeout sending command to mylink", e));
322 } catch (IOException e) {
323 logger.warn("Problem sending command to mylink: {} Message: {}", json, e.getMessage());
324 future.completeExceptionally(new SomfyMyLinkException("Problem sending command to mylink", e));
325 } catch (InterruptedException e) {
326 logger.warn("Interrupted while waiting after sending command to mylink: {} Message: {}", json,
328 future.complete(null);
329 } catch (Exception e) {
330 logger.warn("Unexpected exception while sending command to mylink: {} Message: {}", json,
332 future.completeExceptionally(e);
336 future.complete(null);
341 private <T extends SomfyMyLinkResponseBase> T parseResponse(Reader reader, Class<T> responseType) {
342 JsonObject jsonObj = JsonParser.parseReader(gson.newJsonReader(reader)).getAsJsonObject();
344 logger.debug("Got full message: {}", jsonObj.toString());
346 if (jsonObj.has("error")) {
347 SomfyMyLinkErrorResponse errorResponse = gson.fromJson(jsonObj, SomfyMyLinkErrorResponse.class);
348 logger.info("Error parsing mylink response: {}", errorResponse.error.message);
349 throw new SomfyMyLinkException("Incomplete message.");
352 return Objects.requireNonNull(gson.fromJson(jsonObj, responseType));
355 private Socket getConnection() throws IOException, SomfyMyLinkException {
357 logger.debug("Getting connection to mylink on: {} Post: {}", config.ipAddress, MYLINK_PORT);
358 String myLinkAddress = config.ipAddress;
359 Socket socket = new Socket(myLinkAddress, MYLINK_PORT);
360 socket.setSoTimeout(MYLINK_DEFAULT_TIMEOUT);
362 } catch (IOException e) {
363 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
369 public void thingUpdated(Thing thing) {
370 SomfyMyLinkConfiguration newConfig = thing.getConfiguration().as(SomfyMyLinkConfiguration.class);
375 public void dispose() {
377 dispose(commandExecutor);
380 private static void dispose(@Nullable ExecutorService executor) {
381 if (executor != null) {
382 executor.shutdownNow();