]> git.basschouten.com Git - openhab-addons.git/blob
bd7d0ade517172eb73e0978f8bb84d747b2b8e33
[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.solarmax.internal.connector;
14
15 import java.io.IOException;
16 import java.io.InputStream;
17 import java.io.OutputStream;
18 import java.net.InetSocketAddress;
19 import java.net.Socket;
20 import java.net.UnknownHostException;
21 import java.time.ZonedDateTime;
22 import java.util.ArrayList;
23 import java.util.HashMap;
24 import java.util.List;
25 import java.util.Map;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.slf4j.Logger;
30 import org.slf4j.LoggerFactory;
31
32 /**
33  * 
34  * The {@link SolarMaxConnector} class is used to communicated with the SolarMax device (on a binary level)
35  *
36  * With a little help from https://github.com/sushiguru/solar-pv/blob/master/solmax/pv.php
37  * 
38  * @author Jamie Townsend - Initial contribution
39  */
40 @NonNullByDefault
41 public class SolarMaxConnector {
42
43     /**
44      * default port number of SolarMax devices is...
45      */
46     private static final int DEFAULT_PORT = 12345;
47
48     private static final Logger LOGGER = LoggerFactory.getLogger(SolarMaxConnector.class);
49
50     /**
51      * default timeout for socket connections is 1 second
52      */
53     private static final int CONNECTION_TIMEOUT = 1000;
54
55     /**
56      * default timeout for socket responses is 10 seconds
57      */
58     private static int responseTimeout = 10000;
59
60     /**
61      * gets all known values from the SolarMax device addressable at host:port
62      * 
63      * @param host hostname or ip address of the SolarMax device to be contacted
64      * @param port port the SolarMax is listening on (default is 12345)
65      * @param commandList a list of commands to be sent to the SolarMax device
66      * @return
67      * @throws UnknownHostException if the host is unknown
68      * @throws SolarMaxException if some other exception occurs
69      */
70     public static SolarMaxData getAllValuesFromSolarMax(final String host, int port) throws SolarMaxException {
71         List<SolarMaxCommandKey> commandList = new ArrayList<>();
72
73         for (SolarMaxCommandKey solarMaxCommandKey : SolarMaxCommandKey.values()) {
74             if (solarMaxCommandKey != SolarMaxCommandKey.UNKNOWN) {
75                 commandList.add(solarMaxCommandKey);
76             }
77         }
78
79         SolarMaxData solarMaxData = new SolarMaxData();
80
81         // get the data from the SolarMax device. If we didn't get as many values back as we asked for, there were
82         // communications problems, so set communicationSuccessful appropriately
83
84         Map<SolarMaxCommandKey, @Nullable String> valuesFromSolarMax = getValuesFromSolarMax(host, port, commandList);
85         boolean allCommandsAnswered = true;
86         for (SolarMaxCommandKey solarMaxCommandKey : commandList) {
87             if (!valuesFromSolarMax.containsKey(solarMaxCommandKey)) {
88                 allCommandsAnswered = false;
89                 break;
90             }
91         }
92         solarMaxData.setDataDateTime(ZonedDateTime.now());
93         solarMaxData.setCommunicationSuccessful(allCommandsAnswered);
94         solarMaxData.setData(valuesFromSolarMax);
95
96         return solarMaxData;
97     }
98
99     /**
100      * gets values from the SolarMax device addressable at host:port
101      * 
102      * @param host hostname or ip address of the SolarMax device to be contacted
103      * @param port port the SolarMax is listening on (default is 12345)
104      * @param commandList a list of commands to be sent to the SolarMax device
105      * @return
106      * @throws UnknownHostException if the host is unknown
107      * @throws SolarMaxException if some other exception occurs
108      */
109     private static Map<SolarMaxCommandKey, @Nullable String> getValuesFromSolarMax(final String host, int port,
110             final List<SolarMaxCommandKey> commandList) throws SolarMaxException {
111         Socket socket;
112
113         Map<SolarMaxCommandKey, @Nullable String> returnMap = new HashMap<>();
114
115         // SolarMax can't answer correclty if too many commands are send in a single request, so limit it to 16 at a
116         // time
117         int maxConcurrentCommands = 16;
118         int requestsRequired = (commandList.size() / maxConcurrentCommands);
119         if (commandList.size() % maxConcurrentCommands != 0) {
120             requestsRequired = requestsRequired + 1;
121         }
122         for (int requestNumber = 0; requestNumber < requestsRequired; requestNumber++) {
123             LOGGER.debug("    Requesting data from {}:{} with timeout of {}ms", host, port, responseTimeout);
124
125             int firstCommandNumber = requestNumber * maxConcurrentCommands;
126             int lastCommandNumber = (requestNumber + 1) * maxConcurrentCommands;
127             if (lastCommandNumber > commandList.size()) {
128                 lastCommandNumber = commandList.size();
129             }
130             List<SolarMaxCommandKey> commandsToSend = commandList.subList(firstCommandNumber, lastCommandNumber);
131
132             try {
133                 socket = getSocketConnection(host, port);
134             } catch (UnknownHostException e) {
135                 throw new SolarMaxConnectionException(e);
136             }
137             returnMap.putAll(getValuesFromSolarMax(socket, commandsToSend));
138
139             // SolarMax can't deal with requests too close to one another, so just wait a moment
140             try {
141                 Thread.sleep(10);
142             } catch (InterruptedException e) {
143                 // do nothing
144             }
145         }
146         return returnMap;
147     }
148
149     static String getCommandString(List<SolarMaxCommandKey> commandList) {
150         String commandString = "";
151         for (SolarMaxCommandKey command : commandList) {
152             if (!commandString.isEmpty()) {
153                 commandString = commandString + ";";
154             }
155             commandString = commandString + command.getCommandKey();
156         }
157         return commandString;
158     }
159
160     private static Map<SolarMaxCommandKey, @Nullable String> getValuesFromSolarMax(final Socket socket,
161             final List<SolarMaxCommandKey> commandList) throws SolarMaxException {
162         OutputStream outputStream = null;
163         InputStream inputStream = null;
164         try {
165             outputStream = socket.getOutputStream();
166             inputStream = socket.getInputStream();
167
168             return getValuesFromSolarMax(outputStream, inputStream, commandList);
169         } catch (final SolarMaxException | IOException e) {
170             throw new SolarMaxException("Error getting input/output streams from socket", e);
171         } finally {
172             try {
173                 socket.close();
174                 if (outputStream != null) {
175                     outputStream.close();
176                 }
177                 if (inputStream != null) {
178                     inputStream.close();
179                 }
180             } catch (final IOException e) {
181                 // ignore the error, we're dying anyway...
182             }
183         }
184     }
185
186     private static Map<SolarMaxCommandKey, @Nullable String> getValuesFromSolarMax(final OutputStream outputStream,
187             final InputStream inputStream, final List<SolarMaxCommandKey> commandList) throws SolarMaxException {
188         Map<SolarMaxCommandKey, @Nullable String> returnedValues;
189         String commandString = getCommandString(commandList);
190         String request = contructRequest(commandString);
191         try {
192             LOGGER.trace("    ==>: {}", request);
193
194             outputStream.write(request.getBytes());
195
196             String response = "";
197             byte[] responseByte = new byte[1];
198
199             // get everything from the stream
200             while (true) {
201                 // read one byte from the stream
202                 int bytesRead = inputStream.read(responseByte);
203
204                 // if there was nothing left, break
205                 if (bytesRead < 1) {
206                     break;
207                 }
208
209                 // add the received byte to the response
210                 final String responseString = new String(responseByte);
211                 response = response + responseString;
212
213                 // if it was the final expected character "}", break
214                 if ("}".equals(responseString)) {
215                     break;
216                 }
217             }
218
219             LOGGER.trace("    <==: {}", response);
220
221             if (!validateResponse(response)) {
222                 throw new SolarMaxException("Invalid response received: " + response);
223             }
224
225             returnedValues = extractValuesFromResponse(response);
226
227             return returnedValues;
228         } catch (IOException e) {
229             LOGGER.debug("Error communicating via input/output streams: {} ", e.getMessage());
230             throw new SolarMaxException(e);
231         }
232     }
233
234     /**
235      * @param response e.g.
236      *            "{01;FB;6D|64:KDY=82;KMT=8F;KYR=23F7;KT0=72F1;TNF=1386;TKK=28;PAC=1F70;PRL=28;IL1=236;UL1=8F9;SYS=4E28,0|19E5}"
237      * @return a map of keys and values
238      */
239     static Map<SolarMaxCommandKey, @Nullable String> extractValuesFromResponse(String response) {
240         final Map<SolarMaxCommandKey, @Nullable String> responseMap = new HashMap<>();
241
242         // in case there is no response
243         if (response.indexOf("|") == -1) {
244             LOGGER.warn("Response doesn't contain data. Response: {}", response);
245             return responseMap;
246         }
247
248         // extract the body first
249         // start by getting the part of the response between the two pipes
250         String body = response.substring(response.indexOf("|") + 1, response.lastIndexOf("|"));
251
252         // the name/value pairs now lie after the ":"
253         body = body.substring(body.indexOf(":") + 1);
254
255         // split into an array of name=value pairs
256         String[] entries = body.split(";");
257         for (String entry : entries) {
258
259             if (entry.length() != 0) {
260                 // could be split on "=" instead of fixed length or made to respect length of command, but they're all 3
261                 // characters long (then plus "=")
262                 String str = entry.substring(0, 3);
263
264                 String responseValue = (entry.length() >= 5) ? entry.substring(4) : null;
265
266                 SolarMaxCommandKey key = SolarMaxCommandKey.getKeyFromString(str);
267                 if (key != SolarMaxCommandKey.UNKNOWN) {
268                     responseMap.put(key, responseValue);
269                 }
270             }
271         }
272
273         return responseMap;
274     }
275
276     private static Socket getSocketConnection(final String host, int port)
277             throws SolarMaxConnectionException, UnknownHostException {
278         port = (port == 0) ? DEFAULT_PORT : port;
279
280         Socket socket;
281
282         try {
283             socket = new Socket();
284             socket.connect(new InetSocketAddress(host, port), CONNECTION_TIMEOUT);
285             socket.setSoTimeout(responseTimeout);
286         } catch (final UnknownHostException e) {
287             throw e;
288         } catch (final IOException e) {
289             throw new SolarMaxConnectionException("Error connecting to port '" + port + "' on host '" + host + "'", e);
290         }
291
292         return socket;
293     }
294
295     public static boolean connectionTest(final String host, int port) throws UnknownHostException {
296         Socket socket = null;
297
298         try {
299             socket = getSocketConnection(host, port);
300         } catch (SolarMaxConnectionException e) {
301             return false;
302         } finally {
303             if (socket != null) {
304                 try {
305                     socket.close();
306                 } catch (IOException e) {
307                     // ignore any error while trying to close the socket
308                 }
309             }
310         }
311
312         return true;
313     }
314
315     /**
316      * @return timeout for responses in milliseconds
317      */
318     public static int getResponseTimeout() {
319         return responseTimeout;
320     }
321
322     /**
323      * @param responseTimeout timeout for responses in milliseconds
324      */
325     public static void setResponseTimeout(int responseTimeout) {
326         SolarMaxConnector.responseTimeout = responseTimeout;
327     }
328
329     /**
330      * @param destinationDevice device number - used if devices are daisy-chained. Normally it will be "1"
331      * @param questions appears to be able to handle multiple commands. For now, one at a time is good fishing
332      * @return the request to be sent to the SolarMax device
333      */
334     static String contructRequest(final String questions) {
335         String src = "FB";
336         String dstHex = String.format("%02X", 1); // destinationDevice defaults to 1 and is ignored with TCP/IP
337         String len = "00";
338         String cs = "0000";
339         String msg = "64:" + questions;
340         int lenInt = ("{" + src + ";" + dstHex + ";" + len + "|" + msg + "|" + cs + "}").length();
341
342         // given the following, I'd expect problems if the request is longer than 255 characters. Since I'm not sure
343         // though, I won't fixe what isn't (yet) broken
344         String lenHex = String.format("%02X", lenInt);
345
346         String checksum = calculateChecksum16(src + ";" + dstHex + ";" + lenHex + "|" + msg + "|");
347
348         return "{" + src + ";" + dstHex + ";" + lenHex + "|" + msg + "|" + checksum + "}";
349     }
350
351     /**
352      * calculates the "checksum16" of the given string argument
353      */
354     static String calculateChecksum16(String str) {
355         byte[] bytes = str.getBytes();
356         int sum = 0;
357
358         // loop through each of the bytes and add them together
359         for (byte aByte : bytes) {
360             sum = sum + aByte;
361         }
362
363         // calculate the "checksum16"
364         sum = sum % (int) Math.pow(2, 16);
365
366         // return Integer.toHexString(sum);
367         return String.format("%04X", sum);
368     }
369
370     static boolean validateResponse(final String header) {
371         // probably should implement a patter matcher with a patternString like "/\\{([0-9A-F]{2});FB;([0-9A-F]{2})/",
372         // but for now...
373         return true;
374     }
375 }