From e995a252e17f104954ba7773d814ec18c70b54ba Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Thu, 19 Feb 2026 13:13:19 +1100 Subject: [PATCH] feat: Enhance MK2 driver with device state management and improved command handling --- .drone.yml | 3 + README.md | 5 + mk2driver/mk2.go | 505 +++++++++++++++++++++++++++++++++++--- mk2driver/mk2_test.go | 341 +++++++++++++++++++++++++ mk2driver/mk2interface.go | 54 ++++ mk2driver/mockmk2.go | 45 ++++ 6 files changed, 917 insertions(+), 36 deletions(-) diff --git a/.drone.yml b/.drone.yml index 3dba450..4f1f61e 100644 --- a/.drone.yml +++ b/.drone.yml @@ -47,6 +47,7 @@ steps: registry: registry.coadcorp.com repo: registry.coadcorp.com/nathan/invertergui dockerfile: Dockerfile + buildx_options_semicolon: --platform=linux/amd64,linux/arm64;--provenance=false username: nathan password: from_secret: registry_password @@ -62,6 +63,7 @@ steps: registry: registry.coadcorp.com repo: registry.coadcorp.com/nathan/invertergui dockerfile: Dockerfile + buildx_options_semicolon: --platform=linux/amd64,linux/arm64;--provenance=false username: nathan password: from_secret: registry_password @@ -80,6 +82,7 @@ steps: registry: registry.coadcorp.com repo: registry.coadcorp.com/nathan/invertergui dockerfile: Dockerfile + buildx_options_semicolon: --platform=linux/amd64,linux/arm64;--provenance=false username: nathan password: from_secret: registry_password diff --git a/README.md b/README.md index f8ee04f..069862a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ The invertergui allows the monitoring of a [Victron Multiplus](https://www.victr The [`registry.coadcorp.com/nathan/invertergui`](https://registry.coadcorp.com/nathan/invertergui) container image is a build of this repository. +The code has been updated to support more of the protocol published by Victron at https://www.victronenergy.com/upload/documents/Technical-Information-Interfacing-with-VE-Bus-products-MK2-Protocol-3-14.pdf + ## Acknowledgements This project is based on the original open source `invertergui` project by Hendrik van Wyk and contributors: @@ -76,6 +78,9 @@ services: image: registry.coadcorp.com/nathan/invertergui:latest environment: READ_ONLY: "true" + devices: + - "/dev/ttyUSB0:/dev/ttyUSB0" + command: ["--mqtt.enabled", "--mqtt.broker=tcp://192.168.1.1:1883", "--loglevel=info"] ``` ## Port 8080 diff --git a/mk2driver/mk2.go b/mk2driver/mk2.go index dda76bb..ceeb831 100644 --- a/mk2driver/mk2.go +++ b/mk2driver/mk2.go @@ -6,6 +6,7 @@ import ( "io" "math" "sync" + "sync/atomic" "time" "github.com/sirupsen/logrus" @@ -47,6 +48,8 @@ const ( infoFrameHeader = 0x20 frameHeader = 0xff bootupFrameHeader = 0x0 + frameLengthLEDBit = 0x80 + frameLEDBytes = 2 ) const ( @@ -79,22 +82,42 @@ const ( // winmon frame commands const ( + commandSetState = 0x0E commandReadRAMVar = 0x30 + commandReadSetting = 0x31 commandWriteRAMVar = 0x32 commandWriteSetting = 0x33 commandWriteData = 0x34 + commandReadSelected = 0x35 commandGetRAMVarInfo = 0x36 + commandWriteViaID = 0x37 + commandWriteRAMViaID = 0x38 commandUnsupportedResponse = 0x80 commandReadRAMResponse = 0x85 + commandReadSettingResponse = 0x86 commandWriteRAMResponse = 0x87 commandWriteSettingResponse = 0x88 - commandWriteNotAllowedResponse = 0x9B + commandSetStateResponse = 0x89 + commandReadSelectedResponse = 0x8A + commandWriteViaIDResponse = 0x8B + commandWriteRAMViaIDResponse = 0x8C commandGetRAMVarInfoResponse = 0x8E + commandWriteNotAllowedResponse = 0x9B ) const writeResponseTimeout = 3 * time.Second +var ( + errCommandUnsupported = errors.New("command is not supported by this device firmware") + errWriteRejected = errors.New("write command rejected by device access level") +) + +type winmonResponse struct { + command byte + data []byte +} + type mk2Ser struct { info *Mk2Info p io.ReadWriter @@ -104,12 +127,43 @@ type mk2Ser struct { frameLock bool infochan chan *Mk2Info commandMu sync.Mutex + pollPaused atomic.Bool writeAck chan byte + winmonAck chan winmonResponse stateAck chan struct{} ifaceAck chan byte wg sync.WaitGroup } +var _ ProtocolControl = (*mk2Ser)(nil) + +func parseFrameLength(raw byte) (payloadLength byte, hasLEDStatus bool) { + if raw&frameLengthLEDBit != 0 { + return raw &^ frameLengthLEDBit, true + } + return raw, false +} + +func (m *mk2Ser) beginCommand() { + m.commandMu.Lock() + m.pollPaused.Store(true) +} + +func (m *mk2Ser) endCommand() { + m.pollPaused.Store(false) + m.commandMu.Unlock() +} + +func (m *mk2Ser) sendMonitoringCommand(data []byte) { + if m.pollPaused.Load() { + if len(data) > 0 { + mk2log.WithField("command", fmt.Sprintf("0x%02x", data[0])).Debug("Skipping monitoring command during control transaction") + } + return + } + m.sendCommand(data) +} + func NewMk2Connection(dev io.ReadWriter) (Mk2, error) { mk2 := &mk2Ser{} mk2.p = dev @@ -118,6 +172,7 @@ func NewMk2Connection(dev io.ReadWriter) (Mk2, error) { mk2.frameLock = false mk2.scales = make([]scaling, 0, ramVarMaxOffset) mk2.writeAck = make(chan byte, 4) + mk2.winmonAck = make(chan winmonResponse, 32) mk2.stateAck = make(chan struct{}, 1) mk2.ifaceAck = make(chan byte, 1) mk2.setTarget() @@ -132,7 +187,8 @@ func NewMk2Connection(dev io.ReadWriter) (Mk2, error) { // Locks to incoming frame. func (m *mk2Ser) frameLocker() { frame := make([]byte, 256) - var frameLength byte + ledStatus := make([]byte, frameLEDBytes) + var frameLengthRaw byte for { select { case <-m.run: @@ -141,8 +197,14 @@ func (m *mk2Ser) frameLocker() { default: } if m.frameLock { - frameLength = m.readByte() + frameLengthRaw = m.readByte() + frameLength, hasLEDStatus := parseFrameLength(frameLengthRaw) frameLengthOffset := int(frameLength) + 1 + if frameLengthOffset > len(frame) { + m.addError(fmt.Errorf("Read Length Error: frame length %d exceeds buffer", frameLengthOffset)) + m.frameLock = false + continue + } l, err := io.ReadFull(m.p, frame[:frameLengthOffset]) if err != nil { m.addError(fmt.Errorf("Read Error: %v", err)) @@ -151,12 +213,26 @@ func (m *mk2Ser) frameLocker() { m.addError(errors.New("Read Length Error")) m.frameLock = false } else { - m.handleFrame(frameLength, frame[:frameLengthOffset]) + var appendedLED []byte + if hasLEDStatus { + if _, err = io.ReadFull(m.p, ledStatus); err != nil { + m.addError(fmt.Errorf("Read LED status error: %v", err)) + m.frameLock = false + continue + } + appendedLED = ledStatus + } + m.handleFrame(frameLength, frame[:frameLengthOffset], appendedLED) } } else { tmp := m.readByte() + frameLength, hasLEDStatus := parseFrameLength(frameLengthRaw) frameLengthOffset := int(frameLength) if tmp == frameHeader || tmp == infoFrameHeader { + if frameLengthOffset > len(frame) { + frameLengthRaw = tmp + continue + } l, err := io.ReadFull(m.p, frame[:frameLengthOffset]) if err != nil { m.addError(fmt.Errorf("Read Error: %v", err)) @@ -164,13 +240,20 @@ func (m *mk2Ser) frameLocker() { } else if l != frameLengthOffset { m.addError(errors.New("Read Length Error")) } else { + if hasLEDStatus { + if _, err = io.ReadFull(m.p, ledStatus); err != nil { + m.addError(fmt.Errorf("Read LED status error: %v", err)) + frameLengthRaw = tmp + continue + } + } if checkChecksum(frameLength, tmp, frame[:frameLengthOffset]) { m.frameLock = true mk2log.Info("Frame lock acquired") } } } - frameLength = tmp + frameLengthRaw = tmp } } } @@ -192,7 +275,22 @@ func (m *mk2Ser) WriteRAMVar(id uint16, value int16) error { "id": id, "value": value, }).Info("WriteRAMVar requested") - err := m.writeByID(commandWriteRAMVar, commandWriteRAMResponse, id, value) + + m.beginCommand() + defer m.endCommand() + + err := m.writeByIDOnly(commandWriteRAMViaID, commandWriteRAMViaIDResponse, id, value) + if err != nil { + if errors.Is(err, errWriteRejected) { + mk2log.WithError(err).WithField("id", id).Error("WriteRAMVar failed") + return err + } + mk2log.WithFields(logrus.Fields{ + "id": id, + "value": value, + }).WithError(err).Warn("WriteRAMVar by-id command failed, falling back to legacy write sequence") + err = m.writeBySelection(commandWriteRAMVar, commandWriteRAMResponse, id, value) + } if err != nil { mk2log.WithError(err).WithField("id", id).Error("WriteRAMVar failed") return err @@ -206,7 +304,22 @@ func (m *mk2Ser) WriteSetting(id uint16, value int16) error { "id": id, "value": value, }).Info("WriteSetting requested") - err := m.writeByID(commandWriteSetting, commandWriteSettingResponse, id, value) + + m.beginCommand() + defer m.endCommand() + + err := m.writeByIDOnly(commandWriteViaID, commandWriteViaIDResponse, id, value) + if err != nil { + if errors.Is(err, errWriteRejected) { + mk2log.WithError(err).WithField("id", id).Error("WriteSetting failed") + return err + } + mk2log.WithFields(logrus.Fields{ + "id": id, + "value": value, + }).WithError(err).Warn("WriteSetting by-id command failed, falling back to legacy write sequence") + err = m.writeBySelection(commandWriteSetting, commandWriteSettingResponse, id, value) + } if err != nil { mk2log.WithError(err).WithField("id", id).Error("WriteSetting failed") return err @@ -215,6 +328,125 @@ func (m *mk2Ser) WriteSetting(id uint16, value int16) error { return nil } +func (m *mk2Ser) WriteSettingByID(id uint16, value int16) error { + m.beginCommand() + defer m.endCommand() + return m.writeByIDOnly(commandWriteViaID, commandWriteViaIDResponse, id, value) +} + +func (m *mk2Ser) WriteRAMVarByID(id uint16, value int16) error { + m.beginCommand() + defer m.endCommand() + return m.writeByIDOnly(commandWriteRAMViaID, commandWriteRAMViaIDResponse, id, value) +} + +func (m *mk2Ser) GetDeviceState() (DeviceState, error) { + m.beginCommand() + defer m.endCommand() + + m.clearWinmonResponses() + m.sendCommandLocked([]byte{ + winmonFrame, + commandSetState, + 0x00, + }) + resp, err := m.waitForWinmonResponse(commandSetStateResponse) + if err != nil { + return 0, err + } + return decodeDeviceStateResponse(resp) +} + +func (m *mk2Ser) SetDeviceState(state DeviceState) error { + if !validDeviceState(state) { + return fmt.Errorf("invalid device state: 0x%02x", byte(state)) + } + + m.beginCommand() + defer m.endCommand() + + m.clearWinmonResponses() + m.sendCommandLocked([]byte{ + winmonFrame, + commandSetState, + 0x00, + byte(state), + }) + + resp, err := m.waitForWinmonResponse(commandSetStateResponse) + if err != nil { + return err + } + ackState, err := decodeDeviceStateResponse(resp) + if err != nil { + return err + } + if ackState != state { + return fmt.Errorf("device acknowledged state %s (0x%02x), expected %s (0x%02x)", formatDeviceState(ackState), byte(ackState), formatDeviceState(state), byte(state)) + } + return nil +} + +func (m *mk2Ser) ReadRAMVarByID(id uint16) (int16, error) { + m.beginCommand() + defer m.endCommand() + return m.readValueByID(commandReadRAMVar, commandReadRAMResponse, id) +} + +func (m *mk2Ser) ReadSettingByID(id uint16) (int16, error) { + m.beginCommand() + defer m.endCommand() + return m.readValueByID(commandReadSetting, commandReadSettingResponse, id) +} + +func (m *mk2Ser) SelectRAMVar(id uint16) error { + m.beginCommand() + defer m.endCommand() + _, err := m.readValueByID(commandReadRAMVar, commandReadRAMResponse, id) + return err +} + +func (m *mk2Ser) SelectSetting(id uint16) error { + m.beginCommand() + defer m.endCommand() + _, err := m.readValueByID(commandReadSetting, commandReadSettingResponse, id) + return err +} + +func (m *mk2Ser) ReadSelected() (int16, error) { + m.beginCommand() + defer m.endCommand() + + m.clearWinmonResponses() + m.sendCommandLocked([]byte{ + winmonFrame, + commandReadSelected, + }) + resp, err := m.waitForWinmonResponse(commandReadSelectedResponse) + if err != nil { + return 0, err + } + return decodeInt16Response(resp) +} + +func (m *mk2Ser) ReadRAMVarInfo(id uint16) (RAMVarInfo, error) { + m.beginCommand() + defer m.endCommand() + + m.clearWinmonResponses() + m.sendCommandLocked([]byte{ + winmonFrame, + commandGetRAMVarInfo, + byte(id), + byte(id >> 8), + }) + resp, err := m.waitForWinmonResponse(commandGetRAMVarInfoResponse) + if err != nil { + return RAMVarInfo{}, err + } + return decodeRAMVarInfoResponse(id, resp) +} + func (m *mk2Ser) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error { if !validPanelSwitchState(switchState) { return fmt.Errorf("invalid panel switch state: %d", switchState) @@ -225,8 +457,8 @@ func (m *mk2Ser) SetPanelState(switchState PanelSwitchState, currentLimitA *floa return err } - m.commandMu.Lock() - defer m.commandMu.Unlock() + m.beginCommand() + defer m.endCommand() logEntry := mk2log.WithField("switch_state", switchState) if currentLimitA != nil { @@ -259,8 +491,8 @@ func (m *mk2Ser) SetStandby(enabled bool) error { lineState |= interfaceStandbyFlag } - m.commandMu.Lock() - defer m.commandMu.Unlock() + m.beginCommand() + defer m.endCommand() logEntry := mk2log.WithField("standby_enabled", enabled) logEntry.Info("SetStandby requested") @@ -288,6 +520,22 @@ func validPanelSwitchState(switchState PanelSwitchState) bool { } } +func validDeviceState(state DeviceState) bool { + switch state { + case DeviceStateChargerOnly, DeviceStateInverterOnly, DeviceStateOn, DeviceStateOff: + return true + default: + return false + } +} + +func formatDeviceState(state DeviceState) string { + if name, ok := DeviceStateNames[state]; ok { + return name + } + return "unknown" +} + func encodePanelCurrentLimit(currentLimitA *float64) (uint16, error) { if currentLimitA == nil { return panelCurrentLimitUnknown, nil @@ -303,26 +551,29 @@ func encodePanelCurrentLimit(currentLimitA *float64) (uint16, error) { return uint16(raw), nil } -func (m *mk2Ser) writeByID(selectCommand, expectedResponse byte, id uint16, value int16) error { - m.commandMu.Lock() - defer m.commandMu.Unlock() - mk2log.WithFields(logrus.Fields{ - "select_command": fmt.Sprintf("0x%02x", selectCommand), - "expected_response": fmt.Sprintf("0x%02x", expectedResponse), - "id": id, - "value": value, - }).Debug("Issuing write-by-id command") - +func (m *mk2Ser) writeByIDOnly(writeCommand, expectedResponse byte, id uint16, value int16) error { m.clearWriteResponses() + rawValue := uint16(value) + m.sendCommandLocked([]byte{ + winmonFrame, + writeCommand, + byte(id), + byte(id >> 8), + byte(rawValue), + byte(rawValue >> 8), + }) + return m.waitForWriteResponse(expectedResponse) +} +func (m *mk2Ser) writeBySelection(selectCommand, expectedResponse byte, id uint16, value int16) error { + m.clearWriteResponses() + rawValue := uint16(value) m.sendCommandLocked([]byte{ winmonFrame, selectCommand, byte(id), byte(id >> 8), }) - - rawValue := uint16(value) m.sendCommandLocked([]byte{ winmonFrame, commandWriteData, @@ -333,6 +584,77 @@ func (m *mk2Ser) writeByID(selectCommand, expectedResponse byte, id uint16, valu return m.waitForWriteResponse(expectedResponse) } +func (m *mk2Ser) readValueByID(readCommand, expectedResponse byte, id uint16) (int16, error) { + m.clearWinmonResponses() + m.sendCommandLocked([]byte{ + winmonFrame, + readCommand, + byte(id), + byte(id >> 8), + }) + + resp, err := m.waitForWinmonResponse(expectedResponse) + if err != nil { + return 0, err + } + return decodeInt16Response(resp) +} + +func decodeInt16Response(resp winmonResponse) (int16, error) { + if len(resp.data) < 2 { + return 0, fmt.Errorf("invalid response 0x%02x payload length %d", resp.command, len(resp.data)) + } + return int16(uint16(resp.data[0]) | uint16(resp.data[1])<<8), nil +} + +func decodeDeviceStateResponse(resp winmonResponse) (DeviceState, error) { + if len(resp.data) < 1 { + return 0, fmt.Errorf("invalid device state response payload length %d", len(resp.data)) + } + + var raw byte + if len(resp.data) >= 2 { + raw = resp.data[1] + } else { + raw = resp.data[0] + } + state := DeviceState(raw) + if !validDeviceState(state) { + return state, fmt.Errorf("unsupported device state 0x%02x", raw) + } + return state, nil +} + +func decodeRAMVarInfoResponse(id uint16, resp winmonResponse) (RAMVarInfo, error) { + info := RAMVarInfo{ + ID: id, + } + if len(resp.data) < 4 { + return info, nil + } + + scl := int16(resp.data[1])<<8 + int16(resp.data[0]) + var ofs int16 + if len(resp.data) == 4 { + ofs = int16(uint16(resp.data[3])<<8 + uint16(resp.data[2])) + } else { + ofs = int16(uint16(resp.data[4])<<8 + uint16(resp.data[3])) + } + + info.Supported = true + info.Scale = scl + info.Offset = ofs + info.Signed = scl < 0 + scale := int16Abs(scl) + if scale >= 0x4000 { + info.Factor = 1 / (0x8000 - float64(scale)) + } else { + info.Factor = float64(scale) + } + + return info, nil +} + func (m *mk2Ser) clearWriteResponses() { if m.writeAck == nil { m.writeAck = make(chan byte, 4) @@ -362,9 +684,9 @@ func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error { case expectedResponse: return nil case commandUnsupportedResponse: - return errors.New("write command is not supported by this device firmware") + return errCommandUnsupported case commandWriteNotAllowedResponse: - return errors.New("write command rejected by device access level") + return errWriteRejected default: return fmt.Errorf("unexpected write response 0x%02x", response) } @@ -385,6 +707,67 @@ func (m *mk2Ser) pushWriteResponse(response byte) { } } +func (m *mk2Ser) clearWinmonResponses() { + if m.winmonAck == nil { + m.winmonAck = make(chan winmonResponse, 32) + return + } + for { + select { + case <-m.winmonAck: + default: + return + } + } +} + +func (m *mk2Ser) waitForWinmonResponse(expectedResponse byte) (winmonResponse, error) { + if m.winmonAck == nil { + return winmonResponse{}, errors.New("winmon response channel is not initialized") + } + + timeout := time.After(writeResponseTimeout) + for { + select { + case response := <-m.winmonAck: + mk2log.WithFields(logrus.Fields{ + "expected_response": fmt.Sprintf("0x%02x", expectedResponse), + "received_response": fmt.Sprintf("0x%02x", response.command), + "response_len": len(response.data), + }).Debug("Received winmon response") + + switch response.command { + case expectedResponse: + return response, nil + case commandUnsupportedResponse: + return winmonResponse{}, errCommandUnsupported + case commandWriteNotAllowedResponse: + return winmonResponse{}, errWriteRejected + default: + mk2log.WithFields(logrus.Fields{ + "expected_response": fmt.Sprintf("0x%02x", expectedResponse), + "received_response": fmt.Sprintf("0x%02x", response.command), + }).Debug("Ignoring unrelated winmon response while waiting") + } + case <-timeout: + return winmonResponse{}, fmt.Errorf("timed out waiting for winmon response 0x%02x after %s", expectedResponse, writeResponseTimeout) + } + } +} + +func (m *mk2Ser) pushWinmonResponse(command byte, data []byte) { + if m.winmonAck == nil { + return + } + payloadCopy := make([]byte, len(data)) + copy(payloadCopy, data) + + select { + case m.winmonAck <- winmonResponse{command: command, data: payloadCopy}: + default: + } +} + func (m *mk2Ser) clearStateResponses() { if m.stateAck == nil { m.stateAck = make(chan struct{}, 1) @@ -515,41 +898,84 @@ func (m *mk2Ser) updateReport() { } // Checks for valid frame and chooses decoding. -func (m *mk2Ser) handleFrame(l byte, frame []byte) { +func (m *mk2Ser) handleFrame(l byte, frame []byte, appendedLED []byte) { mk2log.Debugf("[handleFrame] frame %#v", frame) + if len(frame) == 0 { + mk2log.Warn("[handleFrame] empty frame") + return + } if checkChecksum(l, frame[0], frame[1:]) { switch frame[0] { case bootupFrameHeader: + if m.pollPaused.Load() { + mk2log.Debug("Skipping setTarget during active control transaction") + return + } m.setTarget() case frameHeader: + if len(frame) < 2 { + mk2log.Warnf("[handleFrame] truncated frameHeader frame: %#v", frame) + return + } switch frame[1] { case interfaceFrame: if len(frame) > 2 { m.pushInterfaceResponse(frame[2]) } case stateFrame: + if len(appendedLED) == frameLEDBytes { + m.setLEDState(appendedLED[0], appendedLED[1]) + } m.pushStateResponse() case vFrame: + if len(frame) < 6 { + mk2log.Warnf("[handleFrame] truncated version frame: %#v", frame) + return + } m.versionDecode(frame[2:]) case winmonFrame: + if len(frame) < 3 { + mk2log.Warnf("[handleFrame] truncated winmon frame: %#v", frame) + return + } + winmonCommand := frame[2] + var winmonData []byte + if len(frame) > 3 { + winmonData = frame[3 : len(frame)-1] + } + m.pushWinmonResponse(winmonCommand, winmonData) switch frame[2] { case commandGetRAMVarInfoResponse: - m.scaleDecode(frame[2:]) + if !m.pollPaused.Load() { + m.scaleDecode(frame[2:]) + } case commandReadRAMResponse: - m.stateDecode(frame[2:]) - case commandWriteRAMResponse, commandWriteSettingResponse, commandUnsupportedResponse, commandWriteNotAllowedResponse: + if !m.pollPaused.Load() { + m.stateDecode(frame[2:]) + } + case commandReadSettingResponse, commandReadSelectedResponse: + // Responses are consumed by synchronous protocol command methods. + case commandSetStateResponse, commandWriteRAMResponse, commandWriteSettingResponse, commandWriteViaIDResponse, commandWriteRAMViaIDResponse, commandUnsupportedResponse, commandWriteNotAllowedResponse: m.pushWriteResponse(frame[2]) default: mk2log.Warnf("[handleFrame] invalid winmonFrame %v", frame[2:]) } case ledFrame: + if len(frame) < 4 { + mk2log.Warnf("[handleFrame] truncated LED frame: %#v", frame) + return + } m.ledDecode(frame[2:]) default: mk2log.Warnf("[handleFrame] invalid frameHeader %v", frame[1]) } case infoFrameHeader: + if len(frame) < 6 { + mk2log.Warnf("[handleFrame] truncated info frame: %#v", frame) + return + } switch frame[5] { case dcInfoFrame: m.dcDecode(frame[1:]) @@ -582,7 +1008,7 @@ func (m *mk2Ser) reqScaleFactor(in byte) { cmd[0] = winmonFrame cmd[1] = commandGetRAMVarInfo cmd[2] = in - m.sendCommand(cmd) + m.sendMonitoringCommand(cmd) } func int16Abs(in int16) uint16 { @@ -648,7 +1074,7 @@ func (m *mk2Ser) versionDecode(frame []byte) { cmd := make([]byte, 2) cmd[0] = infoReqFrame cmd[1] = infoReqAddrDC - m.sendCommand(cmd) + m.sendMonitoringCommand(cmd) } } @@ -704,7 +1130,7 @@ func (m *mk2Ser) dcDecode(frame []byte) { cmd := make([]byte, 2) cmd[0] = infoReqFrame cmd[1] = infoReqAddrACL1 - m.sendCommand(cmd) + m.sendMonitoringCommand(cmd) } // Decodes AC frame. @@ -720,7 +1146,7 @@ func (m *mk2Ser) acDecode(frame []byte) { // Send status request cmd := make([]byte, 1) cmd[0] = ledFrame - m.sendCommand(cmd) + m.sendMonitoringCommand(cmd) } func (m *mk2Ser) calcFreq(data byte, scaleIndex int) float64 { @@ -739,14 +1165,21 @@ func (m *mk2Ser) stateDecode(frame []byte) { // Decode the LED state frame. func (m *mk2Ser) ledDecode(frame []byte) { - - m.info.LEDs = getLEDs(frame[0], frame[1]) + if len(frame) < 2 { + mk2log.Warnf("Skipping LED decode for short frame: %#v", frame) + return + } + m.setLEDState(frame[0], frame[1]) // Send charge state request cmd := make([]byte, 4) cmd[0] = winmonFrame cmd[1] = commandReadRAMVar cmd[2] = ramVarChargeState - m.sendCommand(cmd) + m.sendMonitoringCommand(cmd) +} + +func (m *mk2Ser) setLEDState(ledsOn, ledsBlink byte) { + m.info.LEDs = getLEDs(ledsOn, ledsBlink) } // Adds active LEDs to list. diff --git a/mk2driver/mk2_test.go b/mk2driver/mk2_test.go index 17491fd..4621a57 100644 --- a/mk2driver/mk2_test.go +++ b/mk2driver/mk2_test.go @@ -50,6 +50,31 @@ func NewIOStub(readBuffer []byte) io.ReadWriter { } } +func buildTestFrame(frameType byte, payload ...byte) (byte, []byte) { + length := byte(len(payload) + 1) + sum := int(length) + int(frameType) + for _, b := range payload { + sum += int(b) + } + checksum := byte((-sum) & 0xff) + frame := append([]byte{frameType}, payload...) + frame = append(frame, checksum) + return length, frame +} + +func buildSentCommand(payload ...byte) []byte { + length := byte(len(payload) + 1) + sum := int(length) + int(frameHeader) + out := make([]byte, 0, len(payload)+3) + out = append(out, length, frameHeader) + for _, b := range payload { + sum += int(b) + out = append(out, b) + } + out = append(out, byte((-sum)&0xff)) + return out +} + // Test a know sequence as reference as extracted from Mk2 func TestSync(t *testing.T) { tests := []struct { @@ -320,6 +345,29 @@ func Test_mk2Ser_WriteSetting(t *testing.T) { } go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteViaIDResponse) + }() + + err := m.WriteSetting(0x1234, 1234) + assert.NoError(t, err) + + expected := []byte{ + 0x07, 0xff, 0x57, 0x37, 0x34, 0x12, 0xd2, 0x04, 0x50, + } + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_WriteSetting_FallbackLegacy(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 2), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandUnsupportedResponse) time.Sleep(10 * time.Millisecond) m.pushWriteResponse(commandWriteSettingResponse) }() @@ -328,12 +376,254 @@ func Test_mk2Ser_WriteSetting(t *testing.T) { assert.NoError(t, err) expected := []byte{ + 0x07, 0xff, 0x57, 0x37, 0x34, 0x12, 0xd2, 0x04, 0x50, 0x05, 0xff, 0x57, 0x33, 0x34, 0x12, 0x2c, 0x05, 0xff, 0x57, 0x34, 0xd2, 0x04, 0x9b, } assert.Equal(t, expected, writeBuffer.Bytes()) } +func Test_mk2Ser_WriteRAMVar(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteRAMViaIDResponse) + }() + + err := m.WriteRAMVar(0x000d, 1) + assert.NoError(t, err) + + expected := buildSentCommand(winmonFrame, commandWriteRAMViaID, 0x0d, 0x00, 0x01, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_WriteRAMVar_FallbackLegacy(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 2), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandUnsupportedResponse) + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteRAMResponse) + }() + + err := m.WriteRAMVar(0x000d, 1) + assert.NoError(t, err) + + expected := append([]byte{}, buildSentCommand(winmonFrame, commandWriteRAMViaID, 0x0d, 0x00, 0x01, 0x00)...) + expected = append(expected, buildSentCommand(winmonFrame, commandWriteRAMVar, 0x0d, 0x00)...) + expected = append(expected, buildSentCommand(winmonFrame, commandWriteData, 0x01, 0x00)...) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_WriteSettingByID(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteViaIDResponse) + }() + + err := m.WriteSettingByID(0x1234, 1234) + assert.NoError(t, err) + + expected := buildSentCommand(winmonFrame, commandWriteViaID, 0x34, 0x12, 0xd2, 0x04) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_WriteRAMVarByID(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteRAMViaIDResponse) + }() + + err := m.WriteRAMVarByID(0x000d, 1) + assert.NoError(t, err) + + expected := buildSentCommand(winmonFrame, commandWriteRAMViaID, 0x0d, 0x00, 0x01, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_GetDeviceState(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandSetStateResponse, []byte{0x00, byte(DeviceStateOn)}) + }() + + state, err := m.GetDeviceState() + assert.NoError(t, err) + assert.Equal(t, DeviceStateOn, state) + + expected := buildSentCommand(winmonFrame, commandSetState, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_SetDeviceState(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandSetStateResponse, []byte{0x00, byte(DeviceStateOff)}) + }() + + err := m.SetDeviceState(DeviceStateOff) + assert.NoError(t, err) + + expected := buildSentCommand(winmonFrame, commandSetState, 0x00, byte(DeviceStateOff)) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_ReadRAMVarByID(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandReadRAMResponse, []byte{0x34, 0x12}) + }() + + value, err := m.ReadRAMVarByID(0x0021) + assert.NoError(t, err) + assert.Equal(t, int16(0x1234), value) + + expected := buildSentCommand(winmonFrame, commandReadRAMVar, 0x21, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_ReadSettingByID(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandReadSettingResponse, []byte{0xcd, 0xab}) + }() + + value, err := m.ReadSettingByID(0x0042) + assert.NoError(t, err) + assert.Equal(t, int16(-21555), value) + + expected := buildSentCommand(winmonFrame, commandReadSetting, 0x42, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_ReadSelected(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandReadSelectedResponse, []byte{0x78, 0x56}) + }() + + value, err := m.ReadSelected() + assert.NoError(t, err) + assert.Equal(t, int16(0x5678), value) + + expected := buildSentCommand(winmonFrame, commandReadSelected) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_SelectRAMVar(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandReadRAMResponse, []byte{0x11, 0x22}) + }() + + err := m.SelectRAMVar(0x0022) + assert.NoError(t, err) + + expected := buildSentCommand(winmonFrame, commandReadRAMVar, 0x22, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_SelectSetting(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandReadSettingResponse, []byte{0x11, 0x22}) + }() + + err := m.SelectSetting(0x0023) + assert.NoError(t, err) + + expected := buildSentCommand(winmonFrame, commandReadSetting, 0x23, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_ReadRAMVarInfo(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandGetRAMVarInfoResponse, []byte{0x9c, 0x7f, 0x00, 0x8f, 0x00}) + }() + + info, err := m.ReadRAMVarInfo(0x0001) + assert.NoError(t, err) + assert.True(t, info.Supported) + assert.Equal(t, uint16(0x0001), info.ID) + assert.Equal(t, int16(0x7f9c), info.Scale) + assert.Equal(t, int16(0x008f), info.Offset) + assert.InDelta(t, 0.01, info.Factor, testDelta) + + expected := buildSentCommand(winmonFrame, commandGetRAMVarInfo, 0x01, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + func Test_mk2Ser_WriteRAMVarRejected(t *testing.T) { testIO := NewIOStub(nil) m := &mk2Ser{ @@ -435,3 +725,54 @@ func Test_mk2Ser_SetStandby_Disabled(t *testing.T) { } assert.Equal(t, expected, writeBuffer.Bytes()) } + +func Test_parseFrameLength(t *testing.T) { + tests := []struct { + name string + raw byte + expectedLen byte + expectedFlag bool + }{ + { + name: "normal length", + raw: 0x07, + expectedLen: 0x07, + expectedFlag: false, + }, + { + name: "length with LED flag", + raw: 0x86, + expectedLen: 0x06, + expectedFlag: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + length, hasLEDStatus := parseFrameLength(tt.raw) + assert.Equal(t, tt.expectedLen, length) + assert.Equal(t, tt.expectedFlag, hasLEDStatus) + }) + } +} + +func Test_mk2Ser_handleFrame_StateFrameWithAppendedLED(t *testing.T) { + m := &mk2Ser{ + info: &Mk2Info{}, + stateAck: make(chan struct{}, 1), + } + + length, frame := buildTestFrame(frameHeader, stateFrame) + m.handleFrame(length, frame, []byte{0x03, 0x00}) + + select { + case <-m.stateAck: + default: + t.Fatal("expected state acknowledgement") + } + + assert.NotNil(t, m.info.LEDs) + assert.Equal(t, LedOn, m.info.LEDs[LedMain]) + assert.Equal(t, LedOn, m.info.LEDs[LedAbsorption]) + assert.Equal(t, LedOff, m.info.LEDs[LedBulk]) +} diff --git a/mk2driver/mk2interface.go b/mk2driver/mk2interface.go index ceaef42..1a7640c 100644 --- a/mk2driver/mk2interface.go +++ b/mk2driver/mk2interface.go @@ -102,3 +102,57 @@ type SettingsWriter interface { // When enabled, the inverter is prevented from sleeping while switched off. SetStandby(enabled bool) error } + +type DeviceState byte + +const ( + // DeviceStateChargerOnly enables charging only. + DeviceStateChargerOnly DeviceState = 0x02 + // DeviceStateInverterOnly enables inverter output and disables charging. + DeviceStateInverterOnly DeviceState = 0x03 + // DeviceStateOn enables both inverter and charger. + DeviceStateOn DeviceState = 0x04 + // DeviceStateOff disables inverter and charger. + DeviceStateOff DeviceState = 0x05 +) + +var DeviceStateNames = map[DeviceState]string{ + DeviceStateChargerOnly: "charger_only", + DeviceStateInverterOnly: "inverter_only", + DeviceStateOn: "on", + DeviceStateOff: "off", +} + +type RAMVarInfo struct { + ID uint16 + Scale int16 + Offset int16 + Factor float64 + Signed bool + Supported bool +} + +// ProtocolControl exposes protocol 3.14 command paths for direct MK2 control. +type ProtocolControl interface { + SettingsWriter + // GetDeviceState returns the current VE.Bus state using command 0x0E. + GetDeviceState() (DeviceState, error) + // SetDeviceState sets the VE.Bus state using command 0x0E. + SetDeviceState(state DeviceState) error + // ReadRAMVarByID reads a RAM variable via command 0x30. + ReadRAMVarByID(id uint16) (int16, error) + // ReadSettingByID reads a setting via command 0x31. + ReadSettingByID(id uint16) (int16, error) + // SelectRAMVar selects a RAM variable for follow-up read-selected/write-selected paths. + SelectRAMVar(id uint16) error + // SelectSetting selects a setting for follow-up read-selected/write-selected paths. + SelectSetting(id uint16) error + // ReadSelected reads the currently selected value via command 0x35. + ReadSelected() (int16, error) + // ReadRAMVarInfo reads RAM variable metadata via command 0x36. + ReadRAMVarInfo(id uint16) (RAMVarInfo, error) + // WriteSettingByID writes a setting via command 0x37. + WriteSettingByID(id uint16, value int16) error + // WriteRAMVarByID writes a RAM variable via command 0x38. + WriteRAMVarByID(id uint16, value int16) error +} diff --git a/mk2driver/mockmk2.go b/mk2driver/mockmk2.go index 9716fc9..b937131 100644 --- a/mk2driver/mockmk2.go +++ b/mk2driver/mockmk2.go @@ -8,6 +8,8 @@ type mock struct { c chan *Mk2Info } +var _ ProtocolControl = (*mock)(nil) + func NewMk2Mock() Mk2 { tmp := &mock{ c: make(chan *Mk2Info, 1), @@ -53,6 +55,49 @@ func (m *mock) SetStandby(_ bool) error { return nil } +func (m *mock) GetDeviceState() (DeviceState, error) { + return DeviceStateOn, nil +} + +func (m *mock) SetDeviceState(_ DeviceState) error { + return nil +} + +func (m *mock) ReadRAMVarByID(_ uint16) (int16, error) { + return 0, nil +} + +func (m *mock) ReadSettingByID(_ uint16) (int16, error) { + return 0, nil +} + +func (m *mock) SelectRAMVar(_ uint16) error { + return nil +} + +func (m *mock) SelectSetting(_ uint16) error { + return nil +} + +func (m *mock) ReadSelected() (int16, error) { + return 0, nil +} + +func (m *mock) ReadRAMVarInfo(id uint16) (RAMVarInfo, error) { + return RAMVarInfo{ + ID: id, + Supported: false, + }, nil +} + +func (m *mock) WriteSettingByID(_ uint16, _ int16) error { + return nil +} + +func (m *mock) WriteRAMVarByID(_ uint16, _ int16) error { + return nil +} + func (m *mock) genMockValues() { mult := 1.0 ledState := LedOff