]> git.basschouten.com Git - openhab-addons.git/blob
ddefd3fcd6ec6e0e90684a0d9685ab1890363f17
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.coolmasternet.internal;
14
15 import static java.nio.charset.StandardCharsets.US_ASCII;
16 import static java.util.concurrent.TimeUnit.SECONDS;
17
18 import java.io.BufferedReader;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.io.InputStreamReader;
22 import java.io.OutputStreamWriter;
23 import java.io.Reader;
24 import java.io.Writer;
25 import java.net.InetSocketAddress;
26 import java.net.Socket;
27 import java.net.SocketTimeoutException;
28 import java.util.concurrent.ScheduledFuture;
29
30 import javax.measure.Unit;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.coolmasternet.internal.config.ControllerConfiguration;
35 import org.openhab.binding.coolmasternet.internal.handler.HVACHandler;
36 import org.openhab.core.library.unit.ImperialUnits;
37 import org.openhab.core.library.unit.SIUnits;
38 import org.openhab.core.thing.Bridge;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.Thing;
41 import org.openhab.core.thing.ThingStatus;
42 import org.openhab.core.thing.ThingStatusDetail;
43 import org.openhab.core.thing.binding.BaseBridgeHandler;
44 import org.openhab.core.types.Command;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
47
48 /**
49  * Bridge to access a CoolMasterNet unit's ASCII protocol via TCP socket.
50  *
51  * <p>
52  * A single CoolMasterNet can be connected to one or more HVAC units, each with
53  * a unique UID. Each HVAC is an individual thing inside the bridge.
54  *
55  * @author Angus Gratton - Initial contribution
56  * @author Wouter Born - Fix null pointer exceptions and stop refresh job on update/dispose
57  */
58 @NonNullByDefault
59 public final class ControllerHandler extends BaseBridgeHandler {
60     private static final String LF = "\n";
61     private static final byte PROMPT = ">".getBytes(US_ASCII)[0];
62     private static final int LS_LINE_LENGTH = 36;
63     private static final int LS_LINE_TEMP_SCALE_OFFSET = 13;
64     private static final int MAX_VALID_LINE_LENGTH = LS_LINE_LENGTH * 20;
65     private static final int SINK_TIMEOUT_MS = 25;
66     private static final int SOCKET_TIMEOUT_MS = 2000;
67
68     private ControllerConfiguration cfg = new ControllerConfiguration();
69     private Unit<?> unit = SIUnits.CELSIUS;
70     private final Logger logger = LoggerFactory.getLogger(ControllerHandler.class);
71     private final Object socketLock = new Object();
72
73     private @Nullable ScheduledFuture<?> poller;
74     private @Nullable Socket socket;
75
76     public ControllerHandler(final Bridge thing) {
77         super(thing);
78     }
79
80     @Override
81     public void initialize() {
82         cfg = getConfigAs(ControllerConfiguration.class);
83         updateStatus(ThingStatus.UNKNOWN);
84         determineTemperatureUnits();
85         stopPoller();
86         startPoller();
87     }
88
89     @Override
90     public void dispose() {
91         updateStatus(ThingStatus.OFFLINE);
92         stopPoller();
93         disconnect();
94     }
95
96     /**
97      * Obtain the temperature unit configured for this controller.
98      *
99      * <p>
100      * CoolMasterNet defaults to Celsius, but allows a user to change the scale
101      * on a per-controller basis using the ASCII I/F "set deg" command. Given
102      * changing the unit is very rarely performed, there is no direct support
103      * for doing so within this binding.
104      *
105      * @return the unit as determined from the first line of the "ls" command
106      */
107     public Unit<?> getUnit() {
108         return unit;
109     }
110
111     private void determineTemperatureUnits() {
112         synchronized (socketLock) {
113             try {
114                 checkConnection();
115                 final String ls = sendCommand("ls");
116                 if (ls.length() < LS_LINE_LENGTH) {
117                     throw new CoolMasterClientError("Invalid 'ls' response: '%s'", ls);
118                 }
119                 final char scale = ls.charAt(LS_LINE_TEMP_SCALE_OFFSET);
120                 unit = scale == 'C' ? SIUnits.CELSIUS : ImperialUnits.FAHRENHEIT;
121                 logger.trace("Temperature scale '{}' set to {}", scale, unit);
122             } catch (final IOException ioe) {
123                 logger.warn("Could not determine temperature scale", ioe);
124                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ioe.getMessage());
125             }
126         }
127     }
128
129     private void startPoller() {
130         synchronized (scheduler) {
131             logger.debug("Scheduling new poller");
132             poller = scheduler.scheduleWithFixedDelay(this::poll, 0, cfg.refresh, SECONDS);
133         }
134     }
135
136     private void stopPoller() {
137         synchronized (scheduler) {
138             final ScheduledFuture<?> poller = this.poller;
139             if (poller != null) {
140                 logger.debug("Cancelling existing poller");
141                 poller.cancel(true);
142                 this.poller = null;
143             }
144         }
145     }
146
147     private void poll() {
148         try {
149             checkConnection();
150         } catch (final IOException ioe) {
151             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ioe.getMessage());
152             return;
153         }
154         for (Thing t : getThing().getThings()) {
155             final HVACHandler h = (HVACHandler) t.getHandler();
156             if (h != null) {
157                 h.refresh();
158             }
159         }
160         if (isConnected()) {
161             updateStatus(ThingStatus.ONLINE);
162         } else {
163             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
164         }
165     }
166
167     /**
168      * Passively determine if the client socket appears to be connected, but do
169      * modify the connection state.
170      *
171      * <p>
172      * Use {@link #checkConnection()} if active verification (and potential
173      * reconnection) of the CoolNetMaster connection is required.
174      */
175     public boolean isConnected() {
176         synchronized (socketLock) {
177             final Socket socket = this.socket;
178             return socket != null && socket.isConnected() && !socket.isClosed();
179         }
180     }
181
182     /**
183      * Send a specific ASCII I/F command to CoolMasterNet and return its response.
184      *
185      * <p>
186      * This method automatically acquires a connection.
187      *
188      * @return the server response to the command (never empty)
189      * @throws {@link IOException} if communications failed with the server
190      */
191     public String sendCommand(final String command) throws IOException {
192         synchronized (socketLock) {
193             checkConnection();
194
195             final StringBuilder response = new StringBuilder();
196             try {
197                 final Socket socket = this.socket;
198                 if (socket == null || !isConnected()) {
199                     throw new CoolMasterClientError(String.format("No connection for sending command %s", command));
200                 }
201
202                 logger.trace("Sending command '{}'", command);
203                 final Writer out = new OutputStreamWriter(socket.getOutputStream(), US_ASCII);
204                 out.write(command);
205                 out.write(LF);
206                 out.flush();
207
208                 final Reader isr = new InputStreamReader(socket.getInputStream(), US_ASCII);
209                 final BufferedReader in = new BufferedReader(isr);
210                 while (true) {
211                     String line = in.readLine();
212                     logger.trace("Read result '{}'", line);
213                     if (line == null || "OK".equals(line)) {
214                         return response.toString();
215                     }
216                     response.append(line);
217                     if (response.length() > MAX_VALID_LINE_LENGTH) {
218                         throw new CoolMasterClientError("Command '%s' received unexpected response '%s'", command,
219                                 response);
220                     }
221                 }
222             } catch (final SocketTimeoutException ste) {
223                 if (response.length() == 0) {
224                     throw new CoolMasterClientError("Command '%s' received no response", command);
225                 }
226                 throw new CoolMasterClientError("Command '%s' received truncated response '%s'", command, response);
227             }
228         }
229     }
230
231     /**
232      * Ensure a client socket is connected and ready to receive commands.
233      *
234      * <p>
235      * This method may block for up to {@link #SOCKET_TIMEOUT_MS}, depending on
236      * the state of the connection. This usual time is {@link #SINK_TIMEOUT_MS}.
237      *
238      * <p>
239      * Return of this method guarantees the socket is ready to receive a
240      * command. If the socket could not be made ready, an exception is raised.
241      *
242      * @throws IOException if the socket could not be made ready
243      */
244     private void checkConnection() throws IOException {
245         synchronized (socketLock) {
246             try {
247                 // Longer sink time used for initial connection welcome > prompt
248                 final int sinkTime;
249                 if (isConnected()) {
250                     sinkTime = SINK_TIMEOUT_MS;
251                 } else {
252                     sinkTime = SOCKET_TIMEOUT_MS;
253                     connect();
254                 }
255
256                 final Socket socket = this.socket;
257                 if (socket == null) {
258                     throw new IllegalStateException(
259                             "Socket is null, which is unexpected because it was verified as available earlier in the same synchronized block; please log a bug report");
260                 }
261                 final InputStream in = socket.getInputStream();
262
263                 // Sink (clear) buffer until earlier of the sinkTime or > prompt
264                 try {
265                     socket.setSoTimeout(sinkTime);
266                     logger.trace("Waiting {} ms for buffer to sink", sinkTime);
267                     while (true) {
268                         int b = in.read();
269                         if (b == -1) {
270                             break;
271                         }
272                         if (b == PROMPT) {
273                             if (in.available() > 0) {
274                                 throw new IOException("Unexpected data following prompt");
275                             }
276                             logger.trace("Buffer empty following unsolicited > prompt");
277                             return;
278                         }
279                     }
280                 } catch (final SocketTimeoutException expectedFromRead) {
281                 } finally {
282                     socket.setSoTimeout(SOCKET_TIMEOUT_MS);
283                 }
284
285                 // Solicit for a prompt given we haven't received one earlier
286                 final Writer out = new OutputStreamWriter(socket.getOutputStream(), US_ASCII);
287                 out.write(LF);
288                 out.flush();
289
290                 // Block until the > prompt arrives or IOE if SOCKET_TIMEOUT_MS
291                 final int b = in.read();
292                 if (b != PROMPT) {
293                     throw new IOException("Unexpected character received");
294                 }
295                 if (in.available() > 0) {
296                     throw new IOException("Unexpected data following prompt");
297                 }
298                 logger.trace("Buffer empty following solicited > prompt");
299             } catch (final IOException ioe) {
300                 disconnect();
301                 throw ioe;
302             }
303         }
304     }
305
306     /**
307      * Opens the socket.
308      *
309      * <p>
310      * Guarantees to either open the socket or thrown an exception.
311      *
312      * @throws IOException if the socket could not be opened
313      */
314     private void connect() throws IOException {
315         synchronized (socketLock) {
316             try {
317                 logger.debug("Connecting to {}:{}", cfg.host, cfg.port);
318                 final Socket socket = new Socket();
319                 socket.connect(new InetSocketAddress(cfg.host, cfg.port), SOCKET_TIMEOUT_MS);
320                 socket.setSoTimeout(SOCKET_TIMEOUT_MS);
321                 this.socket = socket;
322             } catch (final IOException ioe) {
323                 socket = null;
324                 throw ioe;
325             }
326         }
327     }
328
329     /**
330      * Attempts to disconnect the socket.
331      *
332      * <p>
333      * Disconnection failure is not considered an error, although will be logged.
334      */
335     private void disconnect() {
336         synchronized (socketLock) {
337             final Socket socket = this.socket;
338             if (socket != null) {
339                 logger.debug("Disconnecting from {}:{}", cfg.host, cfg.port);
340                 try {
341                     socket.close();
342                 } catch (final IOException ioe) {
343                     logger.warn("Could not disconnect", ioe);
344                 }
345                 this.socket = null;
346             }
347         }
348     }
349
350     /**
351      * Encodes ASCII I/F protocol error messages.
352      *
353      * <p>
354      * This exception is not used for normal socket and connection failures.
355      * It is only used when there is a protocol level error (eg unexpected
356      * messages or malformed content from the CoolNetMaster server).
357      */
358     public class CoolMasterClientError extends IOException {
359         private static final long serialVersionUID = 2L;
360
361         public CoolMasterClientError(final String message) {
362             super(message);
363         }
364
365         public CoolMasterClientError(String format, Object... args) {
366             super(String.format(format, args));
367         }
368     }
369
370     @Override
371     public void handleCommand(final ChannelUID channelUID, final Command command) {
372     }
373 }