[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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1972 additions and 0 deletions

View File

@ -302,6 +302,7 @@
/bundles/org.openhab.binding.snmp/ @openhab/add-ons-maintainers /bundles/org.openhab.binding.snmp/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.solaredge/ @alexf2015 /bundles/org.openhab.binding.solaredge/ @alexf2015
/bundles/org.openhab.binding.solarlog/ @johannrichard /bundles/org.openhab.binding.solarlog/ @johannrichard
/bundles/org.openhab.binding.solarmax/ @jamietownsend
/bundles/org.openhab.binding.solarwatt/ @sven-carstens /bundles/org.openhab.binding.solarwatt/ @sven-carstens
/bundles/org.openhab.binding.somfymylink/ @loungeflyz /bundles/org.openhab.binding.somfymylink/ @loungeflyz
/bundles/org.openhab.binding.somfytahoma/ @octa22 /bundles/org.openhab.binding.somfytahoma/ @octa22

View File

@ -1516,6 +1516,11 @@
<artifactId>org.openhab.binding.solarlog</artifactId> <artifactId>org.openhab.binding.solarlog</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarmax</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.openhab.addons.bundles</groupId> <groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarwatt</artifactId> <artifactId>org.openhab.binding.solarwatt</artifactId>

View File

@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons

View File

@ -0,0 +1,132 @@
# SolarMax Binding
This binding supports SolarMax PV inverters.
## Supported Things
This binding only has a single `inverter` thing that can be added manually.
The SolarMax MT Series is supported (tested with 8MT2 devices).
## Discovery
Auto-discovery is currently not available.
## Thing Configuration
Each inverter requires the following configuration parameters:
| parameter | required | default | description |
| --------------- | -------- | ------- | -------------------------------------------------------------------- |
| host | yes | | hostname or IP address of the inverter |
| port | no | 12345 | Port number to connect to. This should be `12345` for most inverters |
| refreshInterval | no | 15 | Interval (in seconds) to refresh the channel values. |
## Properties
| property | description |
| --------------- | ------------------------------------------------------ |
| softwareVersion | Software Version installed on the SolarMax device |
| buildNumber | Firmware Build Number installed on the SolarMax device |
## Channels
| channel | type | description |
| ------------------------ | ------------------------ | -------------------------------------------- |
| lastUpdated | DateTime | Time when data was last read from the device |
| startups | Number | Number of times the device has started |
| acPhase1Current | Number:ElectricCurrent | Ac Phase 1 Current in Amps |
| acPhase2Current | Number:ElectricCurrent | Ac Phase 2 Current in Amps |
| acPhase3Current | Number:ElectricCurrent | Ac Phase 3 Current in Amps |
| energyGeneratedToday | Number:Energy | Energy Generated Today in Wh |
| energyGeneratedTotal | Number:Energy | Energy Generated since recording began in Wh |
| operatingHours | Number | Operating Hours since recording began in h |
| energyGeneratedYesterday | Number:Energy | Energy Generated Yesterday in Wh |
| energyGeneratedLastMonth | Number:Energy | Energy Generated Last Month in Wh |
| energyGeneratedLastYear | Number:Energy | Energy Generated Last Year in Wh |
| energyGeneratedThisMonth | Number:Energy | Energy Generated This Month in Wh |
| energyGeneratedThisYear | Number:Energy | Energy Generated This Year in Wh |
| currentPowerGenerated | Number:Power | Power currently being generated in W |
| acFrequency | Number:Frequency | AcFrequency in Hz |
| acPhase1Voltage | Number:ElectricPotential | Ac Phase1 Voltage in V |
| acPhase2Voltage | Number:ElectricPotential | Ac Phase2 Voltage in V |
| acPhase3Voltage | Number:ElectricPotential | Ac Phase3 Voltage in V |
| heatSinkTemperature | Number:Temperature | Heat Sink Temperature in degrees celcius |
### Full Example
Below you can find some example textual configuration for a solarmax with some basic functionallity. This can be extended/adjusted according to your needs and depending on the required channels (see list above).
_inverter.things:_
```
Thing solarmax:inverter:solarmax "SolarMax Inverter" [
host="192.168.1.151",
port="12345",
refresh="15"
]
```
_inverter.items:_
```
Group gInverter "SolarMax Inverter"
DateTime lastUpdated "Last Updated" <clock> (gInverter) {channel="solarmax:inverter:solarmax:lastUpdated"}
Number startups "Startups" (gInverter) { channel="solarmax:inverter:solarmax:startups" }
Number:ElectricCurrent acPhase1Current "Ac Phase 1 Current in Amps" <energy> (gInverter) { channel="solarmax:inverter:solarmax:acPhase1Current" }
Number:ElectricCurrent acPhase2Current "Ac Phase 2 Current in Amps" <energy> (gInverter) { channel="solarmax:inverter:solarmax:acPhase2Current" }
Number:ElectricCurrent acPhase3Current "Ac Phase 3 Current in Amps" <energy> (gInverter) { channel="solarmax:inverter:solarmax:acPhase3Current" }
Number:Energy energyGeneratedToday "Energy Generated Today in Wh" <energy> (gInverter) { channel="solarmax:inverter:solarmax:energyGeneratedToday" }
Number:Energy energyGeneratedTotal "Energy Generated since recording began in Wh" <energy> (gInverter) { channel="solarmax:inverter:solarmax:energyGeneratedTotal" }
Number operatingHours "Operating Hours since recording began in h" <time> (gInverter) { channel="solarmax:inverter:solarmax:operatingHours" }
Number:Energy energyGeneratedYesterday "Energy Generated Yesterday in Wh" <energy> (gInverter) { channel="solarmax:inverter:solarmax:operatingHours" }
Number:Energy energyGeneratedLastMonth "Energy Generated Last Month in Wh" <energy> (gInverter) { channel="solarmax:inverter:solarmax:energyGeneratedLastMonth" }
Number:Energy energyGeneratedLastYear "Energy Generated Last Year in Wh" <energy> (gInverter) { channel="solarmax:inverter:solarmax:energyGeneratedLastYear" }
Number:Energy energyGeneratedThisMonth "Energy Generated This Month in Wh" <energy> (gInverter) { channel="solarmax:inverter:solarmax:energyGeneratedThisMonth" }
Number:Energy energyGeneratedThisYear "Energy Generated This Year in Wh" <energy> (gInverter) { channel="solarmax:inverter:solarmax:energyGeneratedThisYear" }
Number:Power currentPowerGenerated "Power currently being generated in W" (gInverter) { channel="solarmax:inverter:solarmax:currentPowerGenerated" }
Number:Frequency acFrequency "AcFrequency in Hz" (gInverter) { channel="solarmax:inverter:solarmax:acFrequency" }
Number:ElectricPotential acPhase1Voltage "Ac Phase1 Voltage in V" <energy> (gInverter) { channel="solarmax:inverter:solarmax:acPhase1Voltage" }
Number:ElectricPotential acPhase2Voltage "Ac Phase2 Voltage in V" <energy> (gInverter) { channel="solarmax:inverter:solarmax:acPhase2Voltage" }
Number:ElectricPotential acPhase3Voltage "Ac Phase3 Voltage in V" <energy> (gInverter) { channel="solarmax:inverter:solarmax:acPhase3Voltage" }
Number:Temperature heatSinkTemperature "Heat Sink Temperature in degrees celcius" <temperature> (gInverter) { channel="solarmax:inverter:solarmax:heatSinkTemperature" }
```
_heatpump.sitemap:_
```
sitemap heatpump label="Heatpump" {
Frame label="Heatpump" {
Text item=HeatPump_State_Ext
Text item=HeatPump_Temperature_1
Text item=HeatPump_Outside_Avg
Text item=HeatPump_Hours_Heatpump
Text item=HeatPump_Hours_Heating
Text item=HeatPump_Hours_Warmwater
Switch item=HeatPump_heating_operation_mode mappings=[0="Auto", 1="Auxiliary heater", 2="Party", 3="Holiday", 4="Off"]
Setpoint item=HeatPump_heating_temperature minValue=-10 maxValue=10 step=0.5
Switch item=HeatPump_warmwater_operation_mode mappings=[0="Auto", 1="Auxiliary heater", 2="Party", 3="Holiday", 4="Off"]
Setpoint item=HeatPump_warmwater_temperature minValue=10 maxValue=65 step=1
}
}
```
### SolarMax Commands
During the implementation the SolarMax device was sent all possible 3 character commands and a number of 4 character commands, to see what it responded to.
The most interesting, identifiable and useful commands were implemented as channels above.
Here is a list of other commands, which are known to return some kind of value: ADR (DeviceAddress / Device Number - only used if the devices are linked serially), AMM, CID, CPG, CPL, CP1, CP2, CP3, CP4, CP5, CYC, DIN, DMO, ETH, FH2, FQR, FWV, IAA, IED, IEE, IEM, ILM, IP4, ISL, ITS, KFS, KHS, KTS, LAN (Language), MAC (MAC Address), PAE, PAM, PDA, PDC, PFA, PIN (Power Installed), PLR, PPC, PRL (AC Power Percent, PSF, PSR, PSS, QAC, QMO, QUC, RA1, RA2, RB1, RB2, REL, RH1, RH2, RPR, RSD, SAC, SAL, SAM, SCH, SNM (IP Broadcast Address??), SPS, SRD, SRS, SYS (Operating State), TCP (probably port number - 12345), TI1, TL1, TL3, TND, TNH, TNL, TP1, TP2, TP3, TV0, TV1, TYP (Type?), UA2, UB2, UGD, UI1, UI2, UI3, ULH, ULL, UMX, UM1, UM2, UM3, UPD, UZK, VCM
Valid commands which returned a null/empty value during testing: FFK, FRT, GCP, ITN, PLD, PLE, PLF, PLS, PPO, TV2, VLE, VLI, VLO

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.4.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.solarmax</artifactId>
<name>openHAB Add-ons :: Bundles :: SolarMax Binding</name>
</project>

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;
}
}

View File

@ -337,6 +337,7 @@
<module>org.openhab.binding.snmp</module> <module>org.openhab.binding.snmp</module>
<module>org.openhab.binding.solaredge</module> <module>org.openhab.binding.solaredge</module>
<module>org.openhab.binding.solarlog</module> <module>org.openhab.binding.solarlog</module>
<module>org.openhab.binding.solarmax</module>
<module>org.openhab.binding.solarwatt</module> <module>org.openhab.binding.solarwatt</module>
<module>org.openhab.binding.somfymylink</module> <module>org.openhab.binding.somfymylink</module>
<module>org.openhab.binding.somfytahoma</module> <module>org.openhab.binding.somfytahoma</module>