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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user