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.coolmasternet.internal;
15 import static java.nio.charset.StandardCharsets.US_ASCII;
16 import static java.util.concurrent.TimeUnit.SECONDS;
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;
30 import javax.measure.Unit;
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;
49 * Bridge to access a CoolMasterNet unit's ASCII protocol via TCP socket.
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.
55 * @author Angus Gratton - Initial contribution
56 * @author Wouter Born - Fix null pointer exceptions and stop refresh job on update/dispose
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;
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();
73 private @Nullable ScheduledFuture<?> poller;
74 private @Nullable Socket socket;
76 public ControllerHandler(final Bridge thing) {
81 public void initialize() {
82 cfg = getConfigAs(ControllerConfiguration.class);
83 updateStatus(ThingStatus.UNKNOWN);
84 determineTemperatureUnits();
90 public void dispose() {
91 updateStatus(ThingStatus.OFFLINE);
97 * Obtain the temperature unit configured for this controller.
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.
105 * @return the unit as determined from the first line of the "ls" command
107 public Unit<?> getUnit() {
111 private void determineTemperatureUnits() {
112 synchronized (socketLock) {
115 final String ls = sendCommand("ls");
116 if (ls.length() < LS_LINE_LENGTH) {
117 throw new CoolMasterClientError("Invalid 'ls' response: '%s'", ls);
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());
129 private void startPoller() {
130 synchronized (scheduler) {
131 logger.debug("Scheduling new poller");
132 poller = scheduler.scheduleWithFixedDelay(this::poll, 0, cfg.refresh, SECONDS);
136 private void stopPoller() {
137 synchronized (scheduler) {
138 final ScheduledFuture<?> poller = this.poller;
139 if (poller != null) {
140 logger.debug("Cancelling existing poller");
147 private void poll() {
150 } catch (final IOException ioe) {
151 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ioe.getMessage());
154 for (Thing t : getThing().getThings()) {
155 final HVACHandler h = (HVACHandler) t.getHandler();
161 updateStatus(ThingStatus.ONLINE);
163 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
168 * Passively determine if the client socket appears to be connected, but do
169 * modify the connection state.
172 * Use {@link #checkConnection()} if active verification (and potential
173 * reconnection) of the CoolNetMaster connection is required.
175 public boolean isConnected() {
176 synchronized (socketLock) {
177 final Socket socket = this.socket;
178 return socket != null && socket.isConnected() && !socket.isClosed();
183 * Send a specific ASCII I/F command to CoolMasterNet and return its response.
186 * This method automatically acquires a connection.
188 * @return the server response to the command (never empty)
189 * @throws {@link IOException} if communications failed with the server
191 public String sendCommand(final String command) throws IOException {
192 synchronized (socketLock) {
195 final StringBuilder response = new StringBuilder();
197 final Socket socket = this.socket;
198 if (socket == null || !isConnected()) {
199 throw new CoolMasterClientError(String.format("No connection for sending command %s", command));
202 logger.trace("Sending command '{}'", command);
203 final Writer out = new OutputStreamWriter(socket.getOutputStream(), US_ASCII);
208 final Reader isr = new InputStreamReader(socket.getInputStream(), US_ASCII);
209 final BufferedReader in = new BufferedReader(isr);
211 String line = in.readLine();
212 logger.trace("Read result '{}'", line);
213 if (line == null || "OK".equals(line)) {
214 return response.toString();
216 response.append(line);
217 if (response.length() > MAX_VALID_LINE_LENGTH) {
218 throw new CoolMasterClientError("Command '%s' received unexpected response '%s'", command,
222 } catch (final SocketTimeoutException ste) {
223 if (response.length() == 0) {
224 throw new CoolMasterClientError("Command '%s' received no response", command);
226 throw new CoolMasterClientError("Command '%s' received truncated response '%s'", command, response);
232 * Ensure a client socket is connected and ready to receive commands.
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}.
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.
242 * @throws IOException if the socket could not be made ready
244 private void checkConnection() throws IOException {
245 synchronized (socketLock) {
247 // Longer sink time used for initial connection welcome > prompt
250 sinkTime = SINK_TIMEOUT_MS;
252 sinkTime = SOCKET_TIMEOUT_MS;
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");
261 final InputStream in = socket.getInputStream();
263 // Sink (clear) buffer until earlier of the sinkTime or > prompt
265 socket.setSoTimeout(sinkTime);
266 logger.trace("Waiting {} ms for buffer to sink", sinkTime);
273 if (in.available() > 0) {
274 throw new IOException("Unexpected data following prompt");
276 logger.trace("Buffer empty following unsolicited > prompt");
280 } catch (final SocketTimeoutException expectedFromRead) {
282 socket.setSoTimeout(SOCKET_TIMEOUT_MS);
285 // Solicit for a prompt given we haven't received one earlier
286 final Writer out = new OutputStreamWriter(socket.getOutputStream(), US_ASCII);
290 // Block until the > prompt arrives or IOE if SOCKET_TIMEOUT_MS
291 final int b = in.read();
293 throw new IOException("Unexpected character received");
295 if (in.available() > 0) {
296 throw new IOException("Unexpected data following prompt");
298 logger.trace("Buffer empty following solicited > prompt");
299 } catch (final IOException ioe) {
310 * Guarantees to either open the socket or thrown an exception.
312 * @throws IOException if the socket could not be opened
314 private void connect() throws IOException {
315 synchronized (socketLock) {
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) {
330 * Attempts to disconnect the socket.
333 * Disconnection failure is not considered an error, although will be logged.
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);
342 } catch (final IOException ioe) {
343 logger.warn("Could not disconnect", ioe);
351 * Encodes ASCII I/F protocol error messages.
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).
358 public class CoolMasterClientError extends IOException {
359 private static final long serialVersionUID = 2L;
361 public CoolMasterClientError(final String message) {
365 public CoolMasterClientError(String format, Object... args) {
366 super(String.format(format, args));
371 public void handleCommand(final ChannelUID channelUID, final Command command) {