[solarmax] Initial contribution (#10414)

* SolarMax Binding Initial implementation

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* #10413 camelCaserizeTheChannelNames

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* #10413 Delete commented code and Refactor Brute Force Command Discovery into something commitable

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* 10413 Delete commented code and Refactor Brute Force Command Discovery into something commitable

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* #10413 Codestyle

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* 10413 Codestyle

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* #10413 corrected sat-plugin errors

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* #10413 updates from code reviews in PR #10414

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* 10413 mvn spotless:apply

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* 10413 Updated to 3.2.0-SNAPSHOT

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Fixed conflicts introduced by foreign commit.

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Updated copyright years

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Ran  mvn spotless:apply to resolve formatting issues

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Updates from review

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Switch to using Units & move softwareVersion & buildNumber to properties

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* A couple of review related updates

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* A couple more review related changes.

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Added Full Example to README.md

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Update parent pom.xml version

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>

* Update bundles/org.openhab.binding.solarmax/src/main/java/org/openhab/binding/solarmax/internal/SolarMaxHandlerFactory.java

Signed-off-by: Fabian Wolter <github@fabian-wolter.de>

* Update bundles/org.openhab.binding.solarmax/src/main/java/org/openhab/binding/solarmax/internal/SolarMaxHandlerFactory.java

Signed-off-by: Fabian Wolter <github@fabian-wolter.de>

Signed-off-by: Jamie Townsend <jamie_townsend@hotmail.com>
Signed-off-by: Fabian Wolter <github@fabian-wolter.de>
Co-authored-by: Fabian Wolter <github@fabian-wolter.de>
This commit is contained in:
Jamie Townsend
2022-09-27 07:51:10 +02:00
committed by GitHub
parent 24d5f2ddc7
commit 015a370392
22 changed files with 1972 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.solarmax-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-solarmax" description="SolarMax Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.solarmax/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link SolarMaxBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarMaxBindingConstants {
private static final String BINDING_ID = "solarmax";
private static final String THING_TYPE_ID = "inverter";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_SOLARMAX = new ThingTypeUID(BINDING_ID, THING_TYPE_ID);
}

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.solarmax.internal.connector.SolarMaxCommandKey;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
/**
* The {@link SolarMaxChannel} Enum defines common constants, which are
* used across the whole binding.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public enum SolarMaxChannel {
CHANNEL_LAST_UPDATED("lastUpdated", null), //
CHANNEL_STARTUPS(SolarMaxCommandKey.startups.name(), null),
CHANNEL_AC_PHASE1_CURRENT(SolarMaxCommandKey.acPhase1Current.name(), Units.AMPERE),
CHANNEL_AC_PHASE2_CURRENT(SolarMaxCommandKey.acPhase2Current.name(), Units.AMPERE),
CHANNEL_AC_PHASE3_CURRENT(SolarMaxCommandKey.acPhase3Current.name(), Units.AMPERE),
CHANNEL_ENERGY_GENERATED_TODAY(SolarMaxCommandKey.energyGeneratedToday.name(), Units.WATT_HOUR),
CHANNEL_ENERGY_GENERATED_TOTAL(SolarMaxCommandKey.energyGeneratedTotal.name(), Units.WATT_HOUR),
CHANNEL_OPERATING_HOURS(SolarMaxCommandKey.operatingHours.name(), Units.HOUR),
CHANNEL_ENERGY_GENERATED_YESTERDAY(SolarMaxCommandKey.energyGeneratedYesterday.name(), Units.WATT_HOUR),
CHANNEL_ENERGY_GENERATED_LAST_MONTH(SolarMaxCommandKey.energyGeneratedLastMonth.name(), Units.WATT_HOUR),
CHANNEL_ENERGY_GENERATED_LAST_YEAR(SolarMaxCommandKey.energyGeneratedLastYear.name(), Units.WATT_HOUR),
CHANNEL_ENERGY_GENERATED_THIS_MONTH(SolarMaxCommandKey.energyGeneratedThisMonth.name(), Units.WATT_HOUR),
CHANNEL_ENERGY_GENERATED_THIS_YEAR(SolarMaxCommandKey.energyGeneratedThisYear.name(), Units.WATT_HOUR),
CHANNEL_CURRENT_POWER_GENERATED(SolarMaxCommandKey.currentPowerGenerated.name(), Units.WATT_HOUR),
CHANNEL_AC_FREQUENCY(SolarMaxCommandKey.acFrequency.name(), Units.HERTZ),
CHANNEL_AC_PHASE1_VOLTAGE(SolarMaxCommandKey.acPhase1Voltage.name(), Units.VOLT),
CHANNEL_AC_PHASE2_VOLTAGE(SolarMaxCommandKey.acPhase2Voltage.name(), Units.VOLT),
CHANNEL_AC_PHASE3_VOLTAGE(SolarMaxCommandKey.acPhase3Voltage.name(), Units.VOLT),
CHANNEL_HEAT_SINK_TEMPERATUR(SolarMaxCommandKey.heatSinkTemperature.name(), SIUnits.CELSIUS);
private final String channelId;
@Nullable
private Unit<?> unit;
private SolarMaxChannel(String channelId, @Nullable Unit<?> unit) {
this.channelId = channelId;
this.unit = unit;
}
public String getChannelId() {
return channelId;
}
@Nullable
public Unit<?> getUnit() {
return this.unit;
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link SolarMaxConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarMaxConfiguration {
public String host = ""; // this will always need to be overridden
public int portNumber = 12345; // default value is 12345
public int refreshInterval = 15; // default value is 15
}

View File

@@ -0,0 +1,182 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.solarmax.internal.connector.SolarMaxCommandKey;
import org.openhab.binding.solarmax.internal.connector.SolarMaxConnector;
import org.openhab.binding.solarmax.internal.connector.SolarMaxData;
import org.openhab.binding.solarmax.internal.connector.SolarMaxException;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SolarMaxHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarMaxHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(SolarMaxHandler.class);
private SolarMaxConfiguration config = getConfigAs(SolarMaxConfiguration.class);
@Nullable
private ScheduledFuture<?> pollingJob;
public SolarMaxHandler(final Thing thing) {
super(thing);
}
@Override
public void handleCommand(final ChannelUID channelUID, final Command command) {
// Read only
}
@Override
public void initialize() {
config = getConfigAs(SolarMaxConfiguration.class);
configurePolling(); // Setup the scheduler
}
/**
* This is called to start the refresh job and also to reset that refresh job when a config change is done.
*/
private void configurePolling() {
logger.debug("Polling data from {} at {}:{} every {} seconds ", getThing().getUID(), this.config.host,
this.config.portNumber, this.config.refreshInterval);
if (this.config.refreshInterval > 0) {
if (pollingJob == null || pollingJob.isCancelled()) {
pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, this.config.refreshInterval,
TimeUnit.SECONDS);
}
}
}
@Override
public void dispose() {
if (pollingJob != null && !pollingJob.isCancelled()) {
pollingJob.cancel(true);
}
pollingJob = null;
}
/**
* Polling event used to get data from the SolarMax device
*/
private Runnable pollingRunnable = () -> {
updateValuesFromDevice();
};
private synchronized void updateValuesFromDevice() {
logger.debug("Updating data from {} at {}:{} ", getThing().getUID(), this.config.host, this.config.portNumber);
// get the data from the SolarMax device
try {
SolarMaxData solarMaxData = SolarMaxConnector.getAllValuesFromSolarMax(config.host, config.portNumber);
if (solarMaxData.wasCommunicationSuccessful()) {
updateStatus(ThingStatus.ONLINE);
updateProperties(solarMaxData);
updateChannels(solarMaxData);
return;
}
} catch (SolarMaxException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication error with the device: " + e.getMessage());
}
}
/*
* Update the channels
*/
private void updateChannels(SolarMaxData solarMaxData) {
logger.debug("Updating all channels");
for (SolarMaxChannel solarMaxChannel : SolarMaxChannel.values()) {
String channelId = solarMaxChannel.getChannelId();
Channel channel = getThing().getChannel(channelId);
if (channelId.equals(SolarMaxChannel.CHANNEL_LAST_UPDATED.getChannelId())) {
// CHANNEL_LAST_UPDATED shows when the device was last read and does not come from the device, so handle
// it specially
State state = solarMaxData.getDataDateTime();
logger.debug("Update channel state: {} - {}", channelId, state);
updateState(channel.getUID(), state);
} else {
// must be somthing to collect from the device, so...
if (solarMaxData.has(SolarMaxCommandKey.valueOf(channelId))) {
if (channel == null) {
logger.error("No channel found with id: {}", channelId);
}
State state = convertValueToState(solarMaxData.get(SolarMaxCommandKey.valueOf(channelId)),
solarMaxChannel.getUnit());
if (channel != null && state != null) {
logger.debug("Update channel state: {} - {}", channelId, state);
updateState(channel.getUID(), state);
} else {
logger.debug("Error refreshing channel {}: {}", getThing().getUID(), channelId);
}
}
}
}
}
private @Nullable State convertValueToState(Number value, @Nullable Unit<?> unit) {
if (unit == null) {
return new DecimalType(value.floatValue());
}
return new QuantityType<>(value, unit);
}
/*
* Update the properties
*/
private void updateProperties(SolarMaxData solarMaxData) {
logger.debug("Updating properties");
for (SolarMaxProperty solarMaxProperty : SolarMaxProperty.values()) {
String propertyId = solarMaxProperty.getPropertyId();
Number valNumber = solarMaxData.get(SolarMaxCommandKey.valueOf(propertyId));
if (valNumber == null) {
logger.debug("Null value returned for value of {}: {}", getThing().getUID(), propertyId);
continue;
}
// deal with properties
if (propertyId.equals(SolarMaxProperty.PROPERTY_BUILD_NUMBER.getPropertyId())
|| propertyId.equals(SolarMaxProperty.PROPERTY_SOFTWARE_VERSION.getPropertyId())) {
updateProperty(solarMaxProperty.getPropertyId(), valNumber.toString());
continue;
}
}
}
}

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal;
import static org.openhab.binding.solarmax.internal.SolarMaxBindingConstants.THING_TYPE_SOLARMAX;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Component;
/**
* The {@link SolarMaxHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.solarmax", service = ThingHandlerFactory.class)
public class SolarMaxHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SOLARMAX);
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_SOLARMAX.equals(thingTypeUID)) {
return new SolarMaxHandler(thing);
}
return null;
}
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.solarmax.internal.connector.SolarMaxCommandKey;
/**
* The {@link SolarMaxProperty} Enum defines common constants, which are
* used across the whole binding.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public enum SolarMaxProperty {
PROPERTY_SOFTWARE_VERSION(SolarMaxCommandKey.softwareVersion.name()),
PROPERTY_BUILD_NUMBER(SolarMaxCommandKey.buildNumber.name());
private final String propertyId;
private SolarMaxProperty(String propertyId) {
this.propertyId = propertyId;
}
public String getPropertyId() {
return propertyId;
}
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal.connector;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link SolarMaxCommandKey} enum defines the commands that are understood by the SolarMax device
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public enum SolarMaxCommandKey {
// for further commands, that are not implemented here, see this binding's README.md file
// Valid commands which returned a non-null value during testing
buildNumber("BDN"), //
startups("CAC"), //
acPhase1Current("IL1"), //
acPhase2Current("IL2"), //
acPhase3Current("IL3"), //
energyGeneratedToday("KDY"), //
operatingHours("KHR"), //
energyGeneratedYesterday("KLD"), //
energyGeneratedLastMonth("KLM"), //
energyGeneratedLastYear("KLY"), //
energyGeneratedThisMonth("KMT"), //
energyGeneratedTotal("KT0"), //
energyGeneratedThisYear("KYR"), //
currentPowerGenerated("PAC"), //
softwareVersion("SWV"), //
heatSinkTemperature("TKK"), //
acFrequency("TNF"), //
acPhase1Voltage("UL1"), //
acPhase2Voltage("UL2"), //
acPhase3Voltage("UL3"), //
UNKNOWN("UNKNOWN") // really unknown - shouldn't ever be sent to the device
;
private String commandKey;
private SolarMaxCommandKey(String commandKey) {
this.commandKey = commandKey;
}
public String getCommandKey() {
return this.commandKey;
}
public static SolarMaxCommandKey getKeyFromString(String commandKey) {
for (SolarMaxCommandKey key : SolarMaxCommandKey.values()) {
if (key.commandKey.equals(commandKey)) {
return key;
}
}
return UNKNOWN;
}
}

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal.connector;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link SolarMaxException} Exception is used for connection problems trying to communicate with the SolarMax
* device.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarMaxConnectionException extends SolarMaxException {
private static final long serialVersionUID = 1L;
public SolarMaxConnectionException(final String message, final Throwable cause) {
super(message, cause);
}
public SolarMaxConnectionException(final Throwable cause) {
super(cause);
}
}

View File

@@ -0,0 +1,375 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal.connector;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* The {@link SolarMaxConnector} class is used to communicated with the SolarMax device (on a binary level)
*
* With a little help from https://github.com/sushiguru/solar-pv/blob/master/solmax/pv.php
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarMaxConnector {
/**
* default port number of SolarMax devices is...
*/
private static final int DEFAULT_PORT = 12345;
private static final Logger LOGGER = LoggerFactory.getLogger(SolarMaxConnector.class);
/**
* default timeout for socket connections is 1 second
*/
private static final int CONNECTION_TIMEOUT = 1000;
/**
* default timeout for socket responses is 10 seconds
*/
private static int responseTimeout = 10000;
/**
* gets all known values from the SolarMax device addressable at host:port
*
* @param host hostname or ip address of the SolarMax device to be contacted
* @param port port the SolarMax is listening on (default is 12345)
* @param commandList a list of commands to be sent to the SolarMax device
* @return
* @throws UnknownHostException if the host is unknown
* @throws SolarMaxException if some other exception occurs
*/
public static SolarMaxData getAllValuesFromSolarMax(final String host, int port) throws SolarMaxException {
List<SolarMaxCommandKey> commandList = new ArrayList<>();
for (SolarMaxCommandKey solarMaxCommandKey : SolarMaxCommandKey.values()) {
if (solarMaxCommandKey != SolarMaxCommandKey.UNKNOWN) {
commandList.add(solarMaxCommandKey);
}
}
SolarMaxData solarMaxData = new SolarMaxData();
// get the data from the SolarMax device. If we didn't get as many values back as we asked for, there were
// communications problems, so set communicationSuccessful appropriately
Map<SolarMaxCommandKey, @Nullable String> valuesFromSolarMax = getValuesFromSolarMax(host, port, commandList);
boolean allCommandsAnswered = true;
for (SolarMaxCommandKey solarMaxCommandKey : commandList) {
if (!valuesFromSolarMax.containsKey(solarMaxCommandKey)) {
allCommandsAnswered = false;
break;
}
}
solarMaxData.setDataDateTime(ZonedDateTime.now());
solarMaxData.setCommunicationSuccessful(allCommandsAnswered);
solarMaxData.setData(valuesFromSolarMax);
return solarMaxData;
}
/**
* gets values from the SolarMax device addressable at host:port
*
* @param host hostname or ip address of the SolarMax device to be contacted
* @param port port the SolarMax is listening on (default is 12345)
* @param commandList a list of commands to be sent to the SolarMax device
* @return
* @throws UnknownHostException if the host is unknown
* @throws SolarMaxException if some other exception occurs
*/
private static Map<SolarMaxCommandKey, @Nullable String> getValuesFromSolarMax(final String host, int port,
final List<SolarMaxCommandKey> commandList) throws SolarMaxException {
Socket socket;
Map<SolarMaxCommandKey, @Nullable String> returnMap = new HashMap<>();
// SolarMax can't answer correclty if too many commands are send in a single request, so limit it to 16 at a
// time
int maxConcurrentCommands = 16;
int requestsRequired = (commandList.size() / maxConcurrentCommands);
if (commandList.size() % maxConcurrentCommands != 0) {
requestsRequired = requestsRequired + 1;
}
for (int requestNumber = 0; requestNumber < requestsRequired; requestNumber++) {
LOGGER.debug(" Requesting data from {}:{} with timeout of {}ms", host, port, responseTimeout);
int firstCommandNumber = requestNumber * maxConcurrentCommands;
int lastCommandNumber = (requestNumber + 1) * maxConcurrentCommands;
if (lastCommandNumber > commandList.size()) {
lastCommandNumber = commandList.size();
}
List<SolarMaxCommandKey> commandsToSend = commandList.subList(firstCommandNumber, lastCommandNumber);
try {
socket = getSocketConnection(host, port);
} catch (UnknownHostException e) {
throw new SolarMaxConnectionException(e);
}
returnMap.putAll(getValuesFromSolarMax(socket, commandsToSend));
// SolarMax can't deal with requests too close to one another, so just wait a moment
try {
Thread.sleep(10);
} catch (InterruptedException e) {
// do nothing
}
}
return returnMap;
}
static String getCommandString(List<SolarMaxCommandKey> commandList) {
String commandString = "";
for (SolarMaxCommandKey command : commandList) {
if (!commandString.isEmpty()) {
commandString = commandString + ";";
}
commandString = commandString + command.getCommandKey();
}
return commandString;
}
private static Map<SolarMaxCommandKey, @Nullable String> getValuesFromSolarMax(final Socket socket,
final List<SolarMaxCommandKey> commandList) throws SolarMaxException {
OutputStream outputStream = null;
InputStream inputStream = null;
try {
outputStream = socket.getOutputStream();
inputStream = socket.getInputStream();
return getValuesFromSolarMax(outputStream, inputStream, commandList);
} catch (final SolarMaxException | IOException e) {
throw new SolarMaxException("Error getting input/output streams from socket", e);
} finally {
try {
socket.close();
if (outputStream != null) {
outputStream.close();
}
if (inputStream != null) {
inputStream.close();
}
} catch (final IOException e) {
// ignore the error, we're dying anyway...
}
}
}
private static Map<SolarMaxCommandKey, @Nullable String> getValuesFromSolarMax(final OutputStream outputStream,
final InputStream inputStream, final List<SolarMaxCommandKey> commandList) throws SolarMaxException {
Map<SolarMaxCommandKey, @Nullable String> returnedValues;
String commandString = getCommandString(commandList);
String request = contructRequest(commandString);
try {
LOGGER.trace(" ==>: {}", request);
outputStream.write(request.getBytes());
String response = "";
byte[] responseByte = new byte[1];
// get everything from the stream
while (true) {
// read one byte from the stream
int bytesRead = inputStream.read(responseByte);
// if there was nothing left, break
if (bytesRead < 1) {
break;
}
// add the received byte to the response
final String responseString = new String(responseByte);
response = response + responseString;
// if it was the final expected character "}", break
if ("}".equals(responseString)) {
break;
}
}
LOGGER.trace(" <==: {}", response);
if (!validateResponse(response)) {
throw new SolarMaxException("Invalid response received: " + response);
}
returnedValues = extractValuesFromResponse(response);
return returnedValues;
} catch (IOException e) {
LOGGER.debug("Error communicating via input/output streams: {} ", e.getMessage());
throw new SolarMaxException(e);
}
}
/**
* @param response e.g.
* "{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}"
* @return a map of keys and values
*/
static Map<SolarMaxCommandKey, @Nullable String> extractValuesFromResponse(String response) {
final Map<SolarMaxCommandKey, @Nullable String> responseMap = new HashMap<>();
// in case there is no response
if (response.indexOf("|") == -1) {
LOGGER.warn("Response doesn't contain data. Response: {}", response);
return responseMap;
}
// extract the body first
// start by getting the part of the response between the two pipes
String body = response.substring(response.indexOf("|") + 1, response.lastIndexOf("|"));
// the name/value pairs now lie after the ":"
body = body.substring(body.indexOf(":") + 1);
// split into an array of name=value pairs
String[] entries = body.split(";");
for (String entry : entries) {
if (entry.length() != 0) {
// could be split on "=" instead of fixed length or made to respect length of command, but they're all 3
// characters long (then plus "=")
String str = entry.substring(0, 3);
String responseValue = (entry.length() >= 5) ? entry.substring(4) : null;
SolarMaxCommandKey key = SolarMaxCommandKey.getKeyFromString(str);
if (key != SolarMaxCommandKey.UNKNOWN) {
responseMap.put(key, responseValue);
}
}
}
return responseMap;
}
private static Socket getSocketConnection(final String host, int port)
throws SolarMaxConnectionException, UnknownHostException {
port = (port == 0) ? DEFAULT_PORT : port;
Socket socket;
try {
socket = new Socket();
socket.connect(new InetSocketAddress(host, port), CONNECTION_TIMEOUT);
socket.setSoTimeout(responseTimeout);
} catch (final UnknownHostException e) {
throw e;
} catch (final IOException e) {
throw new SolarMaxConnectionException("Error connecting to port '" + port + "' on host '" + host + "'", e);
}
return socket;
}
public static boolean connectionTest(final String host, int port) throws UnknownHostException {
Socket socket = null;
try {
socket = getSocketConnection(host, port);
} catch (SolarMaxConnectionException e) {
return false;
} finally {
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
// ignore any error while trying to close the socket
}
}
}
return true;
}
/**
* @return timeout for responses in milliseconds
*/
public static int getResponseTimeout() {
return responseTimeout;
}
/**
* @param responseTimeout timeout for responses in milliseconds
*/
public static void setResponseTimeout(int responseTimeout) {
SolarMaxConnector.responseTimeout = responseTimeout;
}
/**
* @param destinationDevice device number - used if devices are daisy-chained. Normally it will be "1"
* @param questions appears to be able to handle multiple commands. For now, one at a time is good fishing
* @return the request to be sent to the SolarMax device
*/
static String contructRequest(final String questions) {
String src = "FB";
String dstHex = String.format("%02X", 1); // destinationDevice defaults to 1 and is ignored with TCP/IP
String len = "00";
String cs = "0000";
String msg = "64:" + questions;
int lenInt = ("{" + src + ";" + dstHex + ";" + len + "|" + msg + "|" + cs + "}").length();
// given the following, I'd expect problems if the request is longer than 255 characters. Since I'm not sure
// though, I won't fixe what isn't (yet) broken
String lenHex = String.format("%02X", lenInt);
String checksum = calculateChecksum16(src + ";" + dstHex + ";" + lenHex + "|" + msg + "|");
return "{" + src + ";" + dstHex + ";" + lenHex + "|" + msg + "|" + checksum + "}";
}
/**
* calculates the "checksum16" of the given string argument
*/
static String calculateChecksum16(String str) {
byte[] bytes = str.getBytes();
int sum = 0;
// loop through each of the bytes and add them together
for (byte aByte : bytes) {
sum = sum + aByte;
}
// calculate the "checksum16"
sum = sum % (int) Math.pow(2, 16);
// return Integer.toHexString(sum);
return String.format("%04X", sum);
}
static boolean validateResponse(final String header) {
// probably should implement a patter matcher with a patternString like "/\\{([0-9A-F]{2});FB;([0-9A-F]{2})/",
// but for now...
return true;
}
}

