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

@@ -31,13 +31,15 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package webui
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/diebietse/invertergui/mk2driver"
"github.com/diebietse/invertergui/websocket"
"invertergui/mk2driver"
"invertergui/websocket"
"github.com/sirupsen/logrus"
)
@@ -51,25 +53,53 @@ const (
BlinkGreen = "blink-green"
)
const (
modeChargerOnly = "charger_only"
modeInverterOnly = "inverter_only"
modeOn = "on"
modeOff = "off"
modeUnknown = "unknown"
)
type WebGui struct {
mk2driver.Mk2
writer mk2driver.SettingsWriter
stopChan chan struct{}
wg sync.WaitGroup
hub *websocket.Hub
stateMu sync.RWMutex
latest *templateInput
remote remotePanelState
}
func NewWebGui(source mk2driver.Mk2) *WebGui {
func NewWebGui(source mk2driver.Mk2, writer mk2driver.SettingsWriter) *WebGui {
w := &WebGui{
stopChan: make(chan struct{}),
Mk2: source,
writer: writer,
hub: websocket.NewHub(),
remote: remotePanelState{
Writable: writer != nil,
Mode: modeUnknown,
},
}
w.wg.Add(1)
go w.dataPoll()
return w
}
type remotePanelState struct {
Writable bool `json:"writable"`
Mode string `json:"mode"`
CurrentLimit *float64 `json:"current_limit,omitempty"`
Standby *bool `json:"standby,omitempty"`
LastCommand string `json:"last_command,omitempty"`
LastError string `json:"last_error,omitempty"`
LastUpdated string `json:"last_updated,omitempty"`
}
type templateInput struct {
Error []error `json:"errors"`
@@ -94,12 +124,125 @@ type templateInput struct {
OutFreq string `json:"output_frequency"`
LedMap map[string]string `json:"led_map"`
RemotePanel remotePanelState `json:"remote_panel"`
}
type setRemotePanelStateRequest struct {
Mode string `json:"mode"`
CurrentLimit *float64 `json:"current_limit"`
}
type setRemotePanelStandbyRequest struct {
Standby bool `json:"standby"`
}
func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) {
w.hub.ServeHTTP(rw, r)
}
func (w *WebGui) ServeRemotePanelState(rw http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost:
w.handleSetRemotePanelState(rw, r)
default:
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (w *WebGui) ServeRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost:
w.handleSetRemotePanelStandby(rw, r)
default:
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil {
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return
}
req := setRemotePanelStateRequest{}
if err := decodeJSONBody(r, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
switchState, normalizedMode, err := parsePanelMode(req.Mode)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if err := w.writer.SetPanelState(switchState, req.CurrentLimit); err != nil {
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_state"
state.LastError = err.Error()
})
http.Error(rw, err.Error(), http.StatusBadGateway)
return
}
w.updateRemotePanelState(func(state *remotePanelState) {
state.Mode = normalizedMode
state.CurrentLimit = copyFloat64Ptr(req.CurrentLimit)
state.LastCommand = "set_remote_panel_state"
state.LastError = ""
})
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
}
func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil {
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return
}
req := setRemotePanelStandbyRequest{}
if err := decodeJSONBody(r, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if err := w.writer.SetStandby(req.Standby); err != nil {
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_standby"
state.LastError = err.Error()
})
http.Error(rw, err.Error(), http.StatusBadGateway)
return
}
w.updateRemotePanelState(func(state *remotePanelState) {
state.Standby = copyBoolPtr(&req.Standby)
state.LastCommand = "set_remote_panel_standby"
state.LastError = ""
})
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
}
func parsePanelMode(raw string) (mk2driver.PanelSwitchState, string, error) {
switch strings.TrimSpace(strings.ToLower(raw)) {
case modeChargerOnly:
return mk2driver.PanelSwitchChargerOnly, modeChargerOnly, nil
case modeInverterOnly:
return mk2driver.PanelSwitchInverterOnly, modeInverterOnly, nil
case modeOn:
return mk2driver.PanelSwitchOn, modeOn, nil
case modeOff:
return mk2driver.PanelSwitchOff, modeOff, nil
default:
return 0, "", fmt.Errorf("unsupported panel mode %q", raw)
}
}
func ledName(led mk2driver.Led) string {
name, ok := mk2driver.LedNames[led]
if !ok {
@@ -162,15 +305,17 @@ func (w *WebGui) Stop() {
w.wg.Wait()
}
// dataPoll waits for data from the w.poller channel. It will send its currently stored status
// to respChan if anything reads from it.
func (w *WebGui) dataPoll() {
for {
select {
case s := <-w.C():
if s.Valid {
err := w.hub.Broadcast(buildTemplateInput(s))
if err != nil {
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)
}
}
@@ -180,3 +325,93 @@ func (w *WebGui) dataPoll() {
}
}
}
func (w *WebGui) getRemotePanelState() remotePanelState {
w.stateMu.RLock()
defer w.stateMu.RUnlock()
return copyRemotePanelState(w.remote)
}
func (w *WebGui) updateRemotePanelState(update func(state *remotePanelState)) {
w.stateMu.Lock()
update(&w.remote)
w.remote.LastUpdated = time.Now().UTC().Format(time.RFC3339)
snapshot := w.snapshotLocked()
w.stateMu.Unlock()
if snapshot != nil {
if err := w.hub.Broadcast(snapshot); err != nil {
log.Errorf("Could not send control update to clients: %v", err)
}
}
}
func (w *WebGui) snapshotLocked() *templateInput {
if w.latest == nil {
return nil
}
snapshot := cloneTemplateInput(w.latest)
snapshot.RemotePanel = copyRemotePanelState(w.remote)
return snapshot
}
func cloneTemplateInput(in *templateInput) *templateInput {
if in == nil {
return nil
}
out := *in
if in.Error != nil {
out.Error = append([]error(nil), in.Error...)
}
if in.LedMap != nil {
out.LedMap = make(map[string]string, len(in.LedMap))
for k, v := range in.LedMap {
out.LedMap[k] = v
}
}
out.RemotePanel = copyRemotePanelState(in.RemotePanel)
return &out
}
func copyRemotePanelState(in remotePanelState) remotePanelState {
in.CurrentLimit = copyFloat64Ptr(in.CurrentLimit)
in.Standby = copyBoolPtr(in.Standby)
return in
}
func copyFloat64Ptr(in *float64) *float64 {
if in == nil {
return nil
}
value := *in
return &value
}
func copyBoolPtr(in *bool) *bool {
if in == nil {
return nil
}
value := *in
return &value
}
func decodeJSONBody(r *http.Request, destination any) error {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
func writeJSON(rw http.ResponseWriter, statusCode int, payload any) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(statusCode)
if err := json.NewEncoder(rw).Encode(payload); err != nil {
log.Errorf("Could not encode webui API response: %v", err)
}
}