Files
2026-07-16 16:32:44 -04:00

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"
}
}