View File

@@ -0,0 +1,251 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal.connector;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.types.State;
/**
* The {@link SolarMaxData} class is a POJO for storing the values returned from the SolarMax device and accessing the
* (decoded) values
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.PARAMETER, DefaultLocation.FIELD, DefaultLocation.TYPE_BOUND,
DefaultLocation.TYPE_ARGUMENT })
public class SolarMaxData {
private ZonedDateTime dataDateTime = ZonedDateTime.now();
private boolean communicationSuccessful;
private final Map<SolarMaxCommandKey, @Nullable String> data = new HashMap<>();
public State getDataDateTime() {
return new DateTimeType(dataDateTime);
}
public boolean has(SolarMaxCommandKey key) {
return data.containsKey(key);
}
public Number get(SolarMaxCommandKey key) {
switch (key) {
case softwareVersion:
return getSoftwareVersion();
case buildNumber:
return getBuildNumber();
case startups:
return getStartups();
case acPhase1Current:
return getAcPhase1Current();
case acPhase2Current:
return getAcPhase2Current();
case acPhase3Current:
return getAcPhase3Current();
case energyGeneratedToday:
return getEnergyGeneratedToday();
case energyGeneratedTotal:
return getEnergyGeneratedTotal();
case operatingHours:
return getOperatingHours();
case energyGeneratedYesterday:
return getEnergyGeneratedYesterday();
case energyGeneratedLastMonth:
return getEnergyGeneratedLastMonth();
case energyGeneratedLastYear:
return getEnergyGeneratedLastYear();
case energyGeneratedThisMonth:
return getEnergyGeneratedThisMonth();
case energyGeneratedThisYear:
return getEnergyGeneratedThisYear();
case currentPowerGenerated:
return getCurrentPowerGenerated();
case acFrequency:
return getAcFrequency();
case acPhase1Voltage:
return getAcPhase1Voltage();
case acPhase2Voltage:
return getAcPhase2Voltage();
case acPhase3Voltage:
return getAcPhase3Voltage();
case heatSinkTemperature:
return getHeatSinkTemperature();
default:
return null;
}
}
public void setDataDateTime(ZonedDateTime dataDateTime) {
this.dataDateTime = dataDateTime;
}
public boolean wasCommunicationSuccessful() {
return this.communicationSuccessful;
}
public void setCommunicationSuccessful(boolean communicationSuccessful) {
this.communicationSuccessful = communicationSuccessful;
}
public Number getSoftwareVersion() {
return getIntegerValueFrom(SolarMaxCommandKey.softwareVersion);
}
public Number getBuildNumber() {
return getIntegerValueFrom(SolarMaxCommandKey.buildNumber);
}
public Number getStartups() {
return getIntegerValueFrom(SolarMaxCommandKey.startups);
}
public Number getAcPhase1Current() {
return getDecimalValueFrom(SolarMaxCommandKey.acPhase1Current, 0.01);
}
public Number getAcPhase2Current() {
return getDecimalValueFrom(SolarMaxCommandKey.acPhase2Current, 0.01);
}
public Number getAcPhase3Current() {
return getDecimalValueFrom(SolarMaxCommandKey.acPhase3Current, 0.01);
}
public Number getEnergyGeneratedToday() {
return getIntegerValueFrom(SolarMaxCommandKey.energyGeneratedToday, 100);
}
public Number getEnergyGeneratedTotal() {
return getIntegerValueFrom(SolarMaxCommandKey.energyGeneratedTotal, 1000);
}
public Number getOperatingHours() {
return getIntegerValueFrom(SolarMaxCommandKey.operatingHours);
}
public Number getEnergyGeneratedYesterday() {
return getIntegerValueFrom(SolarMaxCommandKey.energyGeneratedYesterday, 100);
}
public Number getEnergyGeneratedLastMonth() {
return getIntegerValueFrom(SolarMaxCommandKey.energyGeneratedLastMonth, 1000);
}
public Number getEnergyGeneratedLastYear() {
return getIntegerValueFrom(SolarMaxCommandKey.energyGeneratedLastYear, 1000);
}
public Number getEnergyGeneratedThisMonth() {
return getIntegerValueFrom(SolarMaxCommandKey.energyGeneratedThisMonth, 1000);
}
public Number getEnergyGeneratedThisYear() {
return getIntegerValueFrom(SolarMaxCommandKey.energyGeneratedThisYear, 1000);
}
public Number getCurrentPowerGenerated() {
return getIntegerValueFrom(SolarMaxCommandKey.currentPowerGenerated, 0.5);
}
Number getAcFrequency() {
return getDecimalValueFrom(SolarMaxCommandKey.acFrequency, 0.01);
}
public Number getAcPhase1Voltage() {
return getDecimalValueFrom(SolarMaxCommandKey.acPhase1Voltage, 0.1);
}
public Number getAcPhase2Voltage() {
return getDecimalValueFrom(SolarMaxCommandKey.acPhase2Voltage, 0.1);
}
public Number getAcPhase3Voltage() {
return getDecimalValueFrom(SolarMaxCommandKey.acPhase3Voltage, 0.1);
}
public Number getHeatSinkTemperature() {
return getIntegerValueFrom(SolarMaxCommandKey.heatSinkTemperature);
}
@Nullable
private Number getDecimalValueFrom(SolarMaxCommandKey solarMaxCommandKey, double multiplyByFactor) {
if (this.data.containsKey(solarMaxCommandKey)) {
String valueString = this.data.get(solarMaxCommandKey);
if (valueString != null) {
int valueInt = Integer.parseInt(valueString, 16);
return (float) valueInt * multiplyByFactor;
}
return null;
}
return null;
}
@Nullable
private Number getIntegerValueFrom(SolarMaxCommandKey solarMaxCommandKey, double multiplyByFactor) {
if (this.data.containsKey(solarMaxCommandKey)) {
String valueString = this.data.get(solarMaxCommandKey);
if (valueString != null) {
int valueInt = Integer.parseInt(valueString, 16);
return (int) (valueInt * multiplyByFactor);
}
return null;
}
return null;
}
@Nullable
private Number getIntegerValueFrom(SolarMaxCommandKey solarMaxCommandKey) {
if (this.data.containsKey(solarMaxCommandKey)) {
String valueString = this.data.get(solarMaxCommandKey);
if (valueString != null) {
return Integer.parseInt(valueString, 16);
}
return null;
}
return null;
}
protected void setData(Map<SolarMaxCommandKey, @Nullable String> data) {
this.data.putAll(data);
}
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal.connector;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link SolarMaxException} Exception is used for general exceptions related to communications with the SolarMax
* device.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarMaxException extends Exception {
private static final long serialVersionUID = 1L;
public SolarMaxException(final String message, final Throwable cause) {
super(message, cause);
}
public SolarMaxException(final Throwable cause) {
super(cause);
}
public SolarMaxException(final String message) {
super(message);
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="solarmax" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>SolarMax Binding</name>
<description>This is the binding for SolarMax power inverters, particularly the MT Series</description>
</binding:binding>

View File

@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="solarmax"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- SolarMaxBindingConstants.THING_TYPE_ID -->
<thing-type id="inverter">
<label>SolarMax Power Inverter</label>
<description>Basic thing for the SolarMax Power Inverter binding</description>
<channels>
<channel id="lastUpdated" typeId="lastUpdated"/>
<channel id="startups" typeId="startups"/>
<channel id="acPhase1Current" typeId="acPhase1Current"/>
<channel id="acPhase2Current" typeId="acPhase2Current"/>
<channel id="acPhase3Current" typeId="acPhase3Current"/>
<channel id="energyGeneratedToday" typeId="energyGeneratedToday"/>
<channel id="energyGeneratedTotal" typeId="energyGeneratedTotal"/>
<channel id="operatingHours" typeId="operatingHours"/>
<channel id="energyGeneratedYesterday" typeId="energyGeneratedYesterday"/>
<channel id="energyGeneratedLastMonth" typeId="energyGeneratedLastMonth"/>
<channel id="energyGeneratedLastYear" typeId="energyGeneratedLastYear"/>
<channel id="energyGeneratedThisMonth" typeId="energyGeneratedThisMonth"/>
<channel id="energyGeneratedThisYear" typeId="energyGeneratedThisYear"/>
<channel id="currentPowerGenerated" typeId="currentPowerGenerated"/>
<channel id="acFrequency" typeId="acFrequency"/>
<channel id="acPhase1Voltage" typeId="acPhase1Voltage"/>
<channel id="acPhase2Voltage" typeId="acPhase2Voltage"/>
<channel id="acPhase3Voltage" typeId="acPhase3Voltage"/>
<channel id="heatSinkTemperature" typeId="heatSinkTemperature"/>
</channels>
<config-description>
<parameter name="host" type="text" required="true">
<label>Host</label>
<description>Hostname or IP Address</description>
</parameter>
<parameter name="portNumber" type="integer" required="false">
<label>Port</label>
<description>Port Number (defaults to 12345)</description>
<default>12345</default>
</parameter>
<parameter name="refreshInterval" type="integer" required="false">
<label>Refresh Interval</label>
<description>Refresh Interval in seconds (defaults to 15)</description>
<default>15</default>
</parameter>
</config-description>
</thing-type>
<channel-type id="lastUpdated">
<item-type>DateTime</item-type>
<label>Last Updated</label>
<description>Time when data was last read from the device</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="startups">
<item-type>Number</item-type>
<label>Startups</label>
<description>Number of times the device has started</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="acPhase1Current">
<item-type>Number:ElectricCurrent</item-type>
<label>AC Phase 1 Current</label>
<description>AC Phase 1 Current in Amps</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="acPhase2Current">
<item-type>Number:ElectricCurrent</item-type>
<label>AC Phase 2 Current</label>
<description>AC Phase 2 Current in Amps</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="acPhase3Current">
<item-type>Number:ElectricCurrent</item-type>
<label>AC Phase 3 Current</label>
<description>AC Phase 3 Current in Amps</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="energyGeneratedToday">
<item-type>Number:Energy</item-type>
<label>Energy Generated Today</label>
<description>Energy Generated Today in wH</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="energyGeneratedTotal">
<item-type>Number:Energy</item-type>
<label>Energy Generated Total</label>
<description>Energy Generated Total since recording began in wH</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="operatingHours">
<item-type>Number</item-type>
<label>Operating Hours</label>
<description>Operating Hours since recording began in H</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="energyGeneratedYesterday">
<item-type>Number:Energy</item-type>
<label>Energy Generated Yesterday</label>
<description>Energy Generated Yesterday in wH</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="energyGeneratedLastMonth">
<item-type>Number:Energy</item-type>
<label>Energy Generated Last Month</label>
<description>Energy Generated Last Month in wH</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="energyGeneratedLastYear">
<item-type>Number:Energy</item-type>
<label>Energy Generated Last Year</label>
<description>Energy Generated Last Year in wH</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="energyGeneratedThisMonth">
<item-type>Number:Energy</item-type>
<label>Energy Generated This Month</label>
<description>Energy Generated This Month in wH</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="energyGeneratedThisYear">
<item-type>Number:Energy</item-type>
<label>Energy Generated This Year</label>
<description>Energy Generated This Year in wH</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="currentPowerGenerated">
<item-type>Number:Power</item-type>
<label>Current Power Generated</label>
<description>Power currently being generated in w</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="acFrequency">
<item-type>Number:Frequency</item-type>
<label>AC Frequency</label>
<description>AcFrequency in Hz</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="acPhase1Voltage">
<item-type>Number:ElectricPotential</item-type>
<label>AC Phase1 Voltage</label>
<description>AC Phase1 Voltage in V</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="acPhase2Voltage">
<item-type>Number:ElectricPotential</item-type>
<label>AC Phase2 Voltage</label>
<description>AC Phase2 Voltage in V</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="acPhase3Voltage">
<item-type>Number:ElectricPotential</item-type>
<label>AC Phase3 Voltage</label>
<description>AC Phase3 Voltage in V</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="heatSinkTemperature">
<item-type>Number:Temperature</item-type>
<label>Heat Sink Temperature</label>
<description>Heat Sink Temperature in degrees celcius</description>
<state readOnly="true"></state>
</channel-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,74 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal.connector;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.Test;
import org.openhab.core.library.types.DateTimeType;
/**
* The {@link SolarMaxDataTest} class is used to test the {@link SolaMaxData} class.
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarMaxDataTest {
@Test
public void dataDateTimeGetterSetterTest() throws Exception {
// dataDateTime shouldn't be a problem, but check it anyway
ZonedDateTime dateTimeOriginal = ZonedDateTime.now();
ZonedDateTime dateTimeUpdated = dateTimeOriginal.plusDays(2);
SolarMaxData solarMaxData = new SolarMaxData();
solarMaxData.setDataDateTime(dateTimeOriginal);
assertEquals(new DateTimeType(dateTimeOriginal), solarMaxData.getDataDateTime());
solarMaxData.setDataDateTime(dateTimeUpdated);
assertEquals(new DateTimeType(dateTimeUpdated), solarMaxData.getDataDateTime());
}
@Test
public void valueGetterSetterTest() throws Exception {
String startupsOriginal = "3B8B"; // 15243 in hex
String startupsUpdated = "3B8C"; // 15244 in hex
SolarMaxData solarMaxData = new SolarMaxData();
Map<SolarMaxCommandKey, @Nullable String> dataOrig = new HashMap<>();
dataOrig.put(SolarMaxCommandKey.startups, startupsOriginal);
solarMaxData.setData(dataOrig);
@Nullable
Number origVersion = solarMaxData.get(SolarMaxCommandKey.startups);
assertNotNull(origVersion);
assertEquals(Integer.parseInt(startupsOriginal, 16), origVersion.intValue());
Map<SolarMaxCommandKey, @Nullable String> dataUpdated = new HashMap<>();
dataUpdated.put(SolarMaxCommandKey.startups, startupsUpdated);
solarMaxData.setData(dataUpdated);
Number updatedVersion = solarMaxData.get(SolarMaxCommandKey.startups);
assertNotEquals(origVersion, updatedVersion);
}
}

View File

@@ -0,0 +1,352 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.solarmax.internal.connector;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SolarmaxConnectorFindCommands} class wass used to brute-force detect different replies from the SolarMax
* device
*
* @author Jamie Townsend - Initial contribution
*/
@NonNullByDefault
public class SolarmaxConnectorFindCommands {
private static final Logger LOGGER = LoggerFactory.getLogger(SolarMaxConnector.class);
private static final String HOST = "192.168.1.151";
private static final int PORT = 12345;
private static final int CONNECTION_TIMEOUT = 1000; // ms
@Test
public void testForCommands() throws UnknownHostException, SolarMaxException {
List<String> validCommands = new ArrayList<>();
List<String> commandsToCheck = new ArrayList<String>();
List<String> failedCommands = new ArrayList<>();
int failedCommandRetry = 0;
String lastFailedCommand = "";
for (String first : getCharacters()) {
for (String second : getCharacters()) {
for (String third : getCharacters()) {
commandsToCheck.add(first + second + third);
// specifically searching for "E" errors with 4 characters (I know now that they don't exist ;-)
// commandsToCheck.add("E" + first + second + third);
}
commandsToCheck.add("E" + first + second);
}
}
// if you only want to try specific commands, perhaps because they failed in the big run, comment out the above
// and use this instead
// commandsToCheck.addAll(Arrays.asList("RH1", "RH2", "RH3", "TP1", "TP2", "TP3", "UL1", "UL2", "UL3", "UMX",
// "UM1", "UM2", "UM3", "UPD", "TCP"));
while (!commandsToCheck.isEmpty()) {
if (commandsToCheck.size() % 100 == 0) {
LOGGER.debug(commandsToCheck.size() + " left to check");
}
try {
if (checkIsValidCommand(commandsToCheck.get(0))) {
validCommands.add(commandsToCheck.get(0));
commandsToCheck.remove(0);
} else {
commandsToCheck.remove(0);
}
} catch (Exception e) {
LOGGER.debug("Sleeping after Exception: " + e.getLocalizedMessage());
if (lastFailedCommand.equals(commandsToCheck.get(0))) {
failedCommandRetry = failedCommandRetry + 1;
if (failedCommandRetry >= 5) {
failedCommands.add(commandsToCheck.get(0));
commandsToCheck.remove(0);
}
} else {
failedCommandRetry = 0;
lastFailedCommand = commandsToCheck.get(0);
}
try {
// Backoff somewhat nicely
Thread.sleep(2 * failedCommandRetry * failedCommandRetry * failedCommandRetry);
} catch (InterruptedException e1) {
// do nothing
}
}
try {
Thread.sleep(10);
} catch (InterruptedException e1) {
// do nothing
}
}
LOGGER.info("\nValid commands:");
for (String validCommand : validCommands) {
LOGGER.info(validCommand);
}
LOGGER.info("\nFailed commands:");
for (String failedCommand : failedCommands) {
LOGGER.info(failedCommand + "\", \"");
}
}
private boolean checkIsValidCommand(String command)
throws InterruptedException, UnknownHostException, SolarMaxException {
List<String> commands = new ArrayList<String>();
commands.add(command);
Map<String, @Nullable String> responseMap = null;
responseMap = getValuesFromSolarMax(HOST, PORT, commands);
if (responseMap.containsKey(command)) {
LOGGER.debug("Request: " + command + " Valid Response: " + responseMap.get(command));
return true;
}
return false;
}
/**
* based on SolarMaxConnector.getValuesFromSolarMax
*/
private static Map<String, @Nullable String> getValuesFromSolarMax(final String host, int port,
final List<String> commandList) throws SolarMaxException {
Socket socket;
Map<String, @Nullable String> returnMap = new HashMap<>();
// SolarMax can't answer correclty if too many commands are send in a single request, so limit it to 16 at a
// time
int maxConcurrentCommands = 16;
int requestsRequired = (commandList.size() / maxConcurrentCommands);
if (commandList.size() % maxConcurrentCommands != 0) {
requestsRequired = requestsRequired + 1;
}
for (int requestNumber = 0; requestNumber < requestsRequired; requestNumber++) {
LOGGER.debug(" Requesting data from {}:{} with timeout of {}ms", host, port, CONNECTION_TIMEOUT);
int firstCommandNumber = requestNumber * maxConcurrentCommands;
int lastCommandNumber = (requestNumber + 1) * maxConcurrentCommands;
if (lastCommandNumber > commandList.size()) {
lastCommandNumber = commandList.size();
}
List<String> commandsToSend = commandList.subList(firstCommandNumber, lastCommandNumber);
try {
socket = getSocketConnection(host, port);
} catch (UnknownHostException e) {
throw new SolarMaxConnectionException(e);
}
returnMap.putAll(getValuesFromSolarMax(socket, commandsToSend));
// SolarMax can't deal with requests too close to one another, so just wait a moment
try {
Thread.sleep(10);
} catch (InterruptedException e) {
// do nothing
}
}
return returnMap;
}
private static Map<String, @Nullable String> getValuesFromSolarMax(final Socket socket,
final List<String> commandList) throws SolarMaxException {
OutputStream outputStream = null;
InputStream inputStream = null;
try {
outputStream = socket.getOutputStream();
inputStream = socket.getInputStream();
return getValuesFromSolarMax(outputStream, inputStream, commandList);
} catch (final SolarMaxException | IOException e) {
throw new SolarMaxException("Error getting input/output streams from socket", e);
} finally {
try {
socket.close();
if (outputStream != null) {
outputStream.close();
}
if (inputStream != null) {
inputStream.close();
}
} catch (final IOException e) {
// ignore the error, we're dying anyway...
}
}
}
private List<String> getCharacters() {
List<String> characters = new ArrayList<>();
for (char c = 'a'; c <= 'z'; c++) {
characters.add(Character.toString(c));
}
for (char c = 'A'; c <= 'Z'; c++) {
characters.add(Character.toString(c));
}
characters.add("0");
characters.add("1");
characters.add("2");
characters.add("3");
characters.add("4");
characters.add("5");
characters.add("6");
characters.add("7");
characters.add("8");
characters.add("9");
characters.add(".");
characters.add("-");
characters.add("_");
return characters;
}
private static Socket getSocketConnection(final String host, int port)
throws SolarMaxConnectionException, UnknownHostException {
port = (port == 0) ? SolarmaxConnectorFindCommands.PORT : port;
Socket socket;
try {
socket = new Socket();
LOGGER.debug(" Connecting to " + host + ":" + port + " with a timeout of " + CONNECTION_TIMEOUT);
socket.connect(new InetSocketAddress(host, port), CONNECTION_TIMEOUT);
LOGGER.debug(" Connected.");
socket.setSoTimeout(CONNECTION_TIMEOUT);
} catch (final UnknownHostException e) {
throw e;
} catch (final IOException e) {
throw new SolarMaxConnectionException("Error connecting to port '" + port + "' on host '" + host + "'", e);
}
return socket;
}
private static Map<String, @Nullable String> getValuesFromSolarMax(final OutputStream outputStream,
final InputStream inputStream, final List<String> commandList) throws SolarMaxException {
Map<String, @Nullable String> returnedValues;
String commandString = getCommandString(commandList);
String request = SolarMaxConnector.contructRequest(commandString);
try {
LOGGER.trace(" ==>: {}", request);
outputStream.write(request.getBytes());
String response = "";
byte[] responseByte = new byte[1];
// get everything from the stream
while (true) {
// read one byte from the stream
int bytesRead = inputStream.read(responseByte);
// if there was nothing left, break
if (bytesRead < 1) {
break;
}
// add the received byte to the response
final String responseString = new String(responseByte);
response = response + responseString;
// if it was the final expected character "}", break
if ("}".equals(responseString)) {
break;
}
}
LOGGER.trace(" <==: {}", response);
// if (!validateResponse(response)) {
// throw new SolarMaxException("Invalid response received: " + response);
// }
returnedValues = extractValuesFromResponse(response);
return returnedValues;
} catch (IOException e) {
LOGGER.debug("Error communicating via input/output streams: {} ", e.getMessage());
throw new SolarMaxException(e);
}
}
static String getCommandString(List<String> commandList) {
String commandString = "";
for (String command : commandList) {
if (!commandString.isEmpty()) {
commandString = commandString + ";";
}
commandString = commandString + command;
}
return commandString;
}
/**
* @param response e.g.
* "{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}"
* @return a map of keys and values
*/
static Map<String, @Nullable String> extractValuesFromResponse(String response) {
final Map<String, @Nullable String> responseMap = new HashMap<>();
// in case there is no response
if (response.indexOf("|") == -1) {
LOGGER.warn("Response doesn't contain data. Response: {}", response);
return responseMap;
}
// extract the body first
// start by getting the part of the response between the two pipes
String body = response.substring(response.indexOf("|") + 1, response.lastIndexOf("|"));
// the name/value pairs now lie after the ":"
body = body.substring(body.indexOf(":") + 1);
// split into an array of name=value pairs
String[] entries = body.split(";");
for (String entry : entries) {
if (entry.length() != 0) {
// could be split on "=" instead of fixed length or made to respect length of command, but they're all 3
// characters long (then plus "=")
String responseKey = entry.substring(0, 3);
String responseValue = (entry.length() >= 5) ? entry.substring(4) : null;
responseMap.put(responseKey, responseValue);
}
}
return responseMap;
}
}