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