From 10048bc6257236af10bc6e7813c67a0bbd15f508 Mon Sep 17 00:00:00 2001 From: Peter Kretz Date: Mon, 11 Nov 2024 22:49:57 +0100 Subject: [PATCH] [solarman] Add support for LSE-3 (LAN Stick Logger) (#17563) * [solarman] Added LSE-3 (LAN Stick Logger) Support (#17559) Signed-off-by: Peter Kretz --- .../org.openhab.binding.solarman/README.md | 19 +- .../internal/SolarmanLoggerConfiguration.java | 8 +- .../internal/SolarmanLoggerHandler.java | 16 +- .../solarman/internal/SolarmanLoggerMode.java | 21 +++ .../channel/SolarmanChannelManager.java | 8 +- .../solarman/internal/defmodel/Lookup.java | 43 +++++ .../internal/defmodel/ParameterItem.java | 19 +- .../modbus/SolarmanLoggerConnection.java | 2 +- .../internal/modbus/SolarmanProtocol.java | 28 +++ .../modbus/SolarmanProtocolFactory.java | 34 ++++ .../internal/modbus/SolarmanRawProtocol.java | 163 ++++++++++++++++++ .../internal/modbus/SolarmanV5Protocol.java | 3 +- .../internal/typeprovider/ChannelUtils.java | 3 + .../updater/SolarmanChannelUpdater.java | 51 ++++-- .../resources/OH-INF/thing/thing-types.xml | 11 ++ .../modbus/SolarmanRawProtocolTest.java | 146 ++++++++++++++++ .../modbus/SolarmanV5ProtocolTest.java | 3 +- 17 files changed, 541 insertions(+), 37 deletions(-) create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerMode.java create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/Lookup.java create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocol.java create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocolFactory.java create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocol.java create mode 100644 bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocolTest.java diff --git a/bundles/org.openhab.binding.solarman/README.md b/bundles/org.openhab.binding.solarman/README.md index 8762f6cda75..994be731a7d 100644 --- a/bundles/org.openhab.binding.solarman/README.md +++ b/bundles/org.openhab.binding.solarman/README.md @@ -15,7 +15,7 @@ These data loggers are used by inverters from a lot of manufacturers, just to na The `solarman:logger` thing supports reading data from a Solarman LSW-3 Stick Logger (it might also work with LSE-3 and maybe others) when connected to a supported inverter. -It was tested on a SUN-12K-SG04LP3-EU only, but because the implementation uses the inverter definitions created as part of Stephan Joubert's Home Assistant plugin it **might** work with the other inverters supported by the plugin. +It was tested on a SUN-12K-SG04LP3-EU only, with LAN Stick LSE-3 in RAW MODBUS solarmanLoggerMode and Wifi Stick in V5 MODBUS solarmanLoggerMode but because the implementation uses the inverter definitions created as part of Stephan Joubert's Home Assistant plugin it **might** work with the other inverters supported by the plugin. ## Thing Configuration @@ -25,14 +25,15 @@ The IP address can be obtained from your router and the serial number can either ### `logger` Thing Configuration -| Name | Type | Description | Default | Required | Advanced | -|--------------------|---------|--------------------------------------------------------|---------|----------|----------| -| hostname | text | Hostname or IP address of the Solarman logger | N/A | yes | no | -| serialNumber | text | Serial number of the Solarman logger | N/A | yes | no | -| inverterType | text | The type of inverter connected to the logger | N/A | yes | no | -| port | integer | Port of the Solarman logger | 8899 | no | yes | -| refreshInterval | integer | Interval the device is polled in sec. | 60 | no | yes | -| additionalRequests | text | Additional requests besides the ones in the definition | N/A | no | yes | +| Name | Type | Description | Default | Required | Advanced | +|--------------------|---------|-------------------------------------------------------------------------------------------------------------------|-----------|----------|----------| +| hostname | text | Hostname or IP address of the Solarman logger | N/A | yes | no | +| serialNumber | text | Serial number of the Solarman logger | N/A | yes | no | +| inverterType | text | The type of inverter connected to the logger | N/A | yes | no | +| port | integer | Port of the Solarman logger | 8899 | no | yes | +| refreshInterval | integer | Interval the device is polled in sec. | 60 | no | yes | +| solarmanLoggerMode | option | RAW Modbus for LAN Stick LSE-3 and V5 MODBUS for most Wifi Sticks. If your Wifi stick uses Raw Modbus choose RAW. | V5 MODBUS | no | yes | +| additionalRequests | text | Additional requests besides the ones in the definition | N/A | no | yes | The `inverterType` parameter governs what registers the binding will read from the logger and what channels it will expose. diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerConfiguration.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerConfiguration.java index 4e8b082a14d..db8abcb6bef 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerConfiguration.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerConfiguration.java @@ -31,6 +31,7 @@ public class SolarmanLoggerConfiguration { public String serialNumber = ""; public String inverterType = "sg04lp3"; public int refreshInterval = 30; + public String solarmanLoggerMode = SolarmanLoggerMode.V5MODBUS.toString(); @Nullable public String additionalRequests; @@ -38,12 +39,13 @@ public class SolarmanLoggerConfiguration { } public SolarmanLoggerConfiguration(String hostname, Integer port, String serialNumber, String inverterType, - int refreshInterval, @Nullable String additionalRequests) { + int refreshInterval, String solarmanLoggerMode, @Nullable String additionalRequests) { this.hostname = hostname; this.port = port; this.serialNumber = serialNumber; this.inverterType = inverterType; this.refreshInterval = refreshInterval; + this.solarmanLoggerMode = solarmanLoggerMode; this.additionalRequests = additionalRequests; } @@ -67,6 +69,10 @@ public class SolarmanLoggerConfiguration { return refreshInterval; } + public SolarmanLoggerMode getSolarmanLoggerMode() { + return SolarmanLoggerMode.valueOf(solarmanLoggerMode); + } + @Nullable public String getAdditionalRequests() { return additionalRequests; diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerHandler.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerHandler.java index a5dfb68c682..4adb140c409 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerHandler.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerHandler.java @@ -34,7 +34,8 @@ import org.openhab.binding.solarman.internal.defmodel.ParameterItem; import org.openhab.binding.solarman.internal.defmodel.Request; import org.openhab.binding.solarman.internal.defmodel.Validation; import org.openhab.binding.solarman.internal.modbus.SolarmanLoggerConnector; -import org.openhab.binding.solarman.internal.modbus.SolarmanV5Protocol; +import org.openhab.binding.solarman.internal.modbus.SolarmanProtocol; +import org.openhab.binding.solarman.internal.modbus.SolarmanProtocolFactory; import org.openhab.binding.solarman.internal.updater.SolarmanChannelUpdater; import org.openhab.binding.solarman.internal.updater.SolarmanProcessResult; import org.openhab.core.thing.Channel; @@ -94,7 +95,10 @@ public class SolarmanLoggerHandler extends BaseThingHandler { logger.debug("Found definition for {}", config.inverterType); } } - SolarmanV5Protocol solarmanV5Protocol = new SolarmanV5Protocol(config); + + logger.debug("Raw Type {}", config.solarmanLoggerMode); + + SolarmanProtocol solarmanProtocol = SolarmanProtocolFactory.createSolarmanProtocol(config); String additionalRequests = Objects.requireNonNullElse(config.getAdditionalRequests(), ""); @@ -110,17 +114,17 @@ public class SolarmanLoggerHandler extends BaseThingHandler { scheduledFuture = scheduler .scheduleWithFixedDelay( - () -> queryLoggerAndUpdateState(solarmanLoggerConnector, solarmanV5Protocol, mergedRequests, + () -> queryLoggerAndUpdateState(solarmanLoggerConnector, solarmanProtocol, mergedRequests, paramToChannelMapping, solarmanChannelUpdater), 0, config.refreshInterval, TimeUnit.SECONDS); } private void queryLoggerAndUpdateState(SolarmanLoggerConnector solarmanLoggerConnector, - SolarmanV5Protocol solarmanV5Protocol, List mergedRequests, + SolarmanProtocol solarmanProtocol, List mergedRequests, Map paramToChannelMapping, SolarmanChannelUpdater solarmanChannelUpdater) { try { SolarmanProcessResult solarmanProcessResult = solarmanChannelUpdater.fetchDataFromLogger(mergedRequests, - solarmanLoggerConnector, solarmanV5Protocol, paramToChannelMapping); + solarmanLoggerConnector, solarmanProtocol, paramToChannelMapping); if (solarmanProcessResult.hasSuccessfulResponses()) { updateStatus(ThingStatus.ONLINE); @@ -149,7 +153,7 @@ public class SolarmanLoggerHandler extends BaseThingHandler { } return new AbstractMap.SimpleEntry<>(new ParameterItem(label, "N/A", "N/A", bcc.uom, bcc.scale, bcc.rule, - parseRegisters(bcc.registers), "N/A", new Validation(), bcc.offset, Boolean.FALSE), + parseRegisters(bcc.registers), "N/A", new Validation(), bcc.offset, Boolean.FALSE, null), channel.getUID()); }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerMode.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerMode.java new file mode 100644 index 00000000000..85e6008de98 --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/SolarmanLoggerMode.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2010-2024 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.solarman.internal; + +/** + * @author Peter Kretz - Initial contribution + */ +public enum SolarmanLoggerMode { + V5MODBUS, + RAWMODBUS +} diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/SolarmanChannelManager.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/SolarmanChannelManager.java index 5fad5438aa8..95c68b7290b 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/SolarmanChannelManager.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/SolarmanChannelManager.java @@ -80,7 +80,13 @@ public class SolarmanChannelManager { baseChannelConfig.scale = scale; } - baseChannelConfig.rule = item.getRule(); + if (item.hasLookup() || Boolean.TRUE.equals(item.getIsstr())) { + // Set 5 for Text (String), when isstr is true or Lookup is present + baseChannelConfig.rule = 5; + } else { + baseChannelConfig.rule = item.getRule(); + } + baseChannelConfig.registers = convertRegisters(item.getRegisters()); baseChannelConfig.uom = item.getUom(); diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/Lookup.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/Lookup.java new file mode 100644 index 00000000000..f81b4cffd1b --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/Lookup.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2010-2024 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.solarman.internal.defmodel; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * @author Peter Kretz - Initial contribution + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@NonNullByDefault +public class Lookup { + private int key; + private String value = ""; + + public int getKey() { + return key; + } + + public void setKey(int key) { + this.key = key; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/ParameterItem.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/ParameterItem.java index bc5a9cbf7cd..1f1ef55400d 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/ParameterItem.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/ParameterItem.java @@ -46,13 +46,15 @@ public class ParameterItem { private BigDecimal offset; @Nullable private Boolean isstr; + private List lookup = new ArrayList<>(); public ParameterItem() { } public ParameterItem(String name, @Nullable String itemClass, @Nullable String stateClass, @Nullable String uom, @Nullable BigDecimal scale, Integer rule, List registers, @Nullable String icon, - @Nullable Validation validation, @Nullable BigDecimal offset, @Nullable Boolean isstr) { + @Nullable Validation validation, @Nullable BigDecimal offset, @Nullable Boolean isstr, + @Nullable List lookup) { this.name = name; this.itemClass = itemClass; this.stateClass = stateClass; @@ -64,6 +66,9 @@ public class ParameterItem { this.validation = validation; this.offset = offset; this.isstr = isstr; + if (lookup != null) { + this.lookup = lookup; + } } public String getName() { @@ -153,4 +158,16 @@ public class ParameterItem { public void setItemClass(String itemClass) { this.itemClass = itemClass; } + + public List getLookup() { + return lookup; + } + + public void setLookup(List lookup) { + this.lookup = lookup; + } + + public Boolean hasLookup() { + return !lookup.isEmpty(); + } } diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanLoggerConnection.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanLoggerConnection.java index cf316734929..b3b05fb2ee1 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanLoggerConnection.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanLoggerConnection.java @@ -92,7 +92,7 @@ public class SolarmanLoggerConnection implements AutoCloseable { return new byte[0]; } - private static String bytesToHex(byte[] bytes) { + protected static String bytesToHex(byte[] bytes) { return IntStream.range(0, bytes.length).mapToObj(i -> String.format("%02X", bytes[i])) .collect(Collectors.joining()); } diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocol.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocol.java new file mode 100644 index 00000000000..18d61493049 --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocol.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2024 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.solarman.internal.modbus; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.solarman.internal.modbus.exception.SolarmanException; + +/** + * @author Peter Kretz - Initial contribution + */ +@NonNullByDefault +public interface SolarmanProtocol { + + Map readRegisters(SolarmanLoggerConnection solarmanLoggerConnection, byte mbFunctionCode, + int firstReg, int lastReg) throws SolarmanException; +} diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocolFactory.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocolFactory.java new file mode 100644 index 00000000000..addeb4cea9e --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanProtocolFactory.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2024 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.solarman.internal.modbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.solarman.internal.SolarmanLoggerConfiguration; + +/** + * @author Peter Kretz - Added RAW Modbus for LAN Stick + */ +@NonNullByDefault +public class SolarmanProtocolFactory { + + public static SolarmanProtocol createSolarmanProtocol(SolarmanLoggerConfiguration solarmanLoggerConfiguration) { + switch (solarmanLoggerConfiguration.getSolarmanLoggerMode()) { + case RAWMODBUS: { + return new SolarmanRawProtocol(solarmanLoggerConfiguration); + } + case V5MODBUS: + default: + return new SolarmanV5Protocol(solarmanLoggerConfiguration); + } + } +} diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocol.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocol.java new file mode 100644 index 00000000000..38fd8e2a263 --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocol.java @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2010-2024 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.solarman.internal.modbus; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.solarman.internal.SolarmanLoggerConfiguration; +import org.openhab.binding.solarman.internal.modbus.exception.SolarmanConnectionException; +import org.openhab.binding.solarman.internal.modbus.exception.SolarmanException; +import org.openhab.binding.solarman.internal.modbus.exception.SolarmanProtocolException; + +/** + * @author Catalin Sanda - Initial contribution + * @author Peter Kretz - Added RAW Modbus for LAN Stick + */ +@NonNullByDefault +public class SolarmanRawProtocol implements SolarmanProtocol { + private final SolarmanLoggerConfiguration solarmanLoggerConfiguration; + + public SolarmanRawProtocol(SolarmanLoggerConfiguration solarmanLoggerConfiguration) { + this.solarmanLoggerConfiguration = solarmanLoggerConfiguration; + } + + public Map readRegisters(SolarmanLoggerConnection solarmanLoggerConnection, byte mbFunctionCode, + int firstReg, int lastReg) throws SolarmanException { + byte[] solarmanRawFrame = buildSolarmanRawFrame(mbFunctionCode, firstReg, lastReg); + byte[] respFrame = solarmanLoggerConnection.sendRequest(solarmanRawFrame); + if (respFrame.length > 0) { + byte[] modbusRespFrame = extractModbusRawResponseFrame(respFrame, solarmanRawFrame); + return parseRawModbusReadHoldingRegistersResponse(modbusRespFrame, firstReg, lastReg); + } else { + throw new SolarmanConnectionException("Response frame was empty"); + } + } + + protected byte[] extractModbusRawResponseFrame(byte @Nullable [] responseFrame, byte[] requestFrame) + throws SolarmanException { + if (responseFrame == null || responseFrame.length == 0) { + throw new SolarmanProtocolException("No response frame"); + } else if (responseFrame.length < 11) { + throw new SolarmanProtocolException("Response frame is too short"); + } else if (responseFrame[0] != (byte) 0x03) { + throw new SolarmanProtocolException("Response frame has invalid starting byte"); + } + + return Arrays.copyOfRange(responseFrame, 6, responseFrame.length); + } + + protected Map parseRawModbusReadHoldingRegistersResponse(byte @Nullable [] frame, int firstReg, + int lastReg) throws SolarmanProtocolException { + int regCount = lastReg - firstReg + 1; + Map registers = new HashMap<>(); + int expectedFrameDataLen = 2 + 1 + regCount * 2; + if (frame == null || frame.length < expectedFrameDataLen) { + throw new SolarmanProtocolException("Modbus frame is too short or empty"); + } + + for (int i = 0; i < regCount; i++) { + int p1 = 3 + (i * 2); + ByteBuffer order = ByteBuffer.wrap(frame, p1, 2).order(ByteOrder.BIG_ENDIAN); + byte[] array = new byte[] { order.get(), order.get() }; + registers.put(i + firstReg, array); + } + + return registers; + } + + /** + * Builds a SolarMAN Raw frame to request data from firstReg to lastReg. + * Frame format is based on + * Solarman RAW Protocol + * Request send: + * Header 03e8: Transaction identifier + * Header 0000: Protocol identifier + * Header 0006: Message length (w/o CRC) + * Payload 01: Slave ID + * Payload 03: Read function + * Payload 0003: 1st register address + * Payload 006e: Nb of registers to read + * Trailer 3426: CRC-16 ModBus + * + * @param mbFunctionCode + * @param firstReg - the start register + * @param lastReg - the end register + * @return byte array containing the Solarman Raw frame + */ + protected byte[] buildSolarmanRawFrame(byte mbFunctionCode, int firstReg, int lastReg) { + byte[] requestPayload = buildSolarmanRawFrameRequestPayload(mbFunctionCode, firstReg, lastReg); + byte[] header = buildSolarmanRawFrameHeader(requestPayload.length); + + return ByteBuffer.allocate(header.length + requestPayload.length).put(header).put(requestPayload).array(); + } + + /** + * Builds a SolarMAN Raw frame Header + * Frame format is based on + * Solarman RAW Protocol + * Request send: + * Header 03e8: Transaction identifier + * Header 0000: Protocol identifier + * Header 0006: Message length (w/o CRC) + * + * @param payloadSize th + * @return byte array containing the Solarman Raw frame header + */ + private byte[] buildSolarmanRawFrameHeader(int payloadSize) { + // (two byte) Denotes the start of the Raw frame. Always 0x03 0xE8. + byte[] transactionId = new byte[] { (byte) 0x03, (byte) 0xE8 }; + + // (two bytes) – Always 0x00 0x00 + byte[] protocolId = new byte[] { (byte) 0x00, (byte) 0x00 }; + + // (two bytes) Payload length + byte[] messageLength = ByteBuffer.allocate(Short.BYTES).order(ByteOrder.BIG_ENDIAN) + .putShort((short) payloadSize).array(); + + // Append all fields into the header + return ByteBuffer.allocate(transactionId.length + protocolId.length + messageLength.length).put(transactionId) + .put(protocolId).put(messageLength).array(); + } + + /** + * Builds a SolarMAN Raw frame payload + * Frame format is based on + * Solarman RAW Protocol + * Request send: + * Payload 01: Slave ID + * Payload 03: Read function + * Payload 0003: 1st register address + * Payload 006e: Nb of registers to read + * Trailer 3426: CRC-16 ModBus + * + * @param mbFunctionCode + * @param firstReg - the start register + * @param lastReg - the end register + * @return byte array containing the Solarman Raw frame payload + */ + protected byte[] buildSolarmanRawFrameRequestPayload(byte mbFunctionCode, int firstReg, int lastReg) { + int regCount = lastReg - firstReg + 1; + byte[] req = ByteBuffer.allocate(6).put((byte) 0x01).put(mbFunctionCode).putShort((short) firstReg) + .putShort((short) regCount).array(); + byte[] crc = ByteBuffer.allocate(Short.BYTES).order(ByteOrder.LITTLE_ENDIAN) + .putShort((short) CRC16Modbus.calculate(req)).array(); + + return ByteBuffer.allocate(req.length + crc.length).put(req).put(crc).array(); + } +} diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5Protocol.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5Protocol.java index 16f484b6d78..f2264f14088 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5Protocol.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5Protocol.java @@ -30,13 +30,14 @@ import org.openhab.binding.solarman.internal.modbus.exception.SolarmanProtocolEx * @author Catalin Sanda - Initial contribution */ @NonNullByDefault -public class SolarmanV5Protocol { +public class SolarmanV5Protocol implements SolarmanProtocol { private final SolarmanLoggerConfiguration solarmanLoggerConfiguration; public SolarmanV5Protocol(SolarmanLoggerConfiguration solarmanLoggerConfiguration) { this.solarmanLoggerConfiguration = solarmanLoggerConfiguration; } + @Override public Map readRegisters(SolarmanLoggerConnection solarmanLoggerConnection, byte mbFunctionCode, int firstReg, int lastReg) throws SolarmanException { byte[] solarmanV5Frame = buildSolarmanV5Frame(mbFunctionCode, firstReg, lastReg); diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/typeprovider/ChannelUtils.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/typeprovider/ChannelUtils.java index 6a49b99adad..abca6afcc12 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/typeprovider/ChannelUtils.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/typeprovider/ChannelUtils.java @@ -14,6 +14,7 @@ package org.openhab.binding.solarman.internal.typeprovider; import javax.measure.Unit; import javax.measure.quantity.Dimensionless; +import javax.measure.quantity.ElectricCharge; import javax.measure.quantity.ElectricCurrent; import javax.measure.quantity.ElectricPotential; import javax.measure.quantity.Energy; @@ -76,6 +77,7 @@ public class ChannelUtils { private static String computeNumberType(String uom) { return switch (uom.toUpperCase()) { case "A" -> CoreItemFactory.NUMBER + ":" + ElectricCurrent.class.getSimpleName(); + case "AH" -> CoreItemFactory.NUMBER + ":" + ElectricCharge.class.getSimpleName(); case "V" -> CoreItemFactory.NUMBER + ":" + ElectricPotential.class.getSimpleName(); case "°C" -> CoreItemFactory.NUMBER + ":" + Temperature.class.getSimpleName(); case "W", "KW", "VA", "KVA", "VAR", "KVAR" -> CoreItemFactory.NUMBER + ":" + Power.class.getSimpleName(); @@ -96,6 +98,7 @@ public class ChannelUtils { public static @Nullable Unit getUnitFromDefinition(String uom) { return switch (uom.toUpperCase()) { case "A" -> Units.AMPERE; + case "AH" -> Units.AMPERE_HOUR; case "V" -> Units.VOLT; case "°C" -> SIUnits.CELSIUS; case "W" -> Units.WATT; diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanChannelUpdater.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanChannelUpdater.java index a9aa8ccd8d2..bb75d5b5dd8 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanChannelUpdater.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanChannelUpdater.java @@ -33,11 +33,12 @@ import javax.measure.format.MeasurementParseException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.solarman.internal.defmodel.Lookup; import org.openhab.binding.solarman.internal.defmodel.ParameterItem; import org.openhab.binding.solarman.internal.defmodel.Request; import org.openhab.binding.solarman.internal.modbus.SolarmanLoggerConnection; import org.openhab.binding.solarman.internal.modbus.SolarmanLoggerConnector; -import org.openhab.binding.solarman.internal.modbus.SolarmanV5Protocol; +import org.openhab.binding.solarman.internal.modbus.SolarmanProtocol; import org.openhab.binding.solarman.internal.modbus.exception.SolarmanConnectionException; import org.openhab.binding.solarman.internal.modbus.exception.SolarmanException; import org.openhab.binding.solarman.internal.typeprovider.ChannelUtils; @@ -64,7 +65,7 @@ public class SolarmanChannelUpdater { } public SolarmanProcessResult fetchDataFromLogger(List requests, - SolarmanLoggerConnector solarmanLoggerConnector, SolarmanV5Protocol solarmanV5Protocol, + SolarmanLoggerConnector solarmanLoggerConnector, SolarmanProtocol solarmanProtocol, Map paramToChannelMapping) { try (SolarmanLoggerConnection solarmanLoggerConnection = solarmanLoggerConnector.createConnection()) { logger.debug("Fetching data from logger"); @@ -77,7 +78,7 @@ public class SolarmanChannelUpdater { SolarmanProcessResult solarmanProcessResult = requests.stream().map(request -> { try { return SolarmanProcessResult.ofValue(request, - solarmanV5Protocol.readRegisters(solarmanLoggerConnection, + solarmanProtocol.readRegisters(solarmanLoggerConnection, (byte) request.getMbFunctioncode().intValue(), request.getStart(), request.getEnd())); } catch (SolarmanException e) { @@ -120,6 +121,7 @@ public class SolarmanChannelUpdater { .map(rawVal -> String.format("%02d", rawVal / 100) + ":" + String.format("%02d", rawVal % 100)) .collect(Collectors.joining()); + logger.debug("Update state: channelUID: {}, state: {}", channelUID.getAsString(), stringValue); stateUpdater.updateState(channelUID, new StringType(stringValue)); } @@ -143,6 +145,7 @@ public class SolarmanChannelUpdater { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy/M/d H:m:s"); LocalDateTime dateTime = LocalDateTime.parse(stringValue, formatter); + logger.debug("Update state: channelUID: {}, state: {}", channelUID.getAsString(), dateTime.toString()); stateUpdater.updateState(channelUID, new DateTimeType(dateTime.atZone(ZoneId.systemDefault()))); } catch (DateTimeParseException e) { logger.debug("Unable to parse string date {} to a DateTime object", stringValue); @@ -156,6 +159,7 @@ public class SolarmanChannelUpdater { + (rawVal & 0x0F)) .collect(Collectors.joining()); + logger.debug("Update Version state: channelUID: {}, state: {}", channelUID.getAsString(), stringValue); stateUpdater.updateState(channelUID, new StringType(stringValue)); } @@ -166,6 +170,7 @@ public class SolarmanChannelUpdater { return acc.append((char) (shortValue >> 8)).append((char) (shortValue & 0xFF)); }, StringBuilder::append).toString(); + logger.debug("Update String state: channelUID: {}, state: {}", channelUID.getAsString(), stringValue); stateUpdater.updateState(channelUID, new StringType(stringValue)); } @@ -175,23 +180,37 @@ public class SolarmanChannelUpdater { BigDecimal convertedValue = convertNumericValue(value, parameterItem.getOffset(), parameterItem.getScale()); String uom = Objects.requireNonNullElse(parameterItem.getUom(), ""); - State state; - if (!uom.isBlank()) { - try { - Unit unitFromDefinition = ChannelUtils.getUnitFromDefinition(uom); - if (unitFromDefinition != null) { - state = new QuantityType<>(convertedValue, unitFromDefinition); - } else { - logger.debug("Unable to parse unit: {}", uom); + if (parameterItem.hasLookup()) { + String stringValue = getStringFromLookupList(value.intValue(), parameterItem.getLookup()); + logger.debug("Update Lookup state: channelUID: {}, key: {}, state: {}", channelUID.getAsString(), + value.intValue(), stringValue); + stateUpdater.updateState(channelUID, new StringType(stringValue)); + } else { + State state; + if (!uom.isBlank()) { + try { + Unit unitFromDefinition = ChannelUtils.getUnitFromDefinition(uom); + if (unitFromDefinition != null) { + state = new QuantityType<>(convertedValue, unitFromDefinition); + } else { + logger.debug("Unable to parse unit: {}", uom); + state = new DecimalType(convertedValue); + } + } catch (MeasurementParseException e) { state = new DecimalType(convertedValue); } - } catch (MeasurementParseException e) { + } else { state = new DecimalType(convertedValue); } - } else { - state = new DecimalType(convertedValue); + logger.debug("Update Numeric state: channelUID: {}, state: {}", channelUID.getAsString(), + state.toFullString()); + stateUpdater.updateState(channelUID, state); } - stateUpdater.updateState(channelUID, state); + } + + private @Nullable String getStringFromLookupList(int key, List lookupList) { + return lookupList.stream().filter(lookup -> key == lookup.getKey()).map(Lookup::getValue).findFirst() + .orElse(""); } private void updateChannelWithRawValue(ParameterItem parameterItem, ChannelUID channelUID, List registers, @@ -200,7 +219,7 @@ public class SolarmanChannelUpdater { reversed(registers).stream().map(readRegistersMap::get).map( val -> String.format("0x%02X", ByteBuffer.wrap(val).order(ByteOrder.BIG_ENDIAN).getShort())) .collect(Collectors.joining(","))); - + logger.debug("Update RawValue state: channelUID: {}, state: {}", channelUID.getAsString(), hexString); stateUpdater.updateState(channelUID, new StringType(hexString)); } diff --git a/bundles/org.openhab.binding.solarman/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.solarman/src/main/resources/OH-INF/thing/thing-types.xml index 3a616b56532..57046c17c7f 100644 --- a/bundles/org.openhab.binding.solarman/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.solarman/src/main/resources/OH-INF/thing/thing-types.xml @@ -57,6 +57,17 @@ 60 true + + + Use RAW Modbus for LAN Stick LSE-3 and V5 NODBUS for most Wifi Sticks. If your Wifi stick uses Raw + Modbus choose RAW. If you do not use this advanced option, V5 MODBUS will be the default. + + + + + V5MODBUS + true + Additional requests besides the ones defined in the inverter definition. diff --git a/bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocolTest.java b/bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocolTest.java new file mode 100644 index 00000000000..647e10e5dcd --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanRawProtocolTest.java @@ -0,0 +1,146 @@ +/** + * Copyright (c) 2010-2024 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.solarman.internal.modbus; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import javax.validation.constraints.NotNull; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.binding.solarman.internal.SolarmanLoggerConfiguration; +import org.openhab.binding.solarman.internal.SolarmanLoggerMode; +import org.openhab.binding.solarman.internal.modbus.exception.SolarmanException; + +/** + * @author Catalin Sanda - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@NonNullByDefault +class SolarmanRawProtocolTest { + SolarmanLoggerConnection solarmanLoggerConnection = (@NotNull SolarmanLoggerConnection) mock( + SolarmanLoggerConnection.class); + + private SolarmanLoggerConfiguration loggerConfiguration = new SolarmanLoggerConfiguration("192.168.1.1", 8899, + "1234567890", "sg04lp3", 60, SolarmanLoggerMode.RAWMODBUS.toString(), null); + + private SolarmanRawProtocol solarmanRawProtocol = new SolarmanRawProtocol(loggerConfiguration); + + @Test + void testbuildSolarmanRawFrame() { + byte[] requestFrame = solarmanRawProtocol.buildSolarmanRawFrame((byte) 0x03, 0x0063, 0x006D); + byte[] expectedFrame = { (byte) 0x03, (byte) 0xE8, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x08, + (byte) 0x01, (byte) 0x03, (byte) 0x00, (byte) 0x63, (byte) 0x00, (byte) 0x0B, (byte) 0xF4, + (byte) 0x13 }; + + assertArrayEquals(requestFrame, expectedFrame); + } + + @Test + void testReadRegister0x01() throws SolarmanException { + // given + when(solarmanLoggerConnection.sendRequest(any())) + .thenReturn(hexStringToByteArray("03E800000019010316168016801590012C11940014005A000000050096007D")); + + // when + Map regValues = solarmanRawProtocol.readRegisters(solarmanLoggerConnection, (byte) 0x03, 1, 1); + + // then + assertEquals(1, regValues.size()); + assertTrue(regValues.containsKey(1)); + assertEquals("1680", bytesToHex(regValues.get(1))); + } + + @Test + void testReadRegisters0x02to0x03() throws SolarmanException { + // given + when(solarmanLoggerConnection.sendRequest(any())) + .thenReturn(hexStringToByteArray("03E800000019010316168016801590012C11940014005A000000050096007D")); + + // when + Map regValues = solarmanRawProtocol.readRegisters(solarmanLoggerConnection, (byte) 0x03, 2, 3); + + // then + assertEquals(2, regValues.size()); + assertTrue(regValues.containsKey(2)); + assertTrue(regValues.containsKey(3)); + assertEquals("1680", bytesToHex(regValues.get(2))); + assertEquals("1680", bytesToHex(regValues.get(3))); + } + + @Test + void testReadRegisterSUN10KSG04LP3EUPart1() throws SolarmanException { + // given + when(solarmanLoggerConnection.sendRequest(any())).thenReturn(hexStringToByteArray( + "03E80000005101034E091A08FD092700000000000000020003000000050000138800800037002800A5004A003D000600010003000A00000000000600010003000A0000091B08F6091C006E00500014010E00C9003E0215")); + + // when + Map regValues = solarmanRawProtocol.readRegisters(solarmanLoggerConnection, (byte) 0x03, 0x3c, + 0x4f); + + // then + assertEquals(20, regValues.size()); + assertTrue(regValues.containsKey(0x3c)); + assertTrue(regValues.containsKey(0x4f)); + assertEquals("091A", bytesToHex(regValues.get(0x3c))); + assertEquals("0001", bytesToHex(regValues.get(0x4f))); + } + + @Test + void testReadRegisterSUN10KSG04LP3EUPart2() throws SolarmanException { + // given + when(solarmanLoggerConnection.sendRequest(any())).thenReturn(hexStringToByteArray( + "03E80000005101034E091A08FD092700000000000000020003000000050000138800800037002800A5004A003D000600010003000A00000000000600010003000A0000091B08F6091C006E00500014010E00C9003E0215")); + + // when + Map regValues = solarmanRawProtocol.readRegisters(solarmanLoggerConnection, (byte) 0x03, 0x50, + 0x5f); + + // then + assertEquals(16, regValues.size()); + assertTrue(regValues.containsKey(0x50)); + assertTrue(regValues.containsKey(0x5f)); + assertEquals("091A", bytesToHex(regValues.get(0x50))); + assertEquals("00A5", bytesToHex(regValues.get(0x5f))); + } + + private static byte[] hexStringToByteArray(String s) { + int len = s.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16)); + } + return data; + } + + @Nullable + private static String bytesToHex(byte @Nullable [] bytes) { + if (bytes == null) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5ProtocolTest.java b/bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5ProtocolTest.java index 4577f750533..bb800b88d49 100644 --- a/bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5ProtocolTest.java +++ b/bundles/org.openhab.binding.solarman/src/test/java/org/openhab/binding/solarman/internal/modbus/SolarmanV5ProtocolTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.openhab.binding.solarman.internal.SolarmanLoggerConfiguration; +import org.openhab.binding.solarman.internal.SolarmanLoggerMode; import org.openhab.binding.solarman.internal.modbus.exception.SolarmanException; /** @@ -39,7 +40,7 @@ class SolarmanV5ProtocolTest { SolarmanLoggerConnection.class); private SolarmanLoggerConfiguration loggerConfiguration = new SolarmanLoggerConfiguration("192.168.1.1", 8899, - "1234567890", "sg04lp3", 60, null); + "1234567890", "sg04lp3", 60, SolarmanLoggerMode.V5MODBUS.toString(), null); private SolarmanV5Protocol solarmanV5Protocol = new SolarmanV5Protocol(loggerConfiguration);