From 05bce1f11816fb5803556a08a4faa42def7d7430 Mon Sep 17 00:00:00 2001 From: Oleksandr Mishchuk Date: Mon, 29 Sep 2025 23:08:49 +0300 Subject: [PATCH 1/2] Fix creation of lookup channels to be of correct type --- .../binding/solarman/internal/typeprovider/ChannelUtils.java | 4 ++++ 1 file changed, 4 insertions(+) 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 a75a0bf9e2e34..ec811f387b014 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 @@ -59,6 +59,10 @@ public static String getItemType(ParameterItem item) { uom = "UNKN"; } + if (item.hasLookup() || Boolean.TRUE.equals(item.getIsstr())) { + return CoreItemFactory.STRING; + } + return switch (rule) { case 5, 6, 7, 9 -> CoreItemFactory.STRING; case 8 -> CoreItemFactory.DATETIME; From 0ddb3a263e4ce94bbf9560f26abdd4b4cc162c9d Mon Sep 17 00:00:00 2001 From: Oleksandr Mishchuk Date: Mon, 29 Sep 2025 23:09:44 +0300 Subject: [PATCH 2/2] Add ability to write Solarman registers, add new type of hybrid invertor Signed-off-by: Oleksandr Mishchuk --- bundles/org.openhab.binding.solarman/pom.xml | 4 +- .../internal/SolarmanLoggerHandler.java | 39 +- .../internal/channel/BaseChannelConfig.java | 1 + .../channel/SolarmanChannelManager.java | 2 + .../internal/defmodel/ParameterItem.java | 16 +- .../internal/enums/IntegerValueType.java | 24 + .../internal/modbus/SolarmanProtocol.java | 5 + .../internal/modbus/SolarmanRawProtocol.java | 124 +- .../internal/modbus/SolarmanV5Protocol.java | 118 +- .../updater/SolarmanChannelUpdater.java | 27 +- .../updater/SolarmanRegisterUpdater.java | 230 ++++ .../solarman/internal/util/ParserUtils.java | 40 + .../resources/OH-INF/thing/thing-types.xml | 1 + .../resources/definitions/deye_sg01hp3.yaml | 1043 +++++++++++++++++ .../modbus/SolarmanRawProtocolTest.java | 4 +- .../modbus/SolarmanV5ProtocolTest.java | 13 +- 16 files changed, 1616 insertions(+), 75 deletions(-) create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/enums/IntegerValueType.java create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanRegisterUpdater.java create mode 100644 bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/util/ParserUtils.java create mode 100644 bundles/org.openhab.binding.solarman/src/main/resources/definitions/deye_sg01hp3.yaml diff --git a/bundles/org.openhab.binding.solarman/pom.xml b/bundles/org.openhab.binding.solarman/pom.xml index dd844012348b7..68fabe7ee3cef 100644 --- a/bundles/org.openhab.binding.solarman/pom.xml +++ b/bundles/org.openhab.binding.solarman/pom.xml @@ -1,4 +1,6 @@ - + + 4.0.0 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 e70604ffdceab..8664efcfa2ef8 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 @@ -38,6 +38,8 @@ 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.binding.solarman.internal.updater.SolarmanRegisterUpdater; +import org.openhab.binding.solarman.internal.util.ParserUtils; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -63,6 +65,8 @@ public class SolarmanLoggerHandler extends BaseThingHandler { private final SolarmanChannelManager solarmanChannelManager; @Nullable private volatile ScheduledFuture scheduledFuture; + @Nullable + private SolarmanRegisterUpdater solarmanRegisterUpdater; public SolarmanLoggerHandler(Thing thing) { super(thing); @@ -72,6 +76,13 @@ public SolarmanLoggerHandler(Thing thing) { @Override public void handleCommand(ChannelUID channelUID, Command command) { + logger.trace("Received command {} in channel {}", command, channelUID); + + if (solarmanRegisterUpdater != null) { + solarmanRegisterUpdater.updateLoggerRegisters(channelUID, command); + } else { + logger.error("SolarmanRegisterUpdater is not initialized yet"); + } } @Override @@ -110,6 +121,8 @@ public void initialize() { extractChannelMappingFromChannels(staticChannels), setupChannelsForInverterDefinition(inverterDefinition)); + solarmanRegisterUpdater = new SolarmanRegisterUpdater(paramToChannelMapping, solarmanLoggerConnector, + solarmanProtocol); SolarmanChannelUpdater solarmanChannelUpdater = new SolarmanChannelUpdater(this::updateState); scheduledFuture = scheduler @@ -152,19 +165,13 @@ private Map extractChannelMappingFromChannels(List(new ParameterItem(label, "N/A", "N/A", bcc.uom, bcc.scale, bcc.rule, - parseRegisters(bcc.registers), "N/A", new Validation(), bcc.offset, Boolean.FALSE, null), - channel.getUID()); + ParserUtils.parseRegisters(bcc.registers), "N/A", new Validation(), bcc.offset, Boolean.FALSE, + bcc.readOnly, null), channel.getUID()); }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } - private List parseRegisters(String registers) { - String[] tokens = registers.split(","); - Pattern pattern = Pattern.compile("\\s*(0x[\\da-fA-F]+|[\\d]+)\\s*"); - return Stream.of(tokens).map(pattern::matcher).filter(Matcher::matches).map(matcher -> matcher.group(1)) - .map(SolarmanLoggerHandler::parseNumber).toList(); - } - // For now just concatenate the list, in the future, merge overlapping requests private List mergeRequests(List requestList1, List requestList2) { return Stream.concat(requestList1.stream(), requestList2.stream()).collect(Collectors.toList()); @@ -177,19 +184,15 @@ private List extractAdditionalRequests(String channels) { return Stream.of(tokens).map(pattern::matcher).filter(Matcher::matches).map(matcher -> { try { - int functionCode = parseNumber(matcher.group(1)); - int start = parseNumber(matcher.group(2)); - int end = parseNumber(matcher.group(3)); + int functionCode = ParserUtils.parseNumber(matcher.group(1)); + int start = ParserUtils.parseNumber(matcher.group(2)); + int end = ParserUtils.parseNumber(matcher.group(3)); return new Request(functionCode, start, end); } catch (NumberFormatException e) { logger.debug("Invalid number format in token: {} , ignoring additional requests", matcher.group(), e); - return new Request(-1, 0, 0); + return Request.NONE; } - }).filter(request -> request.getMbFunctioncode() > 0).collect(Collectors.toList()); - } - - private static int parseNumber(String number) { - return number.startsWith("0x") ? Integer.parseInt(number.substring(2), 16) : Integer.parseInt(number); + }).filter(request -> !Request.NONE.equals(request)).collect(Collectors.toList()); } private Map setupChannelsForInverterDefinition(InverterDefinition inverterDefinition) { diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/BaseChannelConfig.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/BaseChannelConfig.java index 4d76b8b1ac904..4e93b8bf2afe2 100644 --- a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/BaseChannelConfig.java +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/channel/BaseChannelConfig.java @@ -27,4 +27,5 @@ public class BaseChannelConfig { public Integer rule = 1; public BigDecimal offset = BigDecimal.ZERO; public String registers = ""; + public boolean readOnly = true; } 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 67078cf38ba62..9ab98dcc243c0 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 @@ -87,6 +87,8 @@ private Configuration buildConfigurationFromItem(ParameterItem item) { baseChannelConfig.rule = item.getRule(); } + baseChannelConfig.readOnly = !Boolean.FALSE.equals(item.getIsReadOnly()); + 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/ParameterItem.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/defmodel/ParameterItem.java index d50fbef551e21..e5652a8f3ef37 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,6 +46,8 @@ public class ParameterItem { private BigDecimal offset; @Nullable private Boolean isstr; + @Nullable + private Boolean isReadOnly; private List lookup = new ArrayList<>(); public ParameterItem() { @@ -54,18 +56,20 @@ 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 List lookup) { + @Nullable Boolean isReadOnly, @Nullable List lookup) { this.name = name; this.itemClass = itemClass; this.stateClass = stateClass; this.uom = uom; this.scale = scale; this.rule = rule; - this.registers = registers; + this.registers = new ArrayList<>(registers); + this.registers.sort(Integer::compareTo); this.icon = icon; this.validation = validation; this.offset = offset; this.isstr = isstr; + this.isReadOnly = isReadOnly; if (lookup != null) { this.lookup = lookup; } @@ -151,6 +155,14 @@ public void setIsstr(Boolean isstr) { this.isstr = isstr; } + public @Nullable Boolean getIsReadOnly() { + return isReadOnly; + } + + public void setIsReadOnly(Boolean isReadOnly) { + this.isReadOnly = isReadOnly; + } + public @Nullable String getItemClass() { return itemClass; } diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/enums/IntegerValueType.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/enums/IntegerValueType.java new file mode 100644 index 0000000000000..ffc1b55b0ab1b --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/enums/IntegerValueType.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2025 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.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Oleksandr Mishchuk - Initial contribution + */ +@NonNullByDefault +public enum IntegerValueType { + UNSIGNED, + SIGNED +} 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 index fa26a4e4784bd..c3d94d547e457 100644 --- 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 @@ -22,7 +22,12 @@ */ @NonNullByDefault public interface SolarmanProtocol { + byte DEFAULT_SLAVE_ID = 0x01; + byte WRITE_REGISTERS_FUNCTION_CODE = 0x10; Map readRegisters(SolarmanLoggerConnection solarmanLoggerConnection, byte mbFunctionCode, int firstReg, int lastReg) throws SolarmanException; + + boolean writeRegisters(SolarmanLoggerConnection solarmanLoggerConnection, int register, byte[] data) + throws SolarmanException; } 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 index 5cb385d445545..c4a4e8c70e662 100644 --- 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 @@ -37,9 +37,11 @@ public SolarmanRawProtocol(SolarmanLoggerConfiguration solarmanLoggerConfigurati this.solarmanLoggerConfiguration = solarmanLoggerConfiguration; } + @Override public Map readRegisters(SolarmanLoggerConnection solarmanLoggerConnection, byte mbFunctionCode, int firstReg, int lastReg) throws SolarmanException { - byte[] solarmanRawFrame = buildSolarmanRawFrame(mbFunctionCode, firstReg, lastReg); + int regCount = lastReg - firstReg + 1; + byte[] solarmanRawFrame = buildSolarmanRawReadFrame(mbFunctionCode, firstReg, regCount); byte[] respFrame = solarmanLoggerConnection.sendRequest(solarmanRawFrame); if (respFrame.length > 0) { byte[] modbusRespFrame = extractModbusRawResponseFrame(respFrame, solarmanRawFrame); @@ -49,6 +51,73 @@ public Map readRegisters(SolarmanLoggerConnection solarmanLogge } } + @Override + public boolean writeRegisters(SolarmanLoggerConnection solarmanLoggerConnection, int firstReg, byte[] data) + throws SolarmanException { + if (data.length % 2 != 0) { + throw new SolarmanException("Data to be written should be packed as two bytes per register!"); + } + + byte[] solarmanRawFrame = buildSolarmanRawWriteFrame(firstReg, data); + byte[] respFrame = solarmanLoggerConnection.sendRequest(solarmanRawFrame); + if (respFrame.length > 0) { + byte[] modbusRespFrame = extractModbusRawResponseFrame(respFrame, solarmanRawFrame); + parseRawModbusWriteHoldingRegistersResponse(modbusRespFrame, data); + return true; + } else { + throw new SolarmanConnectionException("Response frame was empty"); + } + } + + /** + * Builds a SolarMAN Raw frame to request data write from firstReg. + * Frame format is based on + * Solarman RAW Protocol + * + * @param firstReg - the start register + * @param data - the data to be written + * @return byte array containing the Solarman Raw frame + */ + private byte[] buildSolarmanRawWriteFrame(int firstReg, byte[] data) throws SolarmanException { + byte[] requestPayload = buildSolarmanRawWriteFrameRequestPayload(DEFAULT_SLAVE_ID, firstReg, data); + byte[] header = buildSolarmanRawFrameHeader(requestPayload.length); + + return ByteBuffer.allocate(header.length + requestPayload.length).put(header).put(requestPayload).array(); + } + + /** + * Builds a SolarMAN Raw write frame payload + * Frame format is based on + * Modbus RTU Write Multiple Registers + * + * @param slaveId - Modbus slave ID + * @param firstReg - the start register + * @param data - the data to be written + * @return byte array containing the Modbus RTU Raw frame payload + */ + private byte[] buildSolarmanRawWriteFrameRequestPayload(byte slaveId, int firstReg, byte[] data) + throws SolarmanException { + if (data.length % 2 != 0) { + throw new SolarmanException("Data to be written should be packed as two bytes per register!"); + } + + // slaveId (1 byte) + // mbFunction (1 byte) + // firstRegister (2 bytes) + // registerCount (2 bytes) + // data length (1 byte) + // data + int bufferSize = 1 + 1 + 2 + 2 + 1 + data.length; + int registerCount = data.length / 2; + + byte[] req = ByteBuffer.allocate(bufferSize).put(slaveId).put(WRITE_REGISTERS_FUNCTION_CODE) + .putShort((short) firstReg).putShort((short) registerCount).put((byte) data.length).put(data).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(); + } + protected byte[] extractModbusRawResponseFrame(byte @Nullable [] responseFrame, byte[] requestFrame) throws SolarmanException { if (responseFrame == null || responseFrame.length == 0) { @@ -81,6 +150,36 @@ protected Map parseRawModbusReadHoldingRegistersResponse(byte @ return registers; } + private void parseRawModbusWriteHoldingRegistersResponse(byte[] frame, byte[] data) + throws SolarmanProtocolException { + int expectedRegistersCount = data.length / 2; + + // slaveId (1 byte) + // modbusFunction (1 byte) + // firstRegister (2 bytes) + // registerCount (2 bytes) + int expectedFrameDataLen = 1 + 1 + 2 + 2; + if (frame == null || frame.length < expectedFrameDataLen + 2) { + throw new SolarmanProtocolException("Modbus frame is too short or empty"); + } + + int actualCrc = ByteBuffer.wrap(frame, expectedFrameDataLen, 2).order(ByteOrder.LITTLE_ENDIAN).getShort() + & 0xFFFF; + int expectedCrc = CRC16Modbus.calculate(Arrays.copyOfRange(frame, 0, expectedFrameDataLen)); + + if (actualCrc != expectedCrc) { + throw new SolarmanProtocolException( + String.format("Modbus frame crc is not valid. Expected %04x, got %04x", expectedCrc, actualCrc)); + } + + short registersWrittenCount = ByteBuffer.wrap(frame, 4, 2).getShort(); + if (registersWrittenCount != expectedRegistersCount) { + throw new SolarmanProtocolException( + String.format("Modbus written registers count is not valid. Expected %04x, got %04x", + expectedRegistersCount, registersWrittenCount)); + } + } + /** * Builds a SolarMAN Raw frame to request data from firstReg to lastReg. * Frame format is based on @@ -94,14 +193,14 @@ protected Map parseRawModbusReadHoldingRegistersResponse(byte @ * Payload 0003: 1st register address * Payload 006e: Nb of registers to read * Trailer 3426: CRC-16 ModBus - * - * @param mbFunctionCode + * + * @param mbFunctionCode - Modbus function code * @param firstReg - the start register - * @param lastReg - the end register + * @param regCount - the registers count * @return byte array containing the Solarman Raw frame */ - protected byte[] buildSolarmanRawFrame(byte mbFunctionCode, int firstReg, int lastReg) { - byte[] requestPayload = buildSolarmanRawFrameRequestPayload(mbFunctionCode, firstReg, lastReg); + protected byte[] buildSolarmanRawReadFrame(byte mbFunctionCode, int firstReg, int regCount) { + byte[] requestPayload = buildSolarmanRawReadFrameRequestPayload(mbFunctionCode, firstReg, regCount); byte[] header = buildSolarmanRawFrameHeader(requestPayload.length); return ByteBuffer.allocate(header.length + requestPayload.length).put(header).put(requestPayload).array(); @@ -115,7 +214,7 @@ protected byte[] buildSolarmanRawFrame(byte mbFunctionCode, int firstReg, int la * 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 */ @@ -136,7 +235,7 @@ private byte[] buildSolarmanRawFrameHeader(int payloadSize) { } /** - * Builds a SolarMAN Raw frame payload + * Builds a SolarMAN Raw read frame payload * Frame format is based on * Solarman RAW Protocol * Request send: @@ -145,14 +244,13 @@ private byte[] buildSolarmanRawFrameHeader(int payloadSize) { * Payload 0003: 1st register address * Payload 006e: Nb of registers to read * Trailer 3426: CRC-16 ModBus - * - * @param mbFunctionCode + * + * @param mbFunctionCode - Modbus function code * @param firstReg - the start register - * @param lastReg - the end register + * @param regCount - the registers count * @return byte array containing the Solarman Raw frame payload */ - protected byte[] buildSolarmanRawFrameRequestPayload(byte mbFunctionCode, int firstReg, int lastReg) { - int regCount = lastReg - firstReg + 1; + protected byte[] buildSolarmanRawReadFrameRequestPayload(byte mbFunctionCode, int firstReg, int regCount) { 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) 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 c763e7b54f583..9da515a9fcda6 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 @@ -40,7 +40,9 @@ public SolarmanV5Protocol(SolarmanLoggerConfiguration solarmanLoggerConfiguratio @Override public Map readRegisters(SolarmanLoggerConnection solarmanLoggerConnection, byte mbFunctionCode, int firstReg, int lastReg) throws SolarmanException { - byte[] solarmanV5Frame = buildSolarmanV5Frame(mbFunctionCode, firstReg, lastReg); + int regCount = lastReg - firstReg + 1; + byte[] modbusFrame = buildModbusReadHoldingRegistersFrame(DEFAULT_SLAVE_ID, mbFunctionCode, firstReg, regCount); + byte[] solarmanV5Frame = buildSolarmanV5Frame(modbusFrame); byte[] respFrame = solarmanLoggerConnection.sendRequest(solarmanV5Frame); if (respFrame.length > 0) { byte[] modbusRespFrame = extractModbusResponseFrame(respFrame, solarmanV5Frame); @@ -50,18 +52,31 @@ public Map readRegisters(SolarmanLoggerConnection solarmanLogge } } + @Override + public boolean writeRegisters(SolarmanLoggerConnection solarmanLoggerConnection, int firstReg, byte[] data) + throws SolarmanException { + byte[] modbusFrame = buildModbusWriteHoldingRegistersFrame(DEFAULT_SLAVE_ID, firstReg, data); + byte[] solarmanV5Frame = buildSolarmanV5Frame(modbusFrame); + byte[] respFrame = solarmanLoggerConnection.sendRequest(solarmanV5Frame); + if (respFrame.length > 0) { + byte[] modbusRespFrame = extractModbusResponseFrame(respFrame, solarmanV5Frame); + parseModbusWriteHoldingRegistersResponse(modbusRespFrame, data); + return true; + } else { + throw new SolarmanConnectionException("Response frame was empty"); + } + } + /** - * Builds a SolarMAN V5 frame to request data from firstReg to lastReg. + * Builds a SolarMAN V5 frame. * Frame format is based on * Solarman V5 Protocol * - * @param mbFunctionCode - * @param firstReg - the start register - * @param lastReg - the end register + * @param modbusPayload - ModBus Frame to integrate * @return byte array containing the Solarman V5 frame */ - protected byte[] buildSolarmanV5Frame(byte mbFunctionCode, int firstReg, int lastReg) { - byte[] requestPayload = buildSolarmanV5FrameRequestPayload(mbFunctionCode, firstReg, lastReg); + protected byte[] buildSolarmanV5Frame(byte[] modbusPayload) { + byte[] requestPayload = buildSolarmanV5FrameRequestPayload(modbusPayload); byte[] header = buildSolarmanV5FrameHeader(requestPayload.length); byte[] trailer = buildSolarmanV5FrameTrailer(header, requestPayload); @@ -125,7 +140,7 @@ private byte[] buildSolarmanV5FrameHeader(int payloadSize) { .put(start).put(length).put(controlCode).put(serial).put(loggerSerial).array(); } - protected byte[] buildSolarmanV5FrameRequestPayload(byte mbFunctionCode, int firstReg, int lastReg) { + protected byte[] buildSolarmanV5FrameRequestPayload(byte[] modbusPayload) { // (one byte) – Denotes the frame type. byte[] frameType = new byte[] { 0x02 }; // (two bytes) – Denotes the sensor type. @@ -137,15 +152,12 @@ protected byte[] buildSolarmanV5FrameRequestPayload(byte mbFunctionCode, int fir byte[] powerOnTime = new byte[] { 0x00, 0x00, 0x00, 0x00 }; // Denotes the frame offset time. byte[] offsetTime = new byte[] { 0x00, 0x00, 0x00, 0x00 }; - // (variable length) – Modbus RTU request frame. - byte[] requestFrame = buildModbusReadHoldingRegistersRequestFrame((byte) 0x01, mbFunctionCode, firstReg, - lastReg); return ByteBuffer .allocate(frameType.length + sensorType.length + totalWorkingTime.length + powerOnTime.length - + offsetTime.length + requestFrame.length) - .put(frameType).put(sensorType).put(totalWorkingTime).put(powerOnTime).put(offsetTime).put(requestFrame) - .array(); + + offsetTime.length + modbusPayload.length) + .put(frameType).put(sensorType).put(totalWorkingTime).put(powerOnTime).put(offsetTime) + .put(modbusPayload).array(); } /** @@ -153,16 +165,48 @@ protected byte[] buildSolarmanV5FrameRequestPayload(byte mbFunctionCode, int fir * Registers * * @param slaveId - Slave Address - * @param mbFunctionCode - - * @param firstReg - Starting Address - * @param lastReg - Ending Address + * @param mbFunctionCode - Modbus function code + * @param startRegister - Starting register + * @param registerCount - Register count * @return byte array containing the Modbus request frame */ - protected byte[] buildModbusReadHoldingRegistersRequestFrame(byte slaveId, byte mbFunctionCode, int firstReg, - int lastReg) { - int regCount = lastReg - firstReg + 1; - byte[] req = ByteBuffer.allocate(6).put(slaveId).put(mbFunctionCode).putShort((short) firstReg) - .putShort((short) regCount).array(); + protected byte[] buildModbusReadHoldingRegistersFrame(byte slaveId, byte mbFunctionCode, int startRegister, + int registerCount) { + byte[] req = ByteBuffer.allocate(6).put(slaveId).put(mbFunctionCode).putShort((short) startRegister) + .putShort((short) registerCount).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(); + } + + /** + * Based on Function 16 (10hex) Write Holding + * Registers + * + * @param slaveId - Slave Address + * @param startRegister - Starting register + * @param data - Data to be written + * @return byte array containing the Modbus request frame + */ + protected byte[] buildModbusWriteHoldingRegistersFrame(byte slaveId, int startRegister, byte[] data) + throws SolarmanException { + if (data.length % 2 != 0) { + throw new SolarmanException("Data to be written should be packed as two bytes per register!"); + } + + // slaveId (1 byte) + // mbFunction (1 byte) + // startRegister (2 bytes) + // registerCount (2 bytes) + // data length (1 byte) + // data + int bufferSize = 1 + 1 + 2 + 2 + 1 + data.length; + int registerCount = data.length / 2; + + byte[] req = ByteBuffer.allocate(bufferSize).put(slaveId).put(WRITE_REGISTERS_FUNCTION_CODE) + .putShort((short) startRegister).putShort((short) registerCount).put((byte) data.length).put(data) + .array(); byte[] crc = ByteBuffer.allocate(Short.BYTES).order(ByteOrder.LITTLE_ENDIAN) .putShort((short) CRC16Modbus.calculate(req)).array(); @@ -197,6 +241,36 @@ protected Map parseModbusReadHoldingRegistersResponse(byte @Nul return registers; } + protected void parseModbusWriteHoldingRegistersResponse(byte @Nullable [] frame, byte[] data) + throws SolarmanProtocolException { + int expectedRegistersCount = data.length / 2; + + // slaveId (1 byte) + // modbusFunction (1 byte) + // firstRegister (2 bytes) + // registerCount (2 bytes) + int expectedFrameDataLen = 1 + 1 + 2 + 2; + if (frame == null || frame.length < expectedFrameDataLen + 2) { + throw new SolarmanProtocolException("Modbus frame is too short or empty"); + } + + int actualCrc = ByteBuffer.wrap(frame, expectedFrameDataLen, 2).order(ByteOrder.LITTLE_ENDIAN).getShort() + & 0xFFFF; + int expectedCrc = CRC16Modbus.calculate(Arrays.copyOfRange(frame, 0, expectedFrameDataLen)); + + if (actualCrc != expectedCrc) { + throw new SolarmanProtocolException( + String.format("Modbus frame crc is not valid. Expected %04x, got %04x", expectedCrc, actualCrc)); + } + + short registersWrittenCount = ByteBuffer.wrap(frame, 4, 2).getShort(); + if (registersWrittenCount != expectedRegistersCount) { + throw new SolarmanProtocolException( + String.format("Modbus written registers count is not valid. Expected %04x, got %04x", + expectedRegistersCount, registersWrittenCount)); + } + } + protected byte[] extractModbusResponseFrame(byte @Nullable [] responseFrame, byte[] requestFrame) throws SolarmanException { if (responseFrame == null || responseFrame.length == 0) { 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 e7cccaf14aa6f..988071a025be8 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 @@ -36,6 +36,7 @@ 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.enums.IntegerValueType; import org.openhab.binding.solarman.internal.modbus.SolarmanLoggerConnection; import org.openhab.binding.solarman.internal.modbus.SolarmanLoggerConnector; import org.openhab.binding.solarman.internal.modbus.SolarmanProtocol; @@ -100,9 +101,9 @@ private void updateChannelsForReadRegisters(Map param if (readRegistersMap.keySet().containsAll(registers)) { switch (parameterItem.getRule()) { case 1, 3 -> updateChannelWithNumericValue(parameterItem, channelUID, registers, readRegistersMap, - ValueType.UNSIGNED); + IntegerValueType.UNSIGNED); case 2, 4 -> updateChannelWithNumericValue(parameterItem, channelUID, registers, readRegistersMap, - ValueType.SIGNED); + IntegerValueType.SIGNED); case 5 -> updateChannelWithStringValue(channelUID, registers, readRegistersMap); case 6 -> updateChannelWithRawValue(parameterItem, channelUID, registers, readRegistersMap); case 7 -> updateChannelWithVersion(channelUID, registers, readRegistersMap); @@ -175,7 +176,7 @@ private void updateChannelWithStringValue(ChannelUID channelUID, List r } private void updateChannelWithNumericValue(ParameterItem parameterItem, ChannelUID channelUID, - List registers, Map readRegistersMap, ValueType valueType) { + List registers, Map readRegistersMap, IntegerValueType valueType) { BigInteger value = extractNumericValue(registers, readRegistersMap, valueType); BigDecimal convertedValue = convertNumericValue(value, parameterItem.getOffset(), parameterItem.getScale()); String uom = Objects.requireNonNullElse(parameterItem.getUom(), ""); @@ -229,19 +230,13 @@ private BigDecimal convertNumericValue(BigInteger value, @Nullable BigDecimal of } private BigInteger extractNumericValue(List registers, Map readRegistersMap, - ValueType valueType) { - return reversed(registers) - .stream().map(readRegistersMap::get).reduce( - BigInteger.ZERO, (acc, - val) -> acc.shiftLeft(Short.SIZE) - .add(BigInteger.valueOf(ByteBuffer.wrap(val).getShort() - & (valueType == ValueType.UNSIGNED ? 0xFFFF : 0xFFFFFFFF))), - BigInteger::add); - } - - private enum ValueType { - UNSIGNED, - SIGNED + IntegerValueType valueType) { + return reversed(registers).stream().map(readRegistersMap::get).reduce( + BigInteger.ZERO, (acc, + val) -> acc.shiftLeft(Short.SIZE) + .add(BigInteger.valueOf(ByteBuffer.wrap(val).getShort() + & (valueType == IntegerValueType.UNSIGNED ? 0xFFFF : 0xFFFFFFFF))), + BigInteger::add); } @FunctionalInterface diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanRegisterUpdater.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanRegisterUpdater.java new file mode 100644 index 0000000000000..b926fd7babf7e --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/updater/SolarmanRegisterUpdater.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2010-2025 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.updater; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +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.enums.IntegerValueType; +import org.openhab.binding.solarman.internal.modbus.SolarmanLoggerConnection; +import org.openhab.binding.solarman.internal.modbus.SolarmanLoggerConnector; +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.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.Command; +import org.openhab.core.util.HexUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link SolarmanRegisterUpdater} is responsible for updating registers from received commands + * + * @author Oleksandr Mishchuk - Initial contribution + */ +@NonNullByDefault +public class SolarmanRegisterUpdater { + private static final Logger logger = LoggerFactory.getLogger(SolarmanRegisterUpdater.class); + + private static final Pattern TIME_PATTERN = Pattern.compile("^([0-1][0-9])|(2[0-3]):[0-5][0-9]"); + + private final SolarmanLoggerConnector solarmanLoggerConnector; + private final SolarmanProtocol solarmanProtocol; + private final Map writableChannels; + + public SolarmanRegisterUpdater(Map paramToChannelMapping, + SolarmanLoggerConnector solarmanLoggerConnector, SolarmanProtocol solarmanProtocol) { + this.solarmanProtocol = solarmanProtocol; + this.solarmanLoggerConnector = solarmanLoggerConnector; + + writableChannels = paramToChannelMapping.entrySet().stream() + .filter(e -> Boolean.FALSE.equals(e.getKey().getIsReadOnly())).filter(e -> { + final List registers = e.getKey().getRegisters(); + if (registers.isEmpty()) { + logger.warn("Writeable channel {} have no registers, skipping", e.getValue()); + return false; + } + + final int firstRegister = registers.getFirst(); + int i = 0; + while (i < registers.size() && registers.get(i) == firstRegister + i) { + i++; + } + if (i != registers.size()) { + logger.warn("Writeable channel {} should have consecutive registers, skipping", e.getValue()); + return false; + } + + return true; + }).collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); + } + + public void updateLoggerRegisters(ChannelUID channelUID, Command command) { + final ParameterItem channelToUpdate = writableChannels.get(channelUID); + if (channelToUpdate == null) { + logger.warn("Channel '{}' is either read-only or doesn't exist", channelUID); + return; + } + + switch (channelToUpdate.getRule()) { + case 1 -> updateIntRegisters(channelUID, channelToUpdate, command, Short.BYTES, IntegerValueType.UNSIGNED); + case 2 -> updateIntRegisters(channelUID, channelToUpdate, command, Short.BYTES, IntegerValueType.SIGNED); + case 3 -> + updateIntRegisters(channelUID, channelToUpdate, command, Integer.BYTES, IntegerValueType.UNSIGNED); + case 4 -> updateIntRegisters(channelUID, channelToUpdate, command, Integer.BYTES, IntegerValueType.SIGNED); + case 5 -> updateStringRegisters(channelUID, channelToUpdate, command); + case 6 -> updateRawRegisters(channelUID, channelToUpdate, command); + case 7 -> updateVersionRegisters(channelUID, channelToUpdate, command); + case 8 -> updateDateTimeRegisters(channelUID, channelToUpdate, command); + case 9 -> updateTimeRegisters(channelUID, channelToUpdate, command); + } + } + + private void updateIntRegisters(ChannelUID channelUID, ParameterItem channelToUpdate, Command command, int size, + IntegerValueType integerValueType) { + final DecimalType decimalValue = switch (command) { + case DecimalType decimal -> decimal; + case OnOffType onOff -> onOff == OnOffType.ON ? new DecimalType(1) : DecimalType.ZERO; + case StringType stringType -> { + if (channelToUpdate.getLookup().isEmpty()) { + logUnexpectedCommand(channelUID, command); + yield null; + } else { + final String lookupValue = stringType.toString(); + final Optional lookupOptional = channelToUpdate.getLookup().stream() + .filter(l -> lookupValue.equals(l.getValue())).findFirst(); + if (lookupOptional.isPresent()) { + yield new DecimalType(lookupOptional.get().getKey()); + } else { + logUnexpectedCommand(channelUID, command); + yield null; + } + } + } + default -> { + logUnexpectedCommand(channelUID, command); + yield null; + } + }; + + if (decimalValue != null) { + long value = convertNumericValue(decimalValue, channelToUpdate.getOffset(), channelToUpdate.getScale()) + .longValue(); + if (IntegerValueType.UNSIGNED == integerValueType) { + long mask = (1L << (size * 8)) - 1; + value = value & mask; + } + ByteBuffer buffer = ByteBuffer.allocate(size); + switch (size) { + case 2 -> buffer.putShort((short) value); + case 4 -> buffer.putInt((int) value); + case 8 -> buffer.putLong(value); + } + byte[] data = buffer.array(); + writeRegisters(channelToUpdate.getRegisters().getFirst(), size / 2, data); + } + } + + private void updateStringRegisters(ChannelUID channelUID, ParameterItem channelToUpdate, Command command) { + if (command instanceof StringType stringType) { + String string = stringType.toString(); + int length = (string.length() + 1) / 2 * 2; + byte[] data = ByteBuffer.allocate(length).put(string.getBytes(StandardCharsets.UTF_8)).array(); + writeRegisters(channelToUpdate.getRegisters().getFirst(), length / 2, data); + } else { + logUnexpectedCommand(channelUID, command); + } + } + + private void updateRawRegisters(ChannelUID channelUID, ParameterItem channelToUpdate, Command command) { + logger.warn("Writing Raw to logger is not implemented yet"); + } + + private void updateVersionRegisters(ChannelUID channelUID, ParameterItem channelToUpdate, Command command) { + logger.warn("Writing Version to logger is not implemented yet"); + } + + private void updateDateTimeRegisters(ChannelUID channelUID, ParameterItem channelToUpdate, Command command) { + logger.warn("Writing DateTime to logger is not implemented yet"); + } + + private void updateTimeRegisters(ChannelUID channelUID, ParameterItem channelToUpdate, Command command) { + if (command instanceof StringType string) { + String timeString = string.toString(); + final Matcher timeMatcher = TIME_PATTERN.matcher(timeString); + if (timeMatcher.matches()) { + final int hour = Integer.parseInt(timeString.substring(0, 2)); + final int minute = Integer.parseInt(timeString.substring(3, 5)); + final short value = (short) (hour * 100 + minute); + byte[] data = ByteBuffer.allocate(2).putShort(value).array(); + writeRegisters(channelToUpdate.getRegisters().getFirst(), 1, data); + } else { + logger.warn("Received string '{}' is not correct time format 'HH:mm'", timeString); + } + } else { + logUnexpectedCommand(channelUID, command); + } + } + + private BigDecimal convertNumericValue(DecimalType decimal, @Nullable BigDecimal offset, + @Nullable BigDecimal scale) { + return decimal.toBigDecimal().divide(scale != null ? scale : BigDecimal.ONE, RoundingMode.HALF_UP) + .add(offset != null ? offset : BigDecimal.ZERO); + } + + private void writeRegisters(int firstRegister, int registerCount, byte[] data) { + if (data.length > registerCount * 2) { + logger.warn( + "Data to be written ({}) is longer than the number of 2 byte registers declared for channel ({}). Data will be truncated!", + data.length, registerCount); + } + + data = ByteBuffer.wrap(data, 0, registerCount * 2).array(); + + try (SolarmanLoggerConnection solarmanLoggerConnection = solarmanLoggerConnector.createConnection()) { + logger.debug("Writing data {} to {} logger register(s) starting from 0x{}", HexUtils.bytesToHex(data), + registerCount, String.format("%04X", firstRegister)); + + if (!solarmanLoggerConnection.isConnected()) { + throw new SolarmanConnectionException("Unable to connect to logger"); + } + + if (solarmanProtocol.writeRegisters(solarmanLoggerConnection, firstRegister, data)) { + logger.info("Successfully updated registers"); + } else { + logger.error("Failed to update registers"); + } + } catch (SolarmanException e) { + logger.error("Failed to communicate with logger", e); + } + } + + private void logUnexpectedCommand(ChannelUID uid, Command command) { + logger.warn("Received unexpected command {} in channel {}", command, uid); + } +} diff --git a/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/util/ParserUtils.java b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/util/ParserUtils.java new file mode 100644 index 0000000000000..db8c68c9ea17f --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/java/org/openhab/binding/solarman/internal/util/ParserUtils.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2010-2025 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.util; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ParserUtils} contains different utility methods for parsing + * + * @author Oleksandr Mishchuk - Initial contribution + */ +@NonNullByDefault +public class ParserUtils { + private static final Pattern REGISTER_PATTERN = Pattern.compile("\\s*(0x[\\da-fA-F]+|[\\d]+)\\s*"); + + public static List parseRegisters(String registers) { + String[] tokens = registers.split(","); + return Stream.of(tokens).map(REGISTER_PATTERN::matcher).filter(Matcher::find).map(matcher -> matcher.group(1)) + .map(ParserUtils::parseNumber).toList(); + } + + public static int parseNumber(String number) { + return number.startsWith("0x") ? Integer.parseInt(number.substring(2), 16) : Integer.parseInt(number); + } +} 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 02447908e62ce..f73ca35e667b1 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 @@ -32,6 +32,7 @@ + diff --git a/bundles/org.openhab.binding.solarman/src/main/resources/definitions/deye_sg01hp3.yaml b/bundles/org.openhab.binding.solarman/src/main/resources/definitions/deye_sg01hp3.yaml new file mode 100644 index 0000000000000..99cdc563fcb68 --- /dev/null +++ b/bundles/org.openhab.binding.solarman/src/main/resources/definitions/deye_sg01hp3.yaml @@ -0,0 +1,1043 @@ +# SUN-302K-SG01HP3-EU | 30KW | Three Phase | 3 MPPT | Hybrid Inverter | High Voltage Battery + +requests: + - start: 0x0003 + end: 0x0059 + mb_functioncode: 0x03 + - start: 0x0063 + end: 0x006D + mb_functioncode: 0x03 + - start: 0x0085 + end: 0x0085 + mb_functioncode: 0x03 + - start: 0x00A6 + end: 0x00B1 + mb_functioncode: 0x03 + - start: 0x0094 + end: 0x009F + mb_functioncode: 0x03 + - start: 0x0202 + end: 0x022E + mb_functioncode: 0x03 + - start: 0x0218 + end: 0x021A + mb_functioncode: 0x03 + - start: 0x024A + end: 0x024F + mb_functioncode: 0x03 + - start: 0x0256 + end: 0x027C + mb_functioncode: 0x03 + - start: 0x0284 + end: 0x028D + mb_functioncode: 0x03 + - start: 0x0295 + end: 0x029F + mb_functioncode: 0x03 + - start: 0x02A0 + end: 0x02A8 + mb_functioncode: 0x03 + +parameters: + - group: solar + items: + - name: "PV1 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x02A0] + icon: "mdi:solar-power" + + - name: "PV2 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x02A1] + icon: "mdi:solar-power" + + - name: "PV3 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [ 0x02A2 ] + icon: "mdi:solar-power" + + - name: "PV1 Voltage" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x02A4] + icon: "mdi:solar-power" + + - name: "PV2 Voltage" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x02A6] + icon: "mdi:solar-power" + + - name: "PV3 Voltage" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [ 0x02A7 ] + icon: "mdi:solar-power" + + - name: "PV1 Current" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.1 + rule: 1 + registers: [0x02A5] + icon: "mdi:solar-power" + + - name: "PV2 Current" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.1 + rule: 1 + registers: [0x02A7] + icon: "mdi:solar-power" + + - name: "PV3 Current" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.1 + rule: 1 + registers: [ 0x02A8 ] + icon: "mdi:solar-power" + + - name: "Daily Production" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x0211] + icon: "mdi:solar-power" + validation: + max: 100 + invalidate_all: + + - name: "Total Production" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x0216, 0x0217] + icon: "mdi:solar-power" + + - group: Battery + items: + - name: "Battery Equalization V" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.01 + rule: 1 + registers: [0x0063] + icon: "mdi:battery" + + - name: "Battery Absorption V" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.01 + rule: 1 + registers: [0x0064] + icon: "mdi:battery" + + - name: "Battery Float V" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.01 + rule: 1 + registers: [0x0065] + icon: "mdi:battery" + + - name: "Battery Capacity" + class: "battery" + state_class: "measurement" + uom: "Ah" + scale: 1 + rule: 1 + registers: [0x0066] + icon: "mdi:battery" + + - name: "Battery Empty V" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.01 + rule: 1 + registers: [0x0066] + icon: "mdi:battery" + + - name: "Battery Max A Charge" + class: "current" + state_class: "measurement" + uom: "A" + scale: 1 + rule: 1 + registers: [0x006C] + icon: "mdi:battery" + + - name: "Battery Max A Discharge" + class: "current" + state_class: "measurement" + uom: "A" + scale: 1 + rule: 1 + registers: [0x006D] + icon: "mdi:battery" + + - name: "Daily Battery Charge" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x0202] + icon: "mdi:battery-plus" + + - name: "Daily Battery Discharge" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x0203] + icon: "mdi:battery-plus" + + - name: "Total Battery Charge" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x0204, 0x0205] + icon: "mdi:battery-plus" + + - name: "Total Battery Discharge" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x0206, 0x0207] + icon: "mdi:battery-minus" + + - name: "Battery Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x024E] + icon: "mdi:battery" + + - name: "Battery Voltage" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.01 + rule: 1 + registers: [0x024B] + icon: "mdi:battery" + + - name: "Battery SOC" + class: "battery" + state_class: "measurement" + uom: "%" + scale: 1 + rule: 1 + registers: [0x024C] + icon: "mdi:battery" + validation: + min: 0 + max: 101 + + - name: "Battery Current" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.01 + rule: 2 + registers: [0x024F] + icon: "mdi:battery" + + - name: "Battery Temperature" + class: "temperature" + state_class: "measurement" + uom: "°C" + scale: 0.1 + rule: 1 + offset: 1000 + registers: [0x024A] + icon: "mdi:battery" + validation: + min: 1 + max: 99 + + - group: Grid + items: + - name: "Total Grid Power" + class: "measurement" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x0271] + icon: "mdi:transmission-tower" + + - name: "Grid Voltage L1" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0256] + icon: "mdi:transmission-tower" + + - name: "Grid Voltage L2" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0257] + icon: "mdi:transmission-tower" + + - name: "Grid Voltage L3" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0258] + icon: "mdi:transmission-tower" + + - name: "Internal CT L1 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x025C] + icon: "mdi:transmission-tower" + + - name: "Internal CT L2 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x025D] + icon: "mdi:transmission-tower" + + - name: "Internal CT L3 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x025E] + icon: "mdi:transmission-tower" + + - name: "External CT L1 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x0268] + icon: "mdi:transmission-tower" + + - name: "External CT L2 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x0269] + icon: "mdi:transmission-tower" + + - name: "External CT L3 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x026A] + icon: "mdi:transmission-tower" + + - name: "Daily Energy Bought" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x0208] + icon: "mdi:transmission-tower-export" + + - name: "Total Energy Bought" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x020A, 0x020B] + icon: "mdi:transmission-tower-export" + + - name: "Daily Energy Sold" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x0209] + icon: "mdi:transmission-tower-import" + + - name: "Total Energy Sold" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x020C, 0x020D] + icon: "mdi:transmission-tower-import" + + - name: "Total Grid Production" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 4 + registers: [0x020C, 0x020D] + icon: "mdi:transmission-tower" + + - group: Upload + items: + - name: "Total Load Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x028D] + icon: "mdi:lightning-bolt-outline" + + - name: "Load L1 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x028A] + icon: "mdi:lightning-bolt-outline" + + - name: "Load L2 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x028B] + icon: "mdi:lightning-bolt-outline" + + - name: "Load L3 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x028C] + icon: "mdi:lightning-bolt-outline" + + - name: "Load Voltage L1" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0284] + icon: "mdi:lightning-bolt-outline" + + - name: "Load Voltage L2" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0285] + icon: "mdi:lightning-bolt-outline" + + - name: "Load Voltage L3" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0286] + icon: "mdi:lightning-bolt-outline" + + - name: "Daily Load Consumption" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x020E] + icon: "mdi:lightning-bolt-outline" + + - name: "Total Load Consumption" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x020F, 0x0210] + icon: "mdi:lightning-bolt-outline" + + - group: Inverter + items: + - name: "Current L1" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.01 + rule: 2 + registers: [0x0276] + icon: "mdi:home-lightning-bolt" + + - name: "Current L2" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.01 + rule: 2 + registers: [0x0277] + icon: "mdi:home-lightning-bolt" + + - name: "Current L3" + class: "current" + uom: "A" + scale: 0.01 + rule: 2 + registers: [0x0278] + icon: "mdi:home-lightning-bolt" + + - name: "Inverter L1 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x0279] + icon: "mdi:home-lightning-bolt" + + - name: "Inverter L2 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x027A] + icon: "mdi:home-lightning-bolt" + + - name: "Inverter L3 Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 2 + registers: [0x027B] + icon: "mdi:home-lightning-bolt" + + - name: "DC Temperature" + class: "temperature" + state_class: "measurement" + uom: "°C" + scale: 0.1 + rule: 2 + offset: 1000 + registers: [0x021C] + icon: "mdi:thermometer" + + - name: "AC Temperature" + class: "temperature" + state_class: "measurement" + uom: "°C" + scale: 0.1 + rule: 2 + offset: 1000 + registers: [0x021D] + icon: "mdi:thermometer" + + - name: "Inverter ID" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 5 + registers: [0x0003, 0x0004, 0x0005, 0x0006, 0x0007] + isstr: true + + - name: "Communication Board Version No." + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [0x0011] + isstr: true + + - name: "Control Board Version No." + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [0x000D] + isstr: true + + - group: SmartLoad + items: + - name: "SmartLoad Enable Status" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [0x0085] + isstr: true + lookup: + - key: 0 + value: "GEN Use" + - key: 1 + value: "SMART Load output" + - key: 2 + value: "Microinverter" + icon: "mdi:lightning-bolt-outline" + + - name: "Phase voltage of Gen port A" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0295] + icon: "mdi:home-lightning-bolt" + + - name: "Phase voltage of Gen port B" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0296] + icon: "mdi:home-lightning-bolt" + + - name: "Phase voltage of Gen port C" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0297] + icon: "mdi:home-lightning-bolt" + + - name: "Phase power of Gen port A" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x0298] + icon: "mdi:home-lightning-bolt" + validation: + min: 0 + max: 30000 + + - name: "Phase power of Gen port B" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x0299] + icon: "mdi:home-lightning-bolt" + validation: + min: 0 + max: 30000 + + - name: "Phase power of Gen port C" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x029A] + icon: "mdi:home-lightning-bolt" + validation: + min: 0 + max: 30000 + + - name: "Total Power of Gen port" + class: "power" + state_class: "measurement" + uom: "W" + scale: 1 + rule: 1 + registers: [0x029B] + icon: "mdi:home-l1ghtning-bolt" + validation: + min: 0 + max: 30000 + + - name: "Generator daily power generation" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x0218] + icon: "mdi:transmission-tower-import" + + - name: "Generator total power generation" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x0219, 0x021A] + icon: "mdi:transmission-tower-import" + + - group: Alert + items: + - name: "Alert" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 6 + registers: [0x0229, 0x022A, 0x22B, 0x022C, 0x022D, 0x022E] + + - group: Time Of Use + items: + - name: "TOU Time 1" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 9 + isReadOnly: false + registers: [0x0094] + + - name: "TOU Time 2" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 9 + isReadOnly: false + registers: [0x0095] + + - name: "TOU Time 3" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 9 + isReadOnly: false + registers: [0x0096] + + - name: "TOU Time 4" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 9 + isReadOnly: false + registers: [0x0097] + + - name: "TOU Time 5" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 9 + isReadOnly: false + registers: [0x0098] + + - name: "TOU Time 6" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 9 + isReadOnly: false + registers: [0x0099] + + - name: "TOU Power 1" + class: "energy" + state_class: "measurement" + uom: "W" + scale: 10 + rule: 1 + isReadOnly: false + registers: [0x009A] + + - name: "TOU Power 2" + class: "energy" + state_class: "measurement" + uom: "W" + scale: 10 + rule: 1 + isReadOnly: false + registers: [0x009B] + + - name: "TOU Power 3" + class: "energy" + state_class: "measurement" + uom: "W" + scale: 10 + rule: 1 + isReadOnly: false + registers: [0x009C] + + - name: "TOU Power 4" + class: "energy" + state_class: "measurement" + uom: "W" + scale: 10 + rule: 1 + isReadOnly: false + registers: [0x009D] + + - name: "TOU Power 5" + class: "energy" + state_class: "measurement" + uom: "W" + scale: 10 + rule: 1 + isReadOnly: false + registers: [0x009E] + + - name: "TOU Power 6" + class: "energy" + state_class: "measurement" + uom: "W" + scale: 10 + rule: 1 + isReadOnly: false + registers: [0x009F] + + - name: "TOU Battery SOC 1" + class: "battery" + state_class: "measurement" + uom: "%" + scale: 1 + rule: 1 + isReadOnly: false + registers: [0x00A6] + icon: "mdi:battery" + validation: + min: 0 + max: 100 + + - name: "TOU Battery SOC 2" + class: "battery" + state_class: "measurement" + uom: "%" + scale: 1 + rule: 1 + isReadOnly: false + registers: [0x00A7] + icon: "mdi:battery" + validation: + min: 0 + max: 100 + + - name: "TOU Battery SOC 3" + class: "battery" + state_class: "measurement" + uom: "%" + scale: 1 + rule: 1 + isReadOnly: false + registers: [0x00A8] + icon: "mdi:battery" + validation: + min: 0 + max: 100 + + - name: "TOU Battery SOC 4" + class: "battery" + state_class: "measurement" + uom: "%" + scale: 1 + rule: 1 + isReadOnly: false + registers: [0x00A9] + icon: "mdi:battery" + validation: + min: 0 + max: 100 + + - name: "TOU Battery SOC 5" + class: "battery" + state_class: "measurement" + uom: "%" + scale: 1 + rule: 1 + isReadOnly: false + registers: [0x00AA] + icon: "mdi:battery" + validation: + min: 0 + max: 100 + + - name: "TOU Battery SOC 6" + class: "battery" + state_class: "measurement" + uom: "%" + scale: 1 + rule: 1 + isReadOnly: false + registers: [0x00AB] + icon: "mdi:battery" + validation: + min: 0 + max: 100 + + - name: "TOU Charge Enable 1" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [0x00AC] + isReadOnly: false + isstr: true + lookup: + - key: 0 + value: "Charge disabled" + - key: 1 + value: "Grid charge" + - key: 2 + value: "Generator charge" + - key: 3 + value: "Grid charge + Generator charge" + + - name: "TOU Charge Enable 2" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [ 0x00AD ] + isReadOnly: false + isstr: true + lookup: + - key: 0 + value: "Charge disabled" + - key: 1 + value: "Grid charge" + - key: 2 + value: "Generator charge" + - key: 3 + value: "Grid charge + Generator charge" + + - name: "TOU Charge Enable 3" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [ 0x00AE ] + isReadOnly: false + isstr: true + lookup: + - key: 0 + value: "Charge disabled" + - key: 1 + value: "Grid charge" + - key: 2 + value: "Generator charge" + - key: 3 + value: "Grid charge + Generator charge" + + - name: "TOU Charge Enable 4" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [ 0x00AF ] + isReadOnly: false + isstr: true + lookup: + - key: 0 + value: "Charge disabled" + - key: 1 + value: "Grid charge" + - key: 2 + value: "Generator charge" + - key: 3 + value: "Grid charge + Generator charge" + + - name: "TOU Charge Enable 5" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [ 0x00B0 ] + isReadOnly: false + isstr: true + lookup: + - key: 0 + value: "Charge disabled" + - key: 1 + value: "Grid charge" + - key: 2 + value: "Generator charge" + - key: 3 + value: "Grid charge + Generator charge" + + - name: "TOU Charge Enable 6" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [ 0x00B1 ] + isReadOnly: false + isstr: true + lookup: + - key: 0 + value: "Charge disabled" + - key: 1 + value: "Grid charge" + - key: 2 + value: "Generator charge" + - key: 3 + value: "Grid charge + Generator charge" \ No newline at end of file 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 index 594360533d085..20d4b7e9ca727 100644 --- 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 @@ -45,8 +45,8 @@ class SolarmanRawProtocolTest { private SolarmanRawProtocol solarmanRawProtocol = new SolarmanRawProtocol(loggerConfiguration); @Test - void testbuildSolarmanRawFrame() { - byte[] requestFrame = solarmanRawProtocol.buildSolarmanRawFrame((byte) 0x03, 0x0063, 0x006D); + void testbuildSolarmanRawReadFrame() { + byte[] requestFrame = solarmanRawProtocol.buildSolarmanRawReadFrame((byte) 0x03, 0x0063, 11); 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 }; 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 24187834f3ca1..ecba61b6246eb 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 @@ -44,9 +44,20 @@ class SolarmanV5ProtocolTest { private SolarmanV5Protocol solarmanV5Protocol = new SolarmanV5Protocol(loggerConfiguration); + @Test + void tesBuildModbusReadHoldingRegistersFrame() { + byte[] modbusFrame = solarmanV5Protocol.buildModbusReadHoldingRegistersFrame((byte) 0x01, (byte) 0x03, 0x0000, + 0x0021); + byte[] expectedFrame = { (byte) 0x01, (byte) 0x03, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x21, + (byte) 0x85, (byte) 0xD2 }; + assertArrayEquals(modbusFrame, expectedFrame); + } + @Test void testbuildSolarmanV5Frame() { - byte[] requestFrame = solarmanV5Protocol.buildSolarmanV5Frame((byte) 0x03, 0x0000, 0x0020); + byte[] modbusFrame = solarmanV5Protocol.buildModbusReadHoldingRegistersFrame((byte) 0x01, (byte) 0x03, 0x0000, + 0x0021); + byte[] requestFrame = solarmanV5Protocol.buildSolarmanV5Frame(modbusFrame); byte[] expectedFrame = { (byte) 0xA5, (byte) 0x17, (byte) 0x00, (byte) 0x10, (byte) 0x45, (byte) 0x00, (byte) 0x00, (byte) 0xD2, (byte) 0x02, (byte) 0x96, (byte) 0x49, (byte) 0x02, (byte) 0x00, (byte) 0x00,