generated from rmcguire/go-server-with-otel
163 lines
5.2 KiB
Go
163 lines
5.2 KiB
Go
package econetui
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
|
|
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc"
|
|
)
|
|
|
|
// cardView is the template model for one device card.
|
|
type cardView struct {
|
|
Device *pb.Device
|
|
Icon string // static asset path for the device type
|
|
Modes []modeOption // settable modes for the dropdown
|
|
Pending string // pretty label of an in-flight mode change, if any
|
|
Error string // last mode-change error, if any
|
|
}
|
|
|
|
type modeOption struct {
|
|
Value string // proto enum name, e.g. MODE_HEAT_PUMP
|
|
Label string // display label, e.g. Heat Pump
|
|
Selected bool
|
|
}
|
|
|
|
type pageView struct {
|
|
Devices []cardView
|
|
}
|
|
|
|
func (s *EconetUIServer) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
// A full page load is a "fresh get": show reported state, ignore pending.
|
|
s.render(w, "layout", pageView{Devices: s.deviceViews(r.Context(), false)})
|
|
}
|
|
|
|
func (s *EconetUIServer) handleDevices(w http.ResponseWriter, r *http.Request) {
|
|
// The polling refresh honors pending so an in-flight change stays visible
|
|
// until it lands or its TTL lapses.
|
|
s.render(w, "devices", pageView{Devices: s.deviceViews(r.Context(), true)})
|
|
}
|
|
|
|
func (s *EconetUIServer) handleSetMode(w http.ResponseWriter, r *http.Request) {
|
|
serial := r.PathValue("serial")
|
|
modeName := r.FormValue("mode")
|
|
|
|
modeVal, ok := pb.Mode_value[modeName]
|
|
if !ok || pb.Mode(modeVal) == pb.Mode_MODE_UNSPECIFIED {
|
|
s.renderCard(w, r.Context(), serial, "invalid mode "+modeName)
|
|
return
|
|
}
|
|
|
|
_, err := s.econetGRPC.SetMode(r.Context(), &pb.SetModeRequest{
|
|
SerialNumber: serial,
|
|
Mode: pb.Mode(modeVal),
|
|
})
|
|
if err != nil {
|
|
s.log.Warn().Err(err).Str("serial", serial).Msg("ui: set mode failed")
|
|
s.renderCard(w, r.Context(), serial, err.Error())
|
|
return
|
|
}
|
|
|
|
s.pending.set(serial, econetgrpc.ModeSlug(pb.Mode(modeVal)))
|
|
s.renderCard(w, r.Context(), serial, "")
|
|
}
|
|
|
|
// handleDebug returns the raw EcoNet payload for a device, pretty-printed, for
|
|
// the bug-icon modal.
|
|
func (s *EconetUIServer) handleDebug(w http.ResponseWriter, r *http.Request) {
|
|
serial := r.PathValue("serial")
|
|
raw, ok := s.econetGRPC.DeviceRawJSON(serial)
|
|
if !ok {
|
|
s.render(w, "debug", "No raw payload captured for "+serial+" yet.")
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
|
buf.Write(raw)
|
|
}
|
|
s.render(w, "debug", buf.String())
|
|
}
|
|
|
|
// deviceViews builds card views for all devices. When usePending is true, an
|
|
// in-flight mode change is shown as a pending badge (and cleared once the
|
|
// reported mode catches up).
|
|
func (s *EconetUIServer) deviceViews(ctx context.Context, usePending bool) []cardView {
|
|
resp, err := s.econetGRPC.ListDevices(ctx, &pb.ListDevicesRequest{})
|
|
if err != nil {
|
|
s.log.Error().Err(err).Msg("ui: list devices failed")
|
|
return nil
|
|
}
|
|
views := make([]cardView, 0, len(resp.GetDevices()))
|
|
for _, d := range resp.GetDevices() {
|
|
views = append(views, s.toCardView(d, usePending))
|
|
}
|
|
return views
|
|
}
|
|
|
|
// renderCard re-renders a single device's card, used for the HTMX swap after a
|
|
// mode change. A successful change is optimistic (pending badge shown).
|
|
func (s *EconetUIServer) renderCard(w http.ResponseWriter, ctx context.Context, serial, errMsg string) {
|
|
resp, err := s.econetGRPC.GetDevice(ctx, &pb.GetDeviceRequest{SerialNumber: serial})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
cv := s.toCardView(resp.GetDevice(), true)
|
|
cv.Error = errMsg
|
|
s.render(w, "card", cv)
|
|
}
|
|
|
|
func (s *EconetUIServer) toCardView(d *pb.Device, usePending bool) cardView {
|
|
cv := cardView{Device: d, Icon: iconFor(d.GetGenericType())}
|
|
|
|
for _, m := range d.GetSupportedModes() {
|
|
slug := econetgrpc.ModeSlug(m)
|
|
cv.Modes = append(cv.Modes, modeOption{
|
|
Value: m.String(),
|
|
Label: prettyMode(slug),
|
|
Selected: slug == d.GetMode(),
|
|
})
|
|
}
|
|
|
|
if usePending {
|
|
if desired, ok := s.pending.get(d.GetSerialNumber()); ok {
|
|
if desired == d.GetMode() {
|
|
s.pending.clear(d.GetSerialNumber()) // reported caught up
|
|
} else {
|
|
cv.Pending = prettyMode(desired)
|
|
}
|
|
}
|
|
}
|
|
return cv
|
|
}
|
|
|
|
func (s *EconetUIServer) render(w http.ResponseWriter, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
s.log.Error().Err(err).Str("template", name).Msg("ui: render failed")
|
|
}
|
|
}
|
|
|
|
// iconFor maps a device's generic type to an embedded SVG asset. It matches
|
|
// loosely (case/spacing/underscore-insensitive substrings) because the reported
|
|
// @TYPE varies in form across firmware (e.g. "heatpumpWaterHeater", "HeatPump",
|
|
// "Hybrid Electric"); unrecognized types fall back to a generic icon.
|
|
func iconFor(genericType string) string {
|
|
t := strings.NewReplacer(" ", "", "_", "", "-", "").Replace(strings.ToLower(genericType))
|
|
switch {
|
|
case strings.Contains(t, "heatpump"), strings.Contains(t, "hybrid"):
|
|
return "/static/icons/heatpump.svg"
|
|
case strings.Contains(t, "tankless"):
|
|
return "/static/icons/tankless.svg"
|
|
case strings.Contains(t, "gas"):
|
|
return "/static/icons/gas.svg"
|
|
case strings.Contains(t, "electric"):
|
|
return "/static/icons/electric.svg"
|
|
default:
|
|
return "/static/icons/unknown.svg"
|
|
}
|
|
}
|