Modernize invertergui: MQTT write support, HA integration, UI updates
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled

This commit is contained in:
2026-02-19 12:03:52 +11:00
parent 959d1e3c1f
commit a31a0b4829
460 changed files with 19655 additions and 40205 deletions

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"math"
"sync"
"time"
@@ -49,13 +50,25 @@ const (
const (
acL1InfoFrame = 0x08
dcInfoFrame = 0x0C
interfaceFrame = 0x48 // H
setTargetFrame = 0x41
infoReqFrame = 0x46 //F
ledFrame = 0x4C
stateFrame = 0x53 // S
vFrame = 0x56
winmonFrame = 0x57
)
const (
panelStateVariant2Flags = 0x80
interfacePanelDetectFlag = 0x01
interfaceStandbyFlag = 0x02
panelCurrentLimitUnknown = 0x8000
panelCurrentLimitMax = 0x7FFF
)
// info frame types
const (
infoReqAddrDC = 0x00
@@ -65,12 +78,21 @@ const (
// winmon frame commands
const (
commandReadRAMVar = 0x30
commandWriteRAMVar = 0x32
commandWriteSetting = 0x33
commandWriteData = 0x34
commandGetRAMVarInfo = 0x36
commandReadRAMResponse = 0x85
commandGetRAMVarInfoResponse = 0x8E
commandUnsupportedResponse = 0x80
commandReadRAMResponse = 0x85
commandWriteRAMResponse = 0x87
commandWriteSettingResponse = 0x88
commandWriteNotAllowedResponse = 0x9B
commandGetRAMVarInfoResponse = 0x8E
)
const writeResponseTimeout = 3 * time.Second
type mk2Ser struct {
info *Mk2Info
p io.ReadWriter
@@ -79,6 +101,10 @@ type mk2Ser struct {
run chan struct{}
frameLock bool
infochan chan *Mk2Info
commandMu sync.Mutex
writeAck chan byte
stateAck chan struct{}
ifaceAck chan byte
wg sync.WaitGroup
}
@@ -89,6 +115,9 @@ func NewMk2Connection(dev io.ReadWriter) (Mk2, error) {
mk2.scaleCount = 0
mk2.frameLock = false
mk2.scales = make([]scaling, 0, ramVarMaxOffset)
mk2.writeAck = make(chan byte, 4)
mk2.stateAck = make(chan struct{}, 1)
mk2.ifaceAck = make(chan byte, 1)
mk2.setTarget()
mk2.run = make(chan struct{})
mk2.infochan = make(chan *Mk2Info)
@@ -153,6 +182,233 @@ func (m *mk2Ser) C() chan *Mk2Info {
return m.infochan
}
func (m *mk2Ser) WriteRAMVar(id uint16, value int16) error {
return m.writeByID(commandWriteRAMVar, commandWriteRAMResponse, id, value)
}
func (m *mk2Ser) WriteSetting(id uint16, value int16) error {
return m.writeByID(commandWriteSetting, commandWriteSettingResponse, id, value)
}
func (m *mk2Ser) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error {
if !validPanelSwitchState(switchState) {
return fmt.Errorf("invalid panel switch state: %d", switchState)
}
currentLimitRaw, err := encodePanelCurrentLimit(currentLimitA)
if err != nil {
return err
}
m.commandMu.Lock()
defer m.commandMu.Unlock()
m.clearStateResponses()
m.sendCommandLocked([]byte{
stateFrame,
byte(switchState),
byte(currentLimitRaw),
byte(currentLimitRaw >> 8),
0x01,
panelStateVariant2Flags,
})
return m.waitForStateResponse()
}
func (m *mk2Ser) SetStandby(enabled bool) error {
lineState := byte(interfacePanelDetectFlag)
if enabled {
lineState |= interfaceStandbyFlag
}
m.commandMu.Lock()
defer m.commandMu.Unlock()
m.clearInterfaceResponses()
m.sendCommandLocked([]byte{
interfaceFrame,
lineState,
})
return m.waitForInterfaceResponse(enabled)
}
func validPanelSwitchState(switchState PanelSwitchState) bool {
switch switchState {
case PanelSwitchChargerOnly, PanelSwitchInverterOnly, PanelSwitchOn, PanelSwitchOff:
return true
default:
return false
}
}
func encodePanelCurrentLimit(currentLimitA *float64) (uint16, error) {
if currentLimitA == nil {
return panelCurrentLimitUnknown, nil
}
if *currentLimitA < 0 {
return 0, fmt.Errorf("current_limit must be >= 0, got %.3f", *currentLimitA)
}
raw := math.Round(*currentLimitA * 10)
if raw > panelCurrentLimitMax {
return 0, fmt.Errorf("current_limit %.3f A is above MK2 maximum %.1f A", *currentLimitA, panelCurrentLimitMax/10.0)
}
return uint16(raw), nil
}
func (m *mk2Ser) writeByID(selectCommand, expectedResponse byte, id uint16, value int16) error {
m.commandMu.Lock()
defer m.commandMu.Unlock()
m.clearWriteResponses()
m.sendCommandLocked([]byte{
winmonFrame,
selectCommand,
byte(id),
byte(id >> 8),
})
rawValue := uint16(value)
m.sendCommandLocked([]byte{
winmonFrame,
commandWriteData,
byte(rawValue),
byte(rawValue >> 8),
})
return m.waitForWriteResponse(expectedResponse)
}
func (m *mk2Ser) clearWriteResponses() {
if m.writeAck == nil {
m.writeAck = make(chan byte, 4)
return
}
for {
select {
case <-m.writeAck:
default:
return
}
}
}
func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error {
if m.writeAck == nil {
return errors.New("write response channel is not initialized")
}
select {
case response := <-m.writeAck:
switch response {
case expectedResponse:
return nil
case commandUnsupportedResponse:
return errors.New("write command is not supported by this device firmware")
case commandWriteNotAllowedResponse:
return errors.New("write command rejected by device access level")
default:
return fmt.Errorf("unexpected write response 0x%02x", response)
}
case <-time.After(writeResponseTimeout):
return fmt.Errorf("timed out waiting for write response after %s", writeResponseTimeout)
}
}
func (m *mk2Ser) pushWriteResponse(response byte) {
if m.writeAck == nil {
return
}
select {
case m.writeAck <- response:
default:
}
}
func (m *mk2Ser) clearStateResponses() {
if m.stateAck == nil {
m.stateAck = make(chan struct{}, 1)
return
}
for {
select {
case <-m.stateAck:
default:
return
}
}
}
func (m *mk2Ser) waitForStateResponse() error {
if m.stateAck == nil {
return errors.New("panel state response channel is not initialized")
}
select {
case <-m.stateAck:
return nil
case <-time.After(writeResponseTimeout):
return fmt.Errorf("timed out waiting for panel state response after %s", writeResponseTimeout)
}
}
func (m *mk2Ser) pushStateResponse() {
if m.stateAck == nil {
return
}
select {
case m.stateAck <- struct{}{}:
default:
}
}
func (m *mk2Ser) clearInterfaceResponses() {
if m.ifaceAck == nil {
m.ifaceAck = make(chan byte, 1)
return
}
for {
select {
case <-m.ifaceAck:
default:
return
}
}
}
func (m *mk2Ser) waitForInterfaceResponse(expectedStandby bool) error {
if m.ifaceAck == nil {
return errors.New("interface response channel is not initialized")
}
select {
case lineState := <-m.ifaceAck:
standbyEnabled := lineState&interfaceStandbyFlag != 0
if standbyEnabled != expectedStandby {
return fmt.Errorf("unexpected standby line state 0x%02x", lineState)
}
return nil
case <-time.After(writeResponseTimeout):
return fmt.Errorf("timed out waiting for standby response after %s", writeResponseTimeout)
}
}
func (m *mk2Ser) pushInterfaceResponse(lineState byte) {
if m.ifaceAck == nil {
return
}
select {
case m.ifaceAck <- lineState:
default:
}
}
func (m *mk2Ser) readByte() byte {
buffer := make([]byte, 1)
_, err := io.ReadFull(m.p, buffer)
@@ -192,6 +448,12 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte) {
m.setTarget()
case frameHeader:
switch frame[1] {
case interfaceFrame:
if len(frame) > 2 {
m.pushInterfaceResponse(frame[2])
}
case stateFrame:
m.pushStateResponse()
case vFrame:
m.versionDecode(frame[2:])
case winmonFrame:
@@ -200,6 +462,8 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte) {
m.scaleDecode(frame[2:])
case commandReadRAMResponse:
m.stateDecode(frame[2:])
case commandWriteRAMResponse, commandWriteSettingResponse, commandUnsupportedResponse, commandWriteNotAllowedResponse:
m.pushWriteResponse(frame[2])
default:
logrus.Warnf("[handleFrame] invalid winmonFrame %v", frame[2:])
}
@@ -430,6 +694,12 @@ func getLEDs(ledsOn, ledsBlink byte) map[Led]LEDstate {
// Adds header and trailing crc for frame to send.
func (m *mk2Ser) sendCommand(data []byte) {
m.commandMu.Lock()
defer m.commandMu.Unlock()
m.sendCommandLocked(data)
}
func (m *mk2Ser) sendCommandLocked(data []byte) {
l := len(data)
dataOut := make([]byte, l+3)
dataOut[0] = byte(l + 1)

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
@@ -42,6 +43,7 @@ type testIo struct {
}
func NewIOStub(readBuffer []byte) io.ReadWriter {
writeBuffer = bytes.NewBuffer(nil)
return &testIo{
Reader: bytes.NewBuffer(readBuffer),
Writer: writeBuffer,
@@ -309,3 +311,127 @@ func Test_mk2Ser_calcFreq(t *testing.T) {
})
}
}
func Test_mk2Ser_WriteSetting(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
writeAck: make(chan byte, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushWriteResponse(commandWriteSettingResponse)
}()
err := m.WriteSetting(0x1234, 1234)
assert.NoError(t, err)
expected := []byte{
0x05, 0xff, 0x57, 0x33, 0x34, 0x12, 0x2c,
0x05, 0xff, 0x57, 0x34, 0xd2, 0x04, 0x9b,
}
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_WriteRAMVarRejected(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
writeAck: make(chan byte, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushWriteResponse(commandWriteNotAllowedResponse)
}()
err := m.WriteRAMVar(0x000d, 1)
assert.Error(t, err)
assert.ErrorContains(t, err, "rejected")
}
func Test_mk2Ser_SetPanelState(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
stateAck: make(chan struct{}, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushStateResponse()
}()
currentLimit := 16.5
err := m.SetPanelState(PanelSwitchOn, &currentLimit)
assert.NoError(t, err)
expected := []byte{
0x07, 0xff, 0x53, 0x03, 0xa5, 0x00, 0x01, 0x80, 0x7e,
}
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_SetPanelState_SwitchOnly(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
stateAck: make(chan struct{}, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushStateResponse()
}()
err := m.SetPanelState(PanelSwitchOff, nil)
assert.NoError(t, err)
expected := []byte{
0x07, 0xff, 0x53, 0x04, 0x00, 0x80, 0x01, 0x80, 0xa2,
}
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_SetStandby(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
ifaceAck: make(chan byte, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushInterfaceResponse(interfacePanelDetectFlag | interfaceStandbyFlag)
}()
err := m.SetStandby(true)
assert.NoError(t, err)
expected := []byte{
0x03, 0xff, 0x48, 0x03, 0xb3,
}
assert.Equal(t, expected, writeBuffer.Bytes())
}
func Test_mk2Ser_SetStandby_Disabled(t *testing.T) {
testIO := NewIOStub(nil)
m := &mk2Ser{
p: testIO,
ifaceAck: make(chan byte, 1),
}
go func() {
time.Sleep(10 * time.Millisecond)
m.pushInterfaceResponse(interfacePanelDetectFlag)
}()
err := m.SetStandby(false)
assert.NoError(t, err)
expected := []byte{
0x03, 0xff, 0x48, 0x01, 0xb5,
}
assert.Equal(t, expected, writeBuffer.Bytes())
}

View File

@@ -76,3 +76,29 @@ type Mk2 interface {
C() chan *Mk2Info
Close()
}
type PanelSwitchState byte
const (
// PanelSwitchChargerOnly enables charging only.
PanelSwitchChargerOnly PanelSwitchState = 0x01
// PanelSwitchInverterOnly enables inverter output and disables charging.
PanelSwitchInverterOnly PanelSwitchState = 0x02
// PanelSwitchOn enables both inverter and charger.
PanelSwitchOn PanelSwitchState = 0x03
// PanelSwitchOff disables inverter and charger.
PanelSwitchOff PanelSwitchState = 0x04
)
type SettingsWriter interface {
// WriteRAMVar writes a signed 16-bit value to a RAM variable id.
WriteRAMVar(id uint16, value int16) error
// WriteSetting writes a signed 16-bit value to a setting id.
WriteSetting(id uint16, value int16) error
// SetPanelState sends the MK2 "S" command using a virtual panel switch state.
// If currentLimitA is nil, the command does not update the AC current limit.
SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error
// SetStandby configures the remote panel standby line.
// When enabled, the inverter is prevented from sleeping while switched off.
SetStandby(enabled bool) error
}

View File

@@ -37,6 +37,22 @@ func (m *mock) Close() {
}
func (m *mock) WriteRAMVar(_ uint16, _ int16) error {
return nil
}
func (m *mock) WriteSetting(_ uint16, _ int16) error {
return nil
}
func (m *mock) SetPanelState(_ PanelSwitchState, _ *float64) error {
return nil
}
func (m *mock) SetStandby(_ bool) error {
return nil
}
func (m *mock) genMockValues() {
mult := 1.0
ledState := LedOff