Modernize invertergui: MQTT write support, HA integration, UI updates
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
This commit is contained in:
274
mk2driver/mk2.go
274
mk2driver/mk2.go
@@ -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)
|
||||
|
||||
@@ -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, ¤tLimit)
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user