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.solarmax.internal.connector;
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;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.slf4j.Logger;
30 import org.slf4j.LoggerFactory;
34 * The {@link SolarMaxConnector} class is used to communicated with the SolarMax device (on a binary level)
36 * With a little help from https://github.com/sushiguru/solar-pv/blob/master/solmax/pv.php
38 * @author Jamie Townsend - Initial contribution
41 public class SolarMaxConnector {
44 * default port number of SolarMax devices is...
46 private static final int DEFAULT_PORT = 12345;
48 private static final Logger LOGGER = LoggerFactory.getLogger(SolarMaxConnector.class);
51 * default timeout for socket connections is 1 second
53 private static final int CONNECTION_TIMEOUT = 1000;
56 * default timeout for socket responses is 10 seconds
58 private static int responseTimeout = 10000;
61 * gets all known values from the SolarMax device addressable at host:portNumber
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
67 * @throws UnknownHostException if the host is unknown
68 * @throws SolarMaxException if some other exception occurs
70 public static SolarMaxData getAllValuesFromSolarMax(final String host, final int portNumber,
71 final int deviceAddress) throws SolarMaxException {
72 List<SolarMaxCommandKey> commandList = new ArrayList<>();
74 for (SolarMaxCommandKey solarMaxCommandKey : SolarMaxCommandKey.values()) {
75 if (solarMaxCommandKey != SolarMaxCommandKey.UNKNOWN) {
76 commandList.add(solarMaxCommandKey);
80 SolarMaxData solarMaxData = new SolarMaxData();
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
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;
94 solarMaxData.setDataDateTime(ZonedDateTime.now());
95 solarMaxData.setCommunicationSuccessful(allCommandsAnswered);
96 solarMaxData.setData(valuesFromSolarMax);
102 * gets values from the SolarMax device addressable at host:portNumber
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
108 * @throws UnknownHostException if the host is unknown
109 * @throws SolarMaxException if some other exception occurs
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 {
116 Map<SolarMaxCommandKey, @Nullable String> returnMap = new HashMap<>();
118 // SolarMax can't answer correclty if too many commands are send in a single request, so limit it to 16 at a
120 int maxConcurrentCommands = 16;
121 int requestsRequired = (commandList.size() / maxConcurrentCommands);
122 if (commandList.size() % maxConcurrentCommands != 0) {
123 requestsRequired = requestsRequired + 1;
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);
129 int firstCommandNumber = requestNumber * maxConcurrentCommands;
130 int lastCommandNumber = (requestNumber + 1) * maxConcurrentCommands;
131 if (lastCommandNumber > commandList.size()) {
132 lastCommandNumber = commandList.size();
134 List<SolarMaxCommandKey> commandsToSend = commandList.subList(firstCommandNumber, lastCommandNumber);
137 socket = getSocketConnection(host, portNumber);
138 } catch (UnknownHostException e) {
139 throw new SolarMaxConnectionException(e);
141 returnMap.putAll(getValuesFromSolarMax(socket, deviceAddress, commandsToSend));
143 // SolarMax can't deal with requests too close to one another, so just wait a moment
146 } catch (InterruptedException e) {
153 static String getCommandString(List<SolarMaxCommandKey> commandList) {
154 String commandString = "";
155 for (SolarMaxCommandKey command : commandList) {
156 if (!commandString.isEmpty()) {
157 commandString = commandString + ";";
159 commandString = commandString + command.getCommandKey();
161 return commandString;
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;
169 outputStream = socket.getOutputStream();
170 inputStream = socket.getInputStream();
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);
178 if (outputStream != null) {
179 outputStream.close();
181 if (inputStream != null) {
184 } catch (final IOException e) {
185 // ignore the error, we're dying anyway...
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);
197 LOGGER.trace(" ==>: {}", request);
199 outputStream.write(request.getBytes());
201 String response = "";
202 byte[] responseByte = new byte[1];
204 // get everything from the stream
206 // read one byte from the stream
207 int bytesRead = inputStream.read(responseByte);
209 // if there was nothing left, break
214 // add the received byte to the response
215 final String responseString = new String(responseByte);
216 response = response + responseString;
218 // if it was the final expected character "}", break
219 if ("}".equals(responseString)) {
224 LOGGER.trace(" <==: {}", response);
226 if (!validateResponse(response)) {
227 throw new SolarMaxException("Invalid response received: " + response);
230 returnedValues = extractValuesFromResponse(response);
232 return returnedValues;
233 } catch (IOException e) {
234 LOGGER.debug("Error communicating via input/output streams: {} ", e.getMessage());
235 throw new SolarMaxException(e);
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
244 static Map<SolarMaxCommandKey, @Nullable String> extractValuesFromResponse(String response) {
245 final Map<SolarMaxCommandKey, @Nullable String> responseMap = new HashMap<>();
247 // in case there is no response
248 if (response.indexOf("|") == -1) {
249 LOGGER.warn("Response doesn't contain data. Response: {}", response);
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("|"));
257 // the name/value pairs now lie after the ":"
258 body = body.substring(body.indexOf(":") + 1);
260 // split into an array of name=value pairs
261 String[] entries = body.split(";");
262 for (String entry : entries) {
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);
269 String responseValue = (entry.length() >= 5) ? entry.substring(4) : null;
271 SolarMaxCommandKey key = SolarMaxCommandKey.getKeyFromString(str);
272 if (key != SolarMaxCommandKey.UNKNOWN) {
273 responseMap.put(key, responseValue);
281 private static Socket getSocketConnection(final String host, int portNumber)
282 throws SolarMaxConnectionException, UnknownHostException {
283 portNumber = (portNumber == 0) ? DEFAULT_PORT : portNumber;
288 socket = new Socket();
289 socket.connect(new InetSocketAddress(host, portNumber), CONNECTION_TIMEOUT);
290 socket.setSoTimeout(responseTimeout);
291 } catch (final UnknownHostException e) {
293 } catch (final IOException e) {
294 throw new SolarMaxConnectionException(
295 "Error connecting to portNumber '" + portNumber + "' on host '" + host + "'", e);
301 public static boolean connectionTest(final String host, final int portNumber) throws UnknownHostException {
302 Socket socket = null;
305 socket = getSocketConnection(host, portNumber);
306 } catch (SolarMaxConnectionException e) {
309 if (socket != null) {
312 } catch (IOException e) {
313 // ignore any error while trying to close the socket
322 * @return timeout for responses in milliseconds
324 public static int getResponseTimeout() {
325 return responseTimeout;
329 * @param responseTimeout timeout for responses in milliseconds
331 public static void setResponseTimeout(int responseTimeout) {
332 SolarMaxConnector.responseTimeout = responseTimeout;
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
340 static String contructRequest(final int deviceAddress, final String questions) {
342 String dstHex = String.format("%02X", deviceAddress); // destinationDevice defaults to 1
345 String msg = "64:" + questions;
346 int lenInt = ("{" + src + ";" + dstHex + ";" + len + "|" + msg + "|" + cs + "}").length();
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);
352 String checksum = calculateChecksum16(src + ";" + dstHex + ";" + lenHex + "|" + msg + "|");
354 return "{" + src + ";" + dstHex + ";" + lenHex + "|" + msg + "|" + checksum + "}";
358 * calculates the "checksum16" of the given string argument
360 static String calculateChecksum16(String str) {
361 byte[] bytes = str.getBytes();
364 // loop through each of the bytes and add them together
365 for (byte aByte : bytes) {
369 // calculate the "checksum16"
370 sum = sum % (int) Math.pow(2, 16);
372 // return Integer.toHexString(sum);
373 return String.format("%04X", sum);
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})/",