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
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.
+
+
+
+
+
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.
+
+
+
+
+
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante.
+
+
+
+
+
+
+
+ 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)
+ }
+ }
+ }
+ }
+}