Add read-only mode support and enhance logging throughout the application
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-02-19 12:36:52 +11:00
parent bdcb8e6f73
commit 1c15ff5911
8 changed files with 281 additions and 33 deletions

View File

@@ -9,6 +9,7 @@ FROM scratch
# Group ID 20 is dialout, needed for tty read/write access # Group ID 20 is dialout, needed for tty read/write access
USER 3000:20 USER 3000:20
ENV READ_ONLY=false
COPY --from=builder /build/invertergui /bin/ COPY --from=builder /build/invertergui /bin/
ENTRYPOINT [ "/bin/invertergui" ] ENTRYPOINT [ "/bin/invertergui" ]
EXPOSE 8080 EXPOSE 8080

View File

@@ -31,6 +31,7 @@ Usage:
Application Options: Application Options:
--address= The IP/DNS and port of the machine that the application is running on. (default: :8080) [$ADDRESS] --address= The IP/DNS and port of the machine that the application is running on. (default: :8080) [$ADDRESS]
--read_only Disable all write operations and run in monitoring-only mode. [$READ_ONLY]
--data.source= Set the source of data for the inverter gui. "serial", "tcp" or "mock" (default: serial) [$DATA_SOURCE] --data.source= Set the source of data for the inverter gui. "serial", "tcp" or "mock" (default: serial) [$DATA_SOURCE]
--data.host= Host to connect when source is set to tcp. (default: localhost:8139) [$DATA_HOST] --data.host= Host to connect when source is set to tcp. (default: localhost:8139) [$DATA_HOST]
--data.device= TTY device to use when source is set to serial. (default: /dev/ttyUSB0) [$DATA_DEVICE] --data.device= TTY device to use when source is set to serial. (default: /dev/ttyUSB0) [$DATA_DEVICE]
@@ -54,6 +55,26 @@ Help Options:
-h, --help Show this help message -h, --help Show this help message
``` ```
### Read-Only Mode
Set `READ_ONLY=true` (or `--read_only`) to disable all write operations.
When read-only mode is enabled, the app still monitors and publishes telemetry, but it will not send commands to the Victron device.
This affects:
- MQTT command handling (`--mqtt.command_topic` commands are ignored)
- Web UI control actions (`POST /api/remote-panel/state` and `POST /api/remote-panel/standby`)
Example `docker-compose.yml` snippet:
```yaml
services:
invertergui:
image: registry.coadcorp.com/nathan/invertergui:latest
environment:
READ_ONLY: "true"
```
## Port 8080 ## Port 8080
The default HTTP server port is hosted on port 8080. This exposes the HTTP server that hosts the: The default HTTP server port is hosted on port 8080. This exposes the HTTP server that hosts the:
@@ -315,6 +336,12 @@ The MQTT client will publish updates to the given broker at the set topic.
--mqtt.password-file= Path to a file containing the MQTT password [$MQTT_PASSWORD_FILE] --mqtt.password-file= Path to a file containing the MQTT password [$MQTT_PASSWORD_FILE]
``` ```
Related global option:
```bash
--read_only Disable all write operations and run in monitoring-only mode. [$READ_ONLY]
```
The MQTT client can be enabled by setting the environment variable `MQTT_ENABLED=true` or flag `--mqtt.enabled`. The MQTT client can be enabled by setting the environment variable `MQTT_ENABLED=true` or flag `--mqtt.enabled`.
All MQTT configuration can be done via flags or as environment variables. All MQTT configuration can be done via flags or as environment variables.
The URI for the broker can be configured format should be `scheme://host:port`, where "scheme" is one of "tcp", "ssl", or "ws". The URI for the broker can be configured format should be `scheme://host:port`, where "scheme" is one of "tcp", "ssl", or "ws".

View File

