diff --git a/cmd/guimock/main.go b/cmd/guimock/main.go new file mode 100644 index 0000000..0680431 --- /dev/null +++ b/cmd/guimock/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "flag" + "log" + "net/http" + + "github.com/hpdvanwyk/invertergui/webgui" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func main() { + addr := flag.String("addr", ":8080", "TCP address to listen on.") + mk2 := NewMk2Mock() + gui := webgui.NewWebGui(mk2) + + http.Handle("/ws", http.HandlerFunc(gui.ServeHub)) + http.Handle("/", gui) + http.Handle("/js/controller.js", http.HandlerFunc(gui.ServeJS)) + http.Handle("/munin", http.HandlerFunc(gui.ServeMuninHTTP)) + http.Handle("/muninconfig", http.HandlerFunc(gui.ServeMuninConfigHTTP)) + http.Handle("/metrics", promhttp.Handler()) + log.Fatal(http.ListenAndServe(*addr, nil)) +} diff --git a/cmd/guimock/mock.go b/cmd/guimock/mock.go new file mode 100644 index 0000000..cdeda43 --- /dev/null +++ b/cmd/guimock/mock.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "time" + + "github.com/hpdvanwyk/invertergui/mk2if" +) + +type mock struct { + c chan *mk2if.Mk2Info +} + +func NewMk2Mock() mk2if.Mk2If { + tmp := &mock{ + c: make(chan *mk2if.Mk2Info, 1), + } + go tmp.genMockValues() + return tmp +} + +func genBaseLeds(state mk2if.LEDstate) map[mk2if.Led]mk2if.LEDstate { + return map[mk2if.Led]mk2if.LEDstate{ + mk2if.LedMain: state, + mk2if.LedAbsorption: state, + mk2if.LedBulk: state, + mk2if.LedFloat: state, + mk2if.LedInverter: state, + mk2if.LedOverload: state, + mk2if.LedLowBattery: state, + mk2if.LedTemperature: state, + } +} + +func (m *mock) GetMk2Info() *mk2if.Mk2Info { + return &mk2if.Mk2Info{ + OutCurrent: 2.0, + InCurrent: 2.3, + OutVoltage: 230.0, + InVoltage: 230.1, + BatVoltage: 25, + BatCurrent: -10, + InFrequency: 50, + OutFrequency: 50, + ChargeState: 1, + Errors: nil, + Timestamp: time.Now(), + LEDs: genBaseLeds(mk2if.LedOff), + } +} + +func (m *mock) C() chan *mk2if.Mk2Info { + return m.c +} + +func (m *mock) Close() { + +} + +func (m *mock) genMockValues() { + mult := 1.0 + ledState := mk2if.LedOff + for { + input := &mk2if.Mk2Info{ + OutCurrent: 2.0 * mult, + InCurrent: 2.3 * mult, + OutVoltage: 230.0 * mult, + InVoltage: 230.1 * mult, + BatVoltage: 25 * mult, + BatCurrent: -10 * mult, + InFrequency: 50 * mult, + OutFrequency: 50 * mult, + ChargeState: 1 * mult, + Errors: nil, + Timestamp: time.Now(), + Valid: true, + LEDs: genBaseLeds(ledState), + } + + ledState = (ledState + 1) % 3 + + mult = mult - 0.1 + if mult < 0 { + mult = 1.0 + } + fmt.Printf("Sending\n") + m.c <- input + time.Sleep(1 * time.Second) + } +} diff --git a/cmd/invertergui/main.go b/cmd/invertergui/main.go index 519bddd..60b1e54 100644 --- a/cmd/invertergui/main.go +++ b/cmd/invertergui/main.go @@ -32,14 +32,15 @@ package main import ( "flag" - "github.com/hpdvanwyk/invertergui/mk2if" - "github.com/hpdvanwyk/invertergui/webgui" - "github.com/mikepb/go-serial" - "github.com/prometheus/client_golang/prometheus/promhttp" "io" "log" "net" "net/http" + + "github.com/hpdvanwyk/invertergui/mk2if" + "github.com/hpdvanwyk/invertergui/webgui" + "github.com/mikepb/go-serial" + "github.com/prometheus/client_golang/prometheus/promhttp" ) func main() { @@ -81,6 +82,8 @@ func main() { gui := webgui.NewWebGui(mk2) http.Handle("/", gui) + http.Handle("/ws", http.HandlerFunc(gui.ServeHub)) + http.Handle("/js/controller.js", http.HandlerFunc(gui.ServeJS)) http.Handle("/munin", http.HandlerFunc(gui.ServeMuninHTTP)) http.Handle("/muninconfig", http.HandlerFunc(gui.ServeMuninConfigHTTP)) http.Handle("/metrics", promhttp.Handler()) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1f39d6f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,280 @@ + + + + + + + Victron Multiplus Monitor + + + + + + + + +

Inverter GUI

+
+
+
+
+
+
+
+
+
+
+
Output Current
+
{{ output_current }} A
+
+
+ +
+
+
Output Voltage
+
{{ output_voltage }} V
+
+
+ +
+
+
Output Frequency
+
+ {{ output_frequency }} Hz +
+
+
+ +
+
+
Output Power
+
{{ output_power }} W
+
+
+
+
+
+
+
Input Current
+
{{ input_current }} A
+
+
+ +
+
+
Input Voltage
+
{{ input_voltage }} V
+
+
+ +
+
+
Input Frequency
+
+ {{ input_frequency }} Hz +
+
+
+ +
+
+
Input Power
+
{{ input_power }} W
+
+
+
+
+
+
+
Battery Current
+
+ {{ battery_current }} A +
+
+
+ +
+
+
Battery Voltage
+
+ {{ battery_voltage }} V +
+
+
+ +
+
+
Battery Charge
+
{{ battery_charge }} %
+
+
+ +
+
+
Battery Power
+
{{ battery_power }} W
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mains
+ +
+
+
+
+
+
+
Absorption
+ +
+
+
+
+
+
+
Bulk
+ +
+
+
+
+
+
+
Float
+ +
+
+
+
+
+
+
Inverter
+ +
+
+
+
+ +
+
+
+
+
Overload
+ +
+
+
+
+
+
+
Low Battery
+ +
+
+
+
+
+
+
Temperature
+ +
+
+
+
+
+ + diff --git a/frontend/js/controller.js b/frontend/js/controller.js new file mode 100644 index 0000000..50deeae --- /dev/null +++ b/frontend/js/controller.js @@ -0,0 +1,69 @@ +function loadContent() { + var conn; + + var app = new Vue({ + el: "#app", + data: { + output_current: null, + output_voltage: 0, + output_frequency: 0, + output_power: 0, + input_current: 0, + input_voltage: 0, + input_frequency: 0, + input_power: 0, + battery_current: 0, + battery_voltage: 0, + battery_charge: 0, + battery_power: 0, + led_mains: "dot-off", + led_absorb: "dot-off", + led_bulk: "dot-off", + led_float: "dot-off", + led_inverter: "dot-off", + led_overload: "dot-off", + led_bat_low: "dot-off", + led_over_temp: "dot-off" + } + }); + + if (window["WebSocket"]) { + conn = new WebSocket("ws://" + document.location.host + "/ws"); + conn.onclose = function(evt) { + var item = document.createElement("a"); + item.innerHTML = "Connection closed."; + }; + + conn.onmessage = function(evt) { + var update = JSON.parse(evt.data); + console.log(update); + app.output_current = update.output_current; + app.output_voltage = update.output_voltage; + app.output_frequency = update.output_frequency; + app.output_power = update.output_power; + + app.input_current = update.input_current; + app.input_voltage = update.input_voltage; + app.input_frequency = update.input_frequency; + app.input_power = update.input_power; + + app.battery_charge = update.battery_charge; + app.battery_voltage = update.battery_voltage; + app.battery_power = update.battery_power; + app.battery_current = update.battery_current; + + leds = update.led_map; + app.led_mains = leds.led_mains; + app.led_absorb = leds.led_absorb; + app.led_bulk = leds.led_bulk; + app.led_float = leds.led_float; + app.led_inverter = leds.led_inverter; + app.led_overload = leds.led_overload; + app.led_bat_low = leds.led_bat_low; + app.led_over_temp = leds.led_over_temp; + }; + } else { + var item = document.createElement("a"); + item.innerHTML = "Your browser does not support WebSockets."; + } +} diff --git a/go.mod b/go.mod index 0ab2dea..d1f0110 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module github.com/hpdvanwyk/invertergui require ( + github.com/gorilla/websocket v1.4.0 github.com/mikepb/go-serial v0.0.0-20180731022703-d5134cecf05a github.com/prometheus/client_golang v0.9.2 ) diff --git a/go.sum b/go.sum index 2fc6cb8..41627e4 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLM github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mikepb/go-serial v0.0.0-20180731022703-d5134cecf05a h1:dCv1LIqgkmFyW9NTRX16FANqPkGcyCZAPV54TMGUV+I= diff --git a/webgui/htmltemplate.go b/webgui/htmltemplate.go index e11fcbb..12b4daf 100644 --- a/webgui/htmltemplate.go +++ b/webgui/htmltemplate.go @@ -30,16 +30,70 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package webgui -var htmlTemplate string = ` +var htmlTemplate string = ` + - - - + + Victron Multiplus Monitor + + + +
+
+
+
+
+
+ Quote +
+
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.

+
Someone famous in Source Title
+
+
+
+
+
+
+
+ Quote +
+
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.

+
Someone famous in Source Title
+
+
+
+
+
+
+
+ Quote +
+
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.

+
Someone famous in Source Title
+
+
+
+
+
+
+ One of three columns +
+
+ One of three columns +
+
+
{{if .Error}}

Errors encountered:

{{range .Error}}
{{.}}
diff --git a/webgui/webgui.go b/webgui/webgui.go index 2b2ad05..2144734 100644 --- a/webgui/webgui.go +++ b/webgui/webgui.go @@ -32,37 +32,41 @@ package webgui import ( "fmt" - "github.com/hpdvanwyk/invertergui/mk2if" - "html/template" "net/http" "sync" "time" + + "github.com/hpdvanwyk/invertergui/mk2if" + "github.com/hpdvanwyk/invertergui/websocket" +) + +const ( + LedOff = "dot-off" + LedRed = "dot-red" + BlinkRed = "blink-red" + LedGreen = "dot-green" + BlinkGreen = "blink-green" ) type WebGui struct { respChan chan *mk2if.Mk2Info stopChan chan struct{} - template *template.Template muninRespChan chan muninData poller mk2if.Mk2If wg sync.WaitGroup + hub *websocket.Hub pu *prometheusUpdater } func NewWebGui(source mk2if.Mk2If) *WebGui { w := new(WebGui) - w.respChan = make(chan *mk2if.Mk2Info) w.muninRespChan = make(chan muninData) w.stopChan = make(chan struct{}) - var err error - w.template, err = template.New("thegui").Parse(htmlTemplate) - if err != nil { - panic(err) - } w.poller = source w.pu = newPrometheusUpdater() + w.hub = websocket.NewHub() w.wg.Add(1) go w.dataPoll() @@ -70,44 +74,55 @@ func NewWebGui(source mk2if.Mk2If) *WebGui { } type templateInput struct { - Error []error + Error []error `json:"errors"` - Date string + Date string `json:"date"` - OutCurrent string - OutVoltage string - OutPower string + OutCurrent string `json:"output_current"` + OutVoltage string `json:"output_voltage"` + OutPower string `json:"output_power"` - InCurrent string - InVoltage string - InPower string + InCurrent string `json:"input_current"` + InVoltage string `json:"input_voltage"` + InPower string `json:"input_power"` InMinOut string - BatVoltage string - BatCurrent string - BatPower string - BatCharge string + BatVoltage string `json:"battery_voltage"` + BatCurrent string `json:"battery_current"` + BatPower string `json:"battery_power"` + BatCharge string `json:"battery_charge"` - InFreq string - OutFreq string + InFreq string `json:"input_frequency"` + OutFreq string `json:"output_frequency"` + /* + MainsLed string `json:"main_led"` + AbsorbLed string `json:"absorb_led"` + BulkLed string `json:"bulk_led"` + FloatLed string `json:"float_led"` + InverterLed string `json:"inverter_led"` - Leds []string + OverloadLed string `json:"overload_led"` + LowBatLed string `json:"low_bat_led"` + OverTempLed string `json:"over_temp_led"` + */ + LedMap map[string]string `json:"led_map"` } func (w *WebGui) ServeHTTP(rw http.ResponseWriter, r *http.Request) { - statusErr := <-w.respChan - - tmpInput := buildTemplateInput(statusErr) - - err := w.template.Execute(rw, tmpInput) - if err != nil { - panic(err) - } + http.ServeFile(rw, r, "./frontend/index.html") } -func ledName(nameInt int) string { - name, ok := mk2if.LedNames[nameInt] +func (w *WebGui) ServeJS(rw http.ResponseWriter, r *http.Request) { + http.ServeFile(rw, r, "./frontend/js/controller.js") +} + +func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) { + w.hub.ServeHTTP(rw, r) +} + +func ledName(led mk2if.Led) string { + name, ok := mk2if.LedNames[led] if !ok { return "Unknown led" } @@ -121,24 +136,44 @@ func buildTemplateInput(status *mk2if.Mk2Info) *templateInput { tmpInput := &templateInput{ Error: status.Errors, Date: status.Timestamp.Format(time.RFC1123Z), - OutCurrent: fmt.Sprintf("%.3f", status.OutCurrent), - OutVoltage: fmt.Sprintf("%.3f", status.OutVoltage), - OutPower: fmt.Sprintf("%.3f", outPower), - InCurrent: fmt.Sprintf("%.3f", status.InCurrent), - InVoltage: fmt.Sprintf("%.3f", status.InVoltage), - InFreq: fmt.Sprintf("%.3f", status.InFrequency), - OutFreq: fmt.Sprintf("%.3f", status.OutFrequency), - InPower: fmt.Sprintf("%.3f", inPower), + OutCurrent: fmt.Sprintf("%.2f", status.OutCurrent), + OutVoltage: fmt.Sprintf("%.2f", status.OutVoltage), + OutPower: fmt.Sprintf("%.2f", outPower), + InCurrent: fmt.Sprintf("%.2f", status.InCurrent), + InVoltage: fmt.Sprintf("%.2f", status.InVoltage), + InFreq: fmt.Sprintf("%.2f", status.InFrequency), + OutFreq: fmt.Sprintf("%.2f", status.OutFrequency), + InPower: fmt.Sprintf("%.2f", inPower), - InMinOut: fmt.Sprintf("%.3f", inPower-outPower), + InMinOut: fmt.Sprintf("%.2f", inPower-outPower), - BatCurrent: fmt.Sprintf("%.3f", status.BatCurrent), - BatVoltage: fmt.Sprintf("%.3f", status.BatVoltage), - BatPower: fmt.Sprintf("%.3f", status.BatVoltage*status.BatCurrent), - BatCharge: fmt.Sprintf("%.3f", status.ChargeState*100), + BatCurrent: fmt.Sprintf("%.2f", status.BatCurrent), + BatVoltage: fmt.Sprintf("%.2f", status.BatVoltage), + BatPower: fmt.Sprintf("%.2f", status.BatVoltage*status.BatCurrent), + BatCharge: fmt.Sprintf("%.2f", status.ChargeState*100), + + LedMap: map[string]string{}, } - for i := range status.LedListOn { - tmpInput.Leds = append(tmpInput.Leds, ledName(status.LedListOn[i])) + for k, v := range status.LEDs { + if k == mk2if.LedOverload || k == mk2if.LedTemperature || k == mk2if.LedLowBattery { + switch v { + case mk2if.LedOn: + tmpInput.LedMap[ledName(k)] = LedRed + case mk2if.LedBlink: + tmpInput.LedMap[ledName(k)] = BlinkRed + default: + tmpInput.LedMap[ledName(k)] = LedOff + } + } else { + switch v { + case mk2if.LedOn: + tmpInput.LedMap[ledName(k)] = LedGreen + case mk2if.LedBlink: + tmpInput.LedMap[ledName(k)] = BlinkGreen + default: + tmpInput.LedMap[ledName(k)] = LedOff + } + } } return tmpInput } @@ -160,8 +195,8 @@ func (w *WebGui) dataPoll() { if s.Valid { calcMuninValues(&muninValues, s) w.pu.updatePrometheus(s) + w.hub.Broadcast(buildTemplateInput(s)) } - case w.respChan <- s: case w.muninRespChan <- muninValues: zeroMuninValues(&muninValues) case <-w.stopChan: diff --git a/websocket/client.go b/websocket/client.go new file mode 100644 index 0000000..d6f19a4 --- /dev/null +++ b/websocket/client.go @@ -0,0 +1,93 @@ +package websocket + +import ( + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer. + maxMessageSize = 512 +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +// Client is a middleman between the websocket connection and the hub. +type Client struct { + hub *Hub + + // The websocket connection. + conn *websocket.Conn + + // Buffered channel of outbound messages. + send chan []byte +} + +// writePump pumps messages from the hub to the websocket connection. +// +// A goroutine running writePump is started for each connection. The +// application ensures that there is at most one writer to a connection by +// executing all writes from this goroutine. +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // The hub closed the channel. + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + + if err := w.Close(); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// ServeWs handles websocket requests from the peer. +func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println(err) + return + } + client := &Client{hub: h, conn: conn, send: make(chan []byte, 256)} + client.hub.register <- client + + // Allow collection of memory referenced by the caller by doing all work in + // new goroutines. + go client.writePump() +} diff --git a/websocket/hub.go b/websocket/hub.go new file mode 100644 index 0000000..fff0e27 --- /dev/null +++ b/websocket/hub.go @@ -0,0 +1,64 @@ +package websocket + +import ( + "encoding/json" +) + +// Hub maintains the set of active clients and broadcasts messages to the +// clients. +type Hub struct { + // Registered clients. + clients map[*Client]bool + + // Inbound messages from the clients. + broadcast chan []byte + + // Register requests from the clients. + register chan *Client + + // Unregister requests from clients. + unregister chan *Client +} + +func NewHub() *Hub { + tmp := &Hub{ + broadcast: make(chan []byte), + register: make(chan *Client), + unregister: make(chan *Client), + clients: make(map[*Client]bool), + } + go tmp.run() + return tmp +} + +func (h *Hub) Broadcast(message interface{}) error { + payload, err := json.Marshal(message) + if err != nil { + return err + } + h.broadcast <- []byte(payload) + return nil +} + +func (h *Hub) run() { + for { + select { + case client := <-h.register: + h.clients[client] = true + case client := <-h.unregister: + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + case message := <-h.broadcast: + for client := range h.clients { + select { + case client.send <- message: + default: + close(client.send) + delete(h.clients, client) + } + } + } + } +}