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