@@ -9,8 +9,9 @@ import (
) )
type config struct { type config struct {
Address string `long:"address" env:"ADDRESS" default:":8080" description:"The IP/DNS and port of the machine that the application is running on."` Address string `long:"address" env:"ADDRESS" default:":8080" description:"The IP/DNS and port of the machine that the application is running on."`
Data struct { ReadOnly bool `long:"read_only" env:"READ_ONLY" description:"Disable all write operations and run in monitoring-only mode."`
Data struct {
Source string `long:"data.source" env:"DATA_SOURCE" default:"serial" description:"Set the source of data for the inverter gui. \"serial\", \"tcp\" or \"mock\""` Source string `long:"data.source" env:"DATA_SOURCE" default:"serial" description:"Set the source of data for the inverter gui. \"serial\", \"tcp\" or \"mock\""`
Host string `long:"data.host" env:"DATA_HOST" default:"localhost:8139" description:"Host to connect when source is set to tcp."` Host string `long:"data.host" env:"DATA_HOST" default:"localhost:8139" description:"Host to connect when source is set to tcp."`
Device string `long:"data.device" env:"DATA_DEVICE" default:"/dev/ttyUSB0" description:"TTY device to use when source is set to serial."` Device string `long:"data.device" env:"DATA_DEVICE" default:"/dev/ttyUSB0" description:"TTY device to use when source is set to serial."`

View File

@@ -55,6 +55,7 @@ var log = logrus.WithField("ctx", "inverter-gui")
func main() { func main() {
conf, err := parseConfig() conf, err := parseConfig()
if err != nil { if err != nil {
log.WithError(err).Error("Could not parse configuration")
os.Exit(1) os.Exit(1)
} }
log.Info("Starting invertergui") log.Info("Starting invertergui")
@@ -63,16 +64,33 @@ func main() {
log.Fatalf("Could not parse log level: %v", err) log.Fatalf("Could not parse log level: %v", err)
} }
logrus.SetLevel(logLevel) logrus.SetLevel(logLevel)
log.WithFields(logrus.Fields{
"loglevel": conf.Loglevel,
"address": conf.Address,
"read_only": conf.ReadOnly,
"data_source": conf.Data.Source,
"data_host": conf.Data.Host,
"data_device": conf.Data.Device,
"cli_enabled": conf.Cli.Enabled,
"mqtt_enabled": conf.MQTT.Enabled,
"mqtt_broker": conf.MQTT.Broker,
"mqtt_topic": conf.MQTT.Topic,
"mqtt_command_topic": conf.MQTT.CommandTopic,
"mqtt_status_topic": conf.MQTT.StatusTopic,
"mqtt_ha_enabled": conf.MQTT.HA.Enabled,
}).Info("Configuration loaded")
mk2, err := getMk2Device(conf.Data.Source, conf.Data.Host, conf.Data.Device) mk2, err := getMk2Device(conf.Data.Source, conf.Data.Host, conf.Data.Device)
if err != nil { if err != nil {
log.Fatalf("Could not open data source: %v", err) log.Fatalf("Could not open data source: %v", err)
} }
defer mk2.Close() defer mk2.Close()
log.Info("MK2 device connection established")
core := mk2core.NewCore(mk2) core := mk2core.NewCore(mk2)
if conf.Cli.Enabled { if conf.Cli.Enabled {
log.Info("CLI plugin enabled")
cli.NewCli(core.NewSubscription()) cli.NewCli(core.NewSubscription())
} }
@@ -80,21 +98,35 @@ func main() {
var writer mk2driver.SettingsWriter var writer mk2driver.SettingsWriter
if w, ok := mk2.(mk2driver.SettingsWriter); ok { if w, ok := mk2.(mk2driver.SettingsWriter); ok {
writer = w writer = w
log.Info("MK2 data source supports settings writes")
} else {
log.Warn("MK2 data source does not support settings writes")
}
if conf.ReadOnly {
if writer != nil {
log.Warn("READ_ONLY enabled; disabling all write operations")
} else {
log.Info("READ_ONLY enabled")
}
writer = nil
} }
gui := webui.NewWebGui(core.NewSubscription(), writer) gui := webui.NewWebGui(core.NewSubscription(), writer)
http.Handle("/", static.New()) http.Handle("/", static.New())
http.Handle("/ws", http.HandlerFunc(gui.ServeHub)) http.Handle("/ws", http.HandlerFunc(gui.ServeHub))
http.Handle("/api/remote-panel/state", http.HandlerFunc(gui.ServeRemotePanelState)) http.Handle("/api/remote-panel/state", http.HandlerFunc(gui.ServeRemotePanelState))
http.Handle("/api/remote-panel/standby", http.HandlerFunc(gui.ServeRemotePanelStandby)) http.Handle("/api/remote-panel/standby", http.HandlerFunc(gui.ServeRemotePanelStandby))
log.Info("Web UI routes registered")
// Munin // Munin
mu := munin.NewMunin(core.NewSubscription()) mu := munin.NewMunin(core.NewSubscription())
http.Handle("/munin", http.HandlerFunc(mu.ServeMuninHTTP)) http.Handle("/munin", http.HandlerFunc(mu.ServeMuninHTTP))
http.Handle("/muninconfig", http.HandlerFunc(mu.ServeMuninConfigHTTP)) http.Handle("/muninconfig", http.HandlerFunc(mu.ServeMuninConfigHTTP))
log.Info("Munin routes registered")
// Prometheus // Prometheus
prometheus.NewPrometheus(core.NewSubscription()) prometheus.NewPrometheus(core.NewSubscription())
http.Handle("/metrics", promhttp.Handler()) http.Handle("/metrics", promhttp.Handler())
log.Info("Prometheus route registered")
// MQTT // MQTT
if conf.MQTT.Enabled { if conf.MQTT.Enabled {
@@ -119,6 +151,7 @@ func main() {
if err := mqttclient.New(core.NewSubscription(), writer, mqttConf); err != nil { if err := mqttclient.New(core.NewSubscription(), writer, mqttConf); err != nil {
log.Fatalf("Could not setup MQTT client: %v", err) log.Fatalf("Could not setup MQTT client: %v", err)
} }
log.Info("MQTT client initialized")
} }
log.Infof("Invertergui web server starting on: %v", conf.Address) log.Infof("Invertergui web server starting on: %v", conf.Address)
@@ -134,12 +167,14 @@ func getMk2Device(source, ip, dev string) (mk2driver.Mk2, error) {
switch source { switch source {
case "serial": case "serial":
log.WithField("device", dev).Info("Opening serial MK2 source")
serialConfig := &serial.Config{Name: dev, Baud: 2400} serialConfig := &serial.Config{Name: dev, Baud: 2400}
p, err = serial.OpenPort(serialConfig) p, err = serial.OpenPort(serialConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
case "tcp": case "tcp":
log.WithField("host", ip).Info("Opening TCP MK2 source")
tcpAddr, err = net.ResolveTCPAddr("tcp", ip) tcpAddr, err = net.ResolveTCPAddr("tcp", ip)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -149,6 +184,7 @@ func getMk2Device(source, ip, dev string) (mk2driver.Mk2, error) {
return nil, err return nil, err
} }
case "mock": case "mock":
log.Info("Using mock MK2 data source")
return mk2driver.NewMk2Mock(), nil return mk2driver.NewMk2Mock(), nil
default: default:
return nil, fmt.Errorf("Invalid source selection: %v\nUse \"serial\", \"tcp\" or \"mock\"", source) return nil, fmt.Errorf("Invalid source selection: %v\nUse \"serial\", \"tcp\" or \"mock\"", source)
@@ -158,6 +194,7 @@ func getMk2Device(source, ip, dev string) (mk2driver.Mk2, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
log.WithField("source", source).Info("MK2 connection ready")
return mk2, nil return mk2, nil
} }

View File

@@ -2,8 +2,11 @@ package mk2core
import ( import (
"git.coadcorp.com/nathan/invertergui/mk2driver" "git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/sirupsen/logrus"
) )
var log = logrus.WithField("ctx", "inverter-gui-core")
type Core struct { type Core struct {
mk2driver.Mk2 mk2driver.Mk2
plugins map[*subscription]bool plugins map[*subscription]bool
@@ -16,6 +19,7 @@ func NewCore(m mk2driver.Mk2) *Core {
register: make(chan *subscription, 255), register: make(chan *subscription, 255),
plugins: map[*subscription]bool{}, plugins: map[*subscription]bool{},
} }
log.Info("Core initialized")
go core.run() go core.run()
return core return core
} }
@@ -25,6 +29,7 @@ func (c *Core) NewSubscription() mk2driver.Mk2 {
send: make(chan *mk2driver.Mk2Info), send: make(chan *mk2driver.Mk2Info),
} }
c.register <- sub c.register <- sub
log.Debug("New plugin subscription registered")
return sub return sub
} }
@@ -33,11 +38,13 @@ func (c *Core) run() {
select { select {
case r := <-c.register: case r := <-c.register:
c.plugins[r] = true c.plugins[r] = true
log.WithField("subscribers", len(c.plugins)).Debug("Subscription added")
case e := <-c.C(): case e := <-c.C():
for plugin := range c.plugins { for plugin := range c.plugins {
select { select {
case plugin.send <- e: case plugin.send <- e:
default: default:
log.WithField("subscribers", len(c.plugins)).Warn("Dropping update for a slow subscriber")
} }
} }
} }

View File

@@ -11,6 +11,8 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
var mk2log = logrus.WithField("ctx", "inverter-gui-mk2driver")
type scaling struct { type scaling struct {
scale float64 scale float64
offset float64 offset float64
@@ -122,6 +124,7 @@ func NewMk2Connection(dev io.ReadWriter) (Mk2, error) {
mk2.run = make(chan struct{}) mk2.run = make(chan struct{})
mk2.infochan = make(chan *Mk2Info) mk2.infochan = make(chan *Mk2Info)
mk2.wg.Add(1) mk2.wg.Add(1)
mk2log.Info("MK2 connection initialized")
go mk2.frameLocker() go mk2.frameLocker()
return mk2, nil return mk2, nil
} }
@@ -163,7 +166,7 @@ func (m *mk2Ser) frameLocker() {
} else { } else {
if checkChecksum(frameLength, tmp, frame[:frameLengthOffset]) { if checkChecksum(frameLength, tmp, frame[:frameLengthOffset]) {
m.frameLock = true m.frameLock = true
logrus.Info("Locked") mk2log.Info("Frame lock acquired")
} }
} }
} }
@@ -174,8 +177,10 @@ func (m *mk2Ser) frameLocker() {
// Close Mk2 // Close Mk2
func (m *mk2Ser) Close() { func (m *mk2Ser) Close() {
mk2log.Info("Closing MK2 connection")
close(m.run) close(m.run)
m.wg.Wait() m.wg.Wait()
mk2log.Info("MK2 connection closed")
} }
func (m *mk2Ser) C() chan *Mk2Info { func (m *mk2Ser) C() chan *Mk2Info {
@@ -183,11 +188,31 @@ func (m *mk2Ser) C() chan *Mk2Info {
} }
func (m *mk2Ser) WriteRAMVar(id uint16, value int16) error { func (m *mk2Ser) WriteRAMVar(id uint16, value int16) error {
return m.writeByID(commandWriteRAMVar, commandWriteRAMResponse, id, value) mk2log.WithFields(logrus.Fields{
"id": id,
"value": value,
}).Info("WriteRAMVar requested")
err := m.writeByID(commandWriteRAMVar, commandWriteRAMResponse, id, value)
if err != nil {
mk2log.WithError(err).WithField("id", id).Error("WriteRAMVar failed")
return err
}
mk2log.WithField("id", id).Info("WriteRAMVar applied")
return nil
} }
func (m *mk2Ser) WriteSetting(id uint16, value int16) error { func (m *mk2Ser) WriteSetting(id uint16, value int16) error {
return m.writeByID(commandWriteSetting, commandWriteSettingResponse, id, value) mk2log.WithFields(logrus.Fields{
"id": id,
"value": value,
}).Info("WriteSetting requested")
err := m.writeByID(commandWriteSetting, commandWriteSettingResponse, id, value)
if err != nil {
mk2log.WithError(err).WithField("id", id).Error("WriteSetting failed")
return err
}
mk2log.WithField("id", id).Info("WriteSetting applied")
return nil
} }
func (m *mk2Ser) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error { func (m *mk2Ser) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error {
@@ -203,6 +228,12 @@ func (m *mk2Ser) SetPanelState(switchState PanelSwitchState, currentLimitA *floa
m.commandMu.Lock() m.commandMu.Lock()
defer m.commandMu.Unlock() defer m.commandMu.Unlock()
logEntry := mk2log.WithField("switch_state", switchState)
if currentLimitA != nil {
logEntry = logEntry.WithField("current_limit_a", *currentLimitA)
}
logEntry.Info("SetPanelState requested")
m.clearStateResponses() m.clearStateResponses()
m.sendCommandLocked([]byte{ m.sendCommandLocked([]byte{
stateFrame, stateFrame,
@@ -213,7 +244,13 @@ func (m *mk2Ser) SetPanelState(switchState PanelSwitchState, currentLimitA *floa
panelStateVariant2Flags, panelStateVariant2Flags,
}) })
return m.waitForStateResponse() err = m.waitForStateResponse()
if err != nil {
logEntry.WithError(err).Error("SetPanelState failed")
return err
}
logEntry.Info("SetPanelState acknowledged")
return nil
} }
func (m *mk2Ser) SetStandby(enabled bool) error { func (m *mk2Ser) SetStandby(enabled bool) error {
@@ -224,6 +261,8 @@ func (m *mk2Ser) SetStandby(enabled bool) error {
m.commandMu.Lock() m.commandMu.Lock()
defer m.commandMu.Unlock() defer m.commandMu.Unlock()
logEntry := mk2log.WithField("standby_enabled", enabled)
logEntry.Info("SetStandby requested")
m.clearInterfaceResponses() m.clearInterfaceResponses()
m.sendCommandLocked([]byte{ m.sendCommandLocked([]byte{
@@ -231,7 +270,13 @@ func (m *mk2Ser) SetStandby(enabled bool) error {
lineState, lineState,
}) })
return m.waitForInterfaceResponse(enabled) err := m.waitForInterfaceResponse(enabled)
if err != nil {
logEntry.WithError(err).Error("SetStandby failed")
return err
}
logEntry.Info("SetStandby acknowledged")
return nil
} }
func validPanelSwitchState(switchState PanelSwitchState) bool { func validPanelSwitchState(switchState PanelSwitchState) bool {
@@ -261,6 +306,12 @@ func encodePanelCurrentLimit(currentLimitA *float64) (uint16, error) {
func (m *mk2Ser) writeByID(selectCommand, expectedResponse byte, id uint16, value int16) error { func (m *mk2Ser) writeByID(selectCommand, expectedResponse byte, id uint16, value int16) error {
m.commandMu.Lock() m.commandMu.Lock()
defer m.commandMu.Unlock() 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")
m.clearWriteResponses() m.clearWriteResponses()
@@ -303,6 +354,10 @@ func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error {
select { select {
case response := <-m.writeAck: case response := <-m.writeAck:
mk2log.WithFields(logrus.Fields{
"expected_response": fmt.Sprintf("0x%02x", expectedResponse),
"received_response": fmt.Sprintf("0x%02x", response),
}).Debug("Received write acknowledgement")
switch response { switch response {
case expectedResponse: case expectedResponse:
return nil return nil
@@ -314,6 +369,7 @@ func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error {
return fmt.Errorf("unexpected write response 0x%02x", response) return fmt.Errorf("unexpected write response 0x%02x", response)
} }
case <-time.After(writeResponseTimeout): case <-time.After(writeResponseTimeout):
mk2log.WithField("expected_response", fmt.Sprintf("0x%02x", expectedResponse)).Error("Timed out waiting for write acknowledgement")
return fmt.Errorf("timed out waiting for write response after %s", writeResponseTimeout) return fmt.Errorf("timed out waiting for write response after %s", writeResponseTimeout)
} }
} }
@@ -350,8 +406,10 @@ func (m *mk2Ser) waitForStateResponse() error {
select { select {
case <-m.stateAck: case <-m.stateAck:
mk2log.Debug("Received panel state acknowledgement")
return nil return nil
case <-time.After(writeResponseTimeout): case <-time.After(writeResponseTimeout):
mk2log.Error("Timed out waiting for panel state acknowledgement")
return fmt.Errorf("timed out waiting for panel state response after %s", writeResponseTimeout) return fmt.Errorf("timed out waiting for panel state response after %s", writeResponseTimeout)
} }
} }
@@ -389,11 +447,17 @@ func (m *mk2Ser) waitForInterfaceResponse(expectedStandby bool) error {
select { select {
case lineState := <-m.ifaceAck: case lineState := <-m.ifaceAck:
standbyEnabled := lineState&interfaceStandbyFlag != 0 standbyEnabled := lineState&interfaceStandbyFlag != 0
mk2log.WithFields(logrus.Fields{
"line_state": fmt.Sprintf("0x%02x", lineState),
"expected_standby": expectedStandby,
"actual_standby": standbyEnabled,
}).Debug("Received standby interface acknowledgement")
if standbyEnabled != expectedStandby { if standbyEnabled != expectedStandby {
return fmt.Errorf("unexpected standby line state 0x%02x", lineState) return fmt.Errorf("unexpected standby line state 0x%02x", lineState)
} }
return nil return nil
case <-time.After(writeResponseTimeout): case <-time.After(writeResponseTimeout):
mk2log.WithField("expected_standby", expectedStandby).Error("Timed out waiting for standby acknowledgement")
return fmt.Errorf("timed out waiting for standby response after %s", writeResponseTimeout) return fmt.Errorf("timed out waiting for standby response after %s", writeResponseTimeout)
} }
} }
@@ -421,7 +485,7 @@ func (m *mk2Ser) readByte() byte {
// Adds error to error slice. // Adds error to error slice.
func (m *mk2Ser) addError(err error) { func (m *mk2Ser) addError(err error) {
logrus.Errorf("Mk2 serial slice error: %q", err.Error()) mk2log.Errorf("Mk2 serial slice error: %q", err.Error())
if m.info.Errors == nil { if m.info.Errors == nil {
m.info.Errors = make([]error, 0) m.info.Errors = make([]error, 0)
} }
@@ -432,16 +496,27 @@ func (m *mk2Ser) addError(err error) {
// Updates report. // Updates report.
func (m *mk2Ser) updateReport() { func (m *mk2Ser) updateReport() {
m.info.Timestamp = time.Now() m.info.Timestamp = time.Now()
mk2log.WithFields(logrus.Fields{
"in_voltage": m.info.InVoltage,
"in_current": m.info.InCurrent,
"out_voltage": m.info.OutVoltage,
"out_current": m.info.OutCurrent,
"bat_voltage": m.info.BatVoltage,
"bat_current": m.info.BatCurrent,
"charge_state": m.info.ChargeState,
"valid": m.info.Valid,
}).Debug("Publishing MK2 status update")
select { select {
case m.infochan <- m.info: case m.infochan <- m.info:
default: default:
mk2log.Warn("Dropping MK2 status update; consumer channel is full")
} }
m.info = &Mk2Info{} m.info = &Mk2Info{}
} }
// Checks for valid frame and chooses decoding. // Checks for valid frame and chooses decoding.
func (m *mk2Ser) handleFrame(l byte, frame []byte) { func (m *mk2Ser) handleFrame(l byte, frame []byte) {
logrus.Debugf("[handleFrame] frame %#v", frame) mk2log.Debugf("[handleFrame] frame %#v", frame)
if checkChecksum(l, frame[0], frame[1:]) { if checkChecksum(l, frame[0], frame[1:]) {
switch frame[0] { switch frame[0] {
case bootupFrameHeader: case bootupFrameHeader:
@@ -465,13 +540,13 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte) {
case commandWriteRAMResponse, commandWriteSettingResponse, commandUnsupportedResponse, commandWriteNotAllowedResponse: case commandWriteRAMResponse, commandWriteSettingResponse, commandUnsupportedResponse, commandWriteNotAllowedResponse:
m.pushWriteResponse(frame[2]) m.pushWriteResponse(frame[2])
default: default:
logrus.Warnf("[handleFrame] invalid winmonFrame %v", frame[2:]) mk2log.Warnf("[handleFrame] invalid winmonFrame %v", frame[2:])
} }
case ledFrame: case ledFrame:
m.ledDecode(frame[2:]) m.ledDecode(frame[2:])
default: default:
logrus.Warnf("[handleFrame] invalid frameHeader %v", frame[1]) mk2log.Warnf("[handleFrame] invalid frameHeader %v", frame[1])
} }
case infoFrameHeader: case infoFrameHeader:
@@ -481,13 +556,13 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte) {
case acL1InfoFrame: case acL1InfoFrame:
m.acDecode(frame[1:]) m.acDecode(frame[1:])
default: default:
logrus.Warnf("[handleFrame] invalid infoFrameHeader %v", frame[5]) mk2log.Warnf("[handleFrame] invalid infoFrameHeader %v", frame[5])
} }
default: default:
logrus.Warnf("[handleFrame] Invalid frame %v", frame[0]) mk2log.Warnf("[handleFrame] Invalid frame %v", frame[0])
} }
} else { } else {
logrus.Errorf("[handleFrame] Invalid incoming frame checksum: %x", frame) mk2log.Errorf("[handleFrame] Invalid incoming frame checksum: %x", frame)
m.frameLock = false m.frameLock = false
} }
} }
@@ -520,10 +595,10 @@ func int16Abs(in int16) uint16 {
// Decode the scale factor frame. // Decode the scale factor frame.
func (m *mk2Ser) scaleDecode(frame []byte) { func (m *mk2Ser) scaleDecode(frame []byte) {
tmp := scaling{} tmp := scaling{}
logrus.Debugf("Scale frame(%d): 0x%x", len(frame), frame) mk2log.Debugf("Scale frame(%d): 0x%x", len(frame), frame)
if len(frame) < 6 { if len(frame) < 6 {
tmp.supported = false tmp.supported = false
logrus.Warnf("Skiping scaling factors for: %d", m.scaleCount) mk2log.Warnf("Skiping scaling factors for: %d", m.scaleCount)
} else { } else {
tmp.supported = true tmp.supported = true
var scl int16 var scl int16
@@ -546,19 +621,19 @@ func (m *mk2Ser) scaleDecode(frame []byte) {
tmp.scale = float64(scale) tmp.scale = float64(scale)
} }
} }
logrus.Debugf("scalecount %v: %#v \n", m.scaleCount, tmp) mk2log.Debugf("scalecount %v: %#v \n", m.scaleCount, tmp)
m.scales = append(m.scales, tmp) m.scales = append(m.scales, tmp)
m.scaleCount++ m.scaleCount++
if m.scaleCount < ramVarMaxOffset { if m.scaleCount < ramVarMaxOffset {
m.reqScaleFactor(byte(m.scaleCount)) m.reqScaleFactor(byte(m.scaleCount))
} else { } else {
logrus.Info("Monitoring starting.") mk2log.Info("Monitoring starting.")
} }
} }
// Decode the version number // Decode the version number
func (m *mk2Ser) versionDecode(frame []byte) { func (m *mk2Ser) versionDecode(frame []byte) {
logrus.Debugf("versiondecode %v", frame) mk2log.Debugf("versiondecode %v", frame)
m.info.Version = 0 m.info.Version = 0
m.info.Valid = true m.info.Valid = true
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
@@ -566,7 +641,7 @@ func (m *mk2Ser) versionDecode(frame []byte) {
} }
if m.scaleCount < ramVarMaxOffset { if m.scaleCount < ramVarMaxOffset {
logrus.Info("Get scaling factors.") mk2log.WithField("version", m.info.Version).Info("Get scaling factors")
m.reqScaleFactor(byte(m.scaleCount)) m.reqScaleFactor(byte(m.scaleCount))
} else { } else {
// Send DC status request // Send DC status request
@@ -623,7 +698,7 @@ func (m *mk2Ser) dcDecode(frame []byte) {
m.info.BatCurrent = usedC - chargeC m.info.BatCurrent = usedC - chargeC
m.info.OutFrequency = m.calcFreq(frame[13], ramVarInverterPeriod) m.info.OutFrequency = m.calcFreq(frame[13], ramVarInverterPeriod)
logrus.Debugf("dcDecode %#v", m.info) mk2log.Debugf("dcDecode %#v", m.info)
// Send L1 status request // Send L1 status request
cmd := make([]byte, 2) cmd := make([]byte, 2)
@@ -640,7 +715,7 @@ func (m *mk2Ser) acDecode(frame []byte) {
m.info.OutCurrent = m.applyScale(getSigned(frame[11:13]), ramVarIInverter) m.info.OutCurrent = m.applyScale(getSigned(frame[11:13]), ramVarIInverter)
m.info.InFrequency = m.calcFreq(frame[13], ramVarMainPeriod) m.info.InFrequency = m.calcFreq(frame[13], ramVarMainPeriod)
logrus.Debugf("acDecode %#v", m.info) mk2log.Debugf("acDecode %#v", m.info)
// Send status request // Send status request
cmd := make([]byte, 1) cmd := make([]byte, 1)
@@ -658,7 +733,7 @@ func (m *mk2Ser) calcFreq(data byte, scaleIndex int) float64 {
// Decode charge state of battery. // Decode charge state of battery.
func (m *mk2Ser) stateDecode(frame []byte) { func (m *mk2Ser) stateDecode(frame []byte) {
m.info.ChargeState = m.applyScaleAndSign(frame[1:3], ramVarChargeState) m.info.ChargeState = m.applyScaleAndSign(frame[1:3], ramVarChargeState)
logrus.Debugf("battery state decode %#v", m.info) mk2log.Debugf("battery state decode %#v", m.info)
m.updateReport() m.updateReport()
} }
@@ -711,9 +786,10 @@ func (m *mk2Ser) sendCommandLocked(data []byte) {
} }
dataOut[l+2] = cr dataOut[l+2] = cr
logrus.Debugf("sendCommand %#v", dataOut) mk2log.Debugf("sendCommand %#v", dataOut)
_, err := m.p.Write(dataOut) _, err := m.p.Write(dataOut)
if err != nil { if err != nil {
mk2log.WithError(err).Error("Failed to send MK2 command")
m.addError(fmt.Errorf("Write error: %v", err)) m.addError(fmt.Errorf("Write error: %v", err))
} }
} }

View File

@@ -99,6 +99,17 @@ type panelStateCache struct {
// New creates an MQTT client that publishes MK2 updates and optionally handles setting write commands. // New creates an MQTT client that publishes MK2 updates and optionally handles setting write commands.
func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) error { func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) error {
log.WithFields(logrus.Fields{
"broker": config.Broker,
"client_id": config.ClientID,
"topic": config.Topic,
"command_topic": config.CommandTopic,
"status_topic": config.StatusTopic,
"ha_enabled": config.HomeAssistant.Enabled,
"ha_node_id": config.HomeAssistant.NodeID,
"ha_device_name": config.HomeAssistant.DeviceName,
}).Info("Initializing MQTT client")
c := mqtt.NewClient(getOpts(config)) c := mqtt.NewClient(getOpts(config))
if token := c.Connect(); token.Wait() && token.Error() != nil { if token := c.Connect(); token.Wait() && token.Error() != nil {
return token.Error() return token.Error()
@@ -135,14 +146,18 @@ func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) erro
go func() { go func() {
for e := range mk2.C() { for e := range mk2.C() {
if e == nil || !e.Valid { if e == nil || !e.Valid {
log.Debug("Skipping invalid/nil MK2 event for MQTT publish")
continue continue
} }
if err := publishJSON(c, config.Topic, e, 0, false); err != nil { if err := publishJSON(c, config.Topic, e, 0, false); err != nil {
log.Errorf("Could not publish update to MQTT topic %q: %v", config.Topic, err) log.Errorf("Could not publish update to MQTT topic %q: %v", config.Topic, err)
} else {
log.WithField("topic", config.Topic).Debug("Published MK2 update to MQTT")
} }
} }
}() }()
log.Info("MQTT client setup complete")
return nil return nil
} }
@@ -152,9 +167,14 @@ func subscribeHAPanelModeState(client mqtt.Client, config Config, cache *panelSt
} }
stateTopic := haPanelSwitchStateTopic(config) stateTopic := haPanelSwitchStateTopic(config)
log.WithField("topic", stateTopic).Info("Subscribing to Home Assistant mode state topic for panel cache")
t := client.Subscribe(stateTopic, 1, func(_ mqtt.Client, msg mqtt.Message) { t := client.Subscribe(stateTopic, 1, func(_ mqtt.Client, msg mqtt.Message) {
switchState, switchName, err := normalizePanelSwitch(string(msg.Payload())) switchState, switchName, err := normalizePanelSwitch(string(msg.Payload()))
if err != nil { if err != nil {
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"payload": string(msg.Payload()),
}).WithError(err).Warn("Ignoring invalid Home Assistant mode state payload")
return return
} }
cache.remember(writeCommand{ cache.remember(writeCommand{
@@ -163,6 +183,10 @@ func subscribeHAPanelModeState(client mqtt.Client, config Config, cache *panelSt
SwitchState: switchState, SwitchState: switchState,
SwitchName: switchName, SwitchName: switchName,
}) })
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"switch_name": switchName,
}).Debug("Updated panel mode cache from Home Assistant state topic")
}) })
t.Wait() t.Wait()
return t.Error() return t.Error()
@@ -174,6 +198,11 @@ func commandHandler(client mqtt.Client, writer mk2driver.SettingsWriter, config
} }
return func(_ mqtt.Client, msg mqtt.Message) { return func(_ mqtt.Client, msg mqtt.Message) {
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"payload": string(msg.Payload()),
}).Debug("Received MQTT command message")
cmd, err := decodeWriteCommand(msg.Payload()) cmd, err := decodeWriteCommand(msg.Payload())
if err != nil { if err != nil {
log.Errorf("Invalid MQTT write command payload from topic %q: %v", msg.Topic(), err) log.Errorf("Invalid MQTT write command payload from topic %q: %v", msg.Topic(), err)
@@ -247,6 +276,7 @@ func (c *panelStateCache) resolvePanelCommand(cmd writeCommand) (writeCommand, e
cmd.HasSwitch = true cmd.HasSwitch = true
cmd.SwitchName = c.switchName cmd.SwitchName = c.switchName
cmd.SwitchState = c.switchState cmd.SwitchState = c.switchState
log.WithField("switch", cmd.SwitchName).Debug("Resolved panel command switch from cache")
return cmd, nil return cmd, nil
} }
@@ -260,6 +290,7 @@ func (c *panelStateCache) remember(cmd writeCommand) {
c.switchName = cmd.SwitchName c.switchName = cmd.SwitchName
c.switchState = cmd.SwitchState c.switchState = cmd.SwitchState
c.mu.Unlock() c.mu.Unlock()
log.WithField("switch", cmd.SwitchName).Debug("Remembered panel switch state in cache")
} }
func decodeWriteCommand(payload []byte) (writeCommand, error) { func decodeWriteCommand(payload []byte) (writeCommand, error) {
@@ -465,6 +496,7 @@ func decodeStandbyValue(msg writeCommandPayload) (bool, error) {
func publishWriteStatus(client mqtt.Client, topic string, status writeStatus) { func publishWriteStatus(client mqtt.Client, topic string, status writeStatus) {
if topic == "" { if topic == "" {
log.Debug("Skipping write status publish; status topic is empty")
return return
} }
if err := publishJSON(client, topic, status, 1, false); err != nil { if err := publishJSON(client, topic, status, 1, false); err != nil {
@@ -479,6 +511,11 @@ func publishHADiscovery(client mqtt.Client, config Config) error {
for _, def := range definitions { for _, def := range definitions {
topic := fmt.Sprintf("%s/%s/%s/%s/config", prefix, def.Component, nodeID, def.ObjectID) topic := fmt.Sprintf("%s/%s/%s/%s/config", prefix, def.Component, nodeID, def.ObjectID)
log.WithFields(logrus.Fields{
"topic": topic,
"component": def.Component,
"object_id": def.ObjectID,
}).Debug("Publishing Home Assistant discovery definition")
if err := publishJSON(client, topic, def.Config, 1, true); err != nil { if err := publishJSON(client, topic, def.Config, 1, true); err != nil {
return fmt.Errorf("could not publish discovery for %s/%s: %w", def.Component, def.ObjectID, err) return fmt.Errorf("could not publish discovery for %s/%s: %w", def.Component, def.ObjectID, err)
} }
@@ -665,6 +702,12 @@ func publishJSON(client mqtt.Client, topic string, payload any, qos byte, retain
if t.Error() != nil { if t.Error() != nil {
return t.Error() return t.Error()
} }
log.WithFields(logrus.Fields{
"topic": topic,
"qos": qos,
"retained": retained,
"bytes": len(data),
}).Debug("Published JSON message")
return nil return nil
} }
@@ -679,6 +722,12 @@ func publishString(client mqtt.Client, topic, payload string, qos byte, retained
if t.Error() != nil { if t.Error() != nil {
return t.Error() return t.Error()
} }
log.WithFields(logrus.Fields{
"topic": topic,
"qos": qos,
"retained": retained,
"payload": payload,
}).Debug("Published string message")
return nil return nil
} }

View File

@@ -85,6 +85,7 @@ func NewWebGui(source mk2driver.Mk2, writer mk2driver.SettingsWriter) *WebGui {
Mode: modeUnknown, Mode: modeUnknown,
}, },
} }
log.WithField("remote_writable", writer != nil).Info("Web UI initialized")
w.wg.Add(1) w.wg.Add(1)
go w.dataPoll() go w.dataPoll()
return w return w
@@ -138,50 +139,77 @@ type setRemotePanelStandbyRequest struct {
} }
func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) { func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) {
log.WithFields(logrus.Fields{
"remote": r.RemoteAddr,
"path": r.URL.Path,
}).Debug("WebSocket hub request")
w.hub.ServeHTTP(rw, r) w.hub.ServeHTTP(rw, r)
} }
func (w *WebGui) ServeRemotePanelState(rw http.ResponseWriter, r *http.Request) { func (w *WebGui) ServeRemotePanelState(rw http.ResponseWriter, r *http.Request) {
log.WithFields(logrus.Fields{
"method": r.Method,
"path": r.URL.Path,
"remote": r.RemoteAddr,
}).Debug("Remote panel state API request")
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState()) writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost: case http.MethodPost:
w.handleSetRemotePanelState(rw, r) w.handleSetRemotePanelState(rw, r)
default: default:
log.WithField("method", r.Method).Warn("Remote panel state API received unsupported method")
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
} }
} }
func (w *WebGui) ServeRemotePanelStandby(rw http.ResponseWriter, r *http.Request) { func (w *WebGui) ServeRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
log.WithFields(logrus.Fields{
"method": r.Method,
"path": r.URL.Path,
"remote": r.RemoteAddr,
}).Debug("Remote panel standby API request")
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState()) writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost: case http.MethodPost:
w.handleSetRemotePanelStandby(rw, r) w.handleSetRemotePanelStandby(rw, r)
default: default:
log.WithField("method", r.Method).Warn("Remote panel standby API received unsupported method")
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
} }
} }
func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Request) { func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil { if w.writer == nil {
log.Warn("Remote panel state write requested, but writer is unavailable")
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented) http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return return
} }
req := setRemotePanelStateRequest{} req := setRemotePanelStateRequest{}
if err := decodeJSONBody(r, &req); err != nil { if err := decodeJSONBody(r, &req); err != nil {
log.WithError(err).Warn("Invalid remote panel state request body")
http.Error(rw, err.Error(), http.StatusBadRequest) http.Error(rw, err.Error(), http.StatusBadRequest)
return return
} }
logEntry := log.WithField("mode", req.Mode)
if req.CurrentLimit != nil {
logEntry = logEntry.WithField("current_limit_a", *req.CurrentLimit)
}
logEntry.Info("Applying remote panel state from API")
switchState, normalizedMode, err := parsePanelMode(req.Mode) switchState, normalizedMode, err := parsePanelMode(req.Mode)
if err != nil { if err != nil {
logEntry.WithError(err).Warn("Unsupported remote panel mode")
http.Error(rw, err.Error(), http.StatusBadRequest) http.Error(rw, err.Error(), http.StatusBadRequest)
return return
} }
if err := w.writer.SetPanelState(switchState, req.CurrentLimit); err != nil { if err := w.writer.SetPanelState(switchState, req.CurrentLimit); err != nil {
logEntry.WithError(err).Error("Failed to apply remote panel state")
w.updateRemotePanelState(func(state *remotePanelState) { w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_state" state.LastCommand = "set_remote_panel_state"
state.LastError = err.Error() state.LastError = err.Error()
@@ -196,22 +224,27 @@ func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Reque
state.LastCommand = "set_remote_panel_state" state.LastCommand = "set_remote_panel_state"
state.LastError = "" state.LastError = ""
}) })
logEntry.WithField("normalized_mode", normalizedMode).Info("Remote panel state applied")
writeJSON(rw, http.StatusOK, w.getRemotePanelState()) writeJSON(rw, http.StatusOK, w.getRemotePanelState())
} }
func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Request) { func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil { if w.writer == nil {
log.Warn("Remote panel standby write requested, but writer is unavailable")
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented) http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return return
} }
req := setRemotePanelStandbyRequest{} req := setRemotePanelStandbyRequest{}
if err := decodeJSONBody(r, &req); err != nil { if err := decodeJSONBody(r, &req); err != nil {
log.WithError(err).Warn("Invalid remote panel standby request body")
http.Error(rw, err.Error(), http.StatusBadRequest) http.Error(rw, err.Error(), http.StatusBadRequest)
return return
} }
log.WithField("standby", req.Standby).Info("Applying standby state from API")
if err := w.writer.SetStandby(req.Standby); err != nil { if err := w.writer.SetStandby(req.Standby); err != nil {
log.WithError(err).WithField("standby", req.Standby).Error("Failed to apply standby state")
w.updateRemotePanelState(func(state *remotePanelState) { w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_standby" state.LastCommand = "set_remote_panel_standby"
state.LastError = err.Error() state.LastError = err.Error()
@@ -225,6 +258,7 @@ func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Req
state.LastCommand = "set_remote_panel_standby" state.LastCommand = "set_remote_panel_standby"
state.LastError = "" state.LastError = ""
}) })
log.WithField("standby", req.Standby).Info("Standby state applied")
writeJSON(rw, http.StatusOK, w.getRemotePanelState()) writeJSON(rw, http.StatusOK, w.getRemotePanelState())
} }
@@ -301,23 +335,32 @@ func buildTemplateInput(status *mk2driver.Mk2Info) *templateInput {
} }
func (w *WebGui) Stop() { func (w *WebGui) Stop() {
log.Info("Stopping Web UI polling")
close(w.stopChan) close(w.stopChan)
w.wg.Wait() w.wg.Wait()
log.Info("Web UI polling stopped")
} }
func (w *WebGui) dataPoll() { func (w *WebGui) dataPoll() {
for { for {
select { select {
case s := <-w.C(): case s := <-w.C():
if s.Valid { if s == nil {
payload := buildTemplateInput(s) log.Debug("Skipping nil MK2 update in Web UI poller")
w.stateMu.Lock() continue
payload.RemotePanel = w.remote }
w.latest = payload if !s.Valid {
w.stateMu.Unlock() log.WithField("errors", len(s.Errors)).Debug("Skipping invalid MK2 update in Web UI poller")
if err := w.hub.Broadcast(payload); err != nil { continue
log.Errorf("Could not send update to clients: %v", err) }
}
payload := buildTemplateInput(s)
w.stateMu.Lock()
payload.RemotePanel = w.remote
w.latest = payload
w.stateMu.Unlock()
if err := w.hub.Broadcast(payload); err != nil {
log.Errorf("Could not send update to clients: %v", err)
} }
case <-w.stopChan: case <-w.stopChan:
w.wg.Done() w.wg.Done()
@@ -336,6 +379,13 @@ func (w *WebGui) updateRemotePanelState(update func(state *remotePanelState)) {
w.stateMu.Lock() w.stateMu.Lock()
update(&w.remote) update(&w.remote)
w.remote.LastUpdated = time.Now().UTC().Format(time.RFC3339) w.remote.LastUpdated = time.Now().UTC().Format(time.RFC3339)
log.WithFields(logrus.Fields{
"mode": w.remote.Mode,
"has_limit": w.remote.CurrentLimit != nil,
"has_standby": w.remote.Standby != nil,
"last_command": w.remote.LastCommand,
"last_error": w.remote.LastError,
}).Debug("Updated remote panel state cache")
snapshot := w.snapshotLocked() snapshot := w.snapshotLocked()
w.stateMu.Unlock() w.stateMu.Unlock()