From fc1673dcffa161638c31c64bffbd98948db65031 Mon Sep 17 00:00:00 2001 From: Florian Ringbeck Date: Mon, 20 Apr 2026 10:45:34 +0200 Subject: [PATCH] initial commit --- .gitignore | 2 + go.mod | 3 + main.go | 183 +++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 73 ++++++++++++++++++ static/favicon.svg | 12 +++ static/index.html | 22 ++++++ 6 files changed, 295 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go create mode 100644 static/favicon.svg create mode 100644 static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7ec484 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.exe +config.json \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0b89bb0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module powerwatts + +go 1.22 diff --git a/main.go b/main.go new file mode 100644 index 0000000..bbbfd00 --- /dev/null +++ b/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "embed" + "encoding/json" + "errors" + "html/template" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" +) + +//go:embed static/* +var indexHTML embed.FS + +type store struct { + mu sync.RWMutex + value int +} + +func (s *store) get() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.value +} + +func (s *store) set(v int) { + s.mu.Lock() + s.value = v + s.mu.Unlock() +} + +func main() { + secret := os.Getenv("WRITE_SECRET") + addr := os.Getenv("ADDR") + if cfg, err := loadConfig("config.json"); err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Fatalf("load config: %v", err) + } + } else { + if secret == "" { + secret = cfg.WriteSecret + } + if addr == "" { + addr = cfg.Addr + } + } + if secret == "" { + log.Fatal("WRITE_SECRET is required") + } + if addr == "" { + addr = ":8080" + } + + app := &server{ + secret: secret, + store: &store{}, + } + + log.Printf("listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, app.routes())) +} + +type server struct { + secret string + store *store +} + +type config struct { + WriteSecret string `json:"write_secret"` + Addr string `json:"addr"` +} + +func loadConfig(path string) (config, error) { + data, err := os.ReadFile(path) + if err != nil { + return config{}, err + } + + var cfg config + if err := json.Unmarshal(data, &cfg); err != nil { + return config{}, err + } + return cfg, nil +} + +func (s *server) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/favicon.svg", s.handleFavicon) + mux.HandleFunc("/write", s.handleWrite) + return mux +} + +func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + page, err := template.New("index.html").ParseFS(indexHTML, "static/index.html") + if err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := page.Execute(w, map[string]any{"Watts": s.store.get()}); err != nil { + http.Error(w, "render error", http.StatusInternalServerError) + } +} + +const faviconSVG = ` + + + + + + + + + + +` + +func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/favicon.svg" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = w.Write([]byte(faviconSVG)) +} + +func (s *server) handleWrite(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if got := r.Header.Get("X-Secret"); got == "" || got != s.secret { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + value, err := parseWatts(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s.store.set(value) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"watts": value}) +} + +func parseWatts(r *http.Request) (int, error) { + contentType := r.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") { + var payload struct { + Watts *int `json:"watts"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + return 0, errors.New("invalid json") + } + if payload.Watts == nil { + return 0, errors.New("missing watts") + } + return *payload.Watts, nil + } + + if err := r.ParseForm(); err != nil { + return 0, errors.New("invalid form data") + } + if v := r.FormValue("watts"); v != "" { + return strconv.Atoi(v) + } + return 0, errors.New("missing watts") +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..28d8f4d --- /dev/null +++ b/main_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "bytes" + "os" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleIndexRendersWatts(t *testing.T) { + srv := &server{secret: "secret", store: &store{value: 123}} + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + srv.handleIndex(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if !strings.Contains(rec.Body.String(), "123") { + t.Fatalf("response did not contain watt value: %s", rec.Body.String()) + } +} + +func TestHandleWriteRequiresSecret(t *testing.T) { + srv := &server{secret: "secret", store: &store{}} + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.handleWrite(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +func TestHandleWriteStoresValue(t *testing.T) { + srv := &server{secret: "secret", store: &store{}} + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Secret", "secret") + rec := httptest.NewRecorder() + + srv.handleWrite(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := srv.store.get(); got != 456 { + t.Fatalf("stored watts = %d, want %d", got, 456) + } +} + +func TestLoadConfig(t *testing.T) { + path := t.TempDir() + "/config.json" + if err := os.WriteFile(path, []byte(`{"write_secret":"secret","addr":":9090"}`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := loadConfig(path) + if err != nil { + t.Fatalf("loadConfig returned error: %v", err) + } + if cfg.WriteSecret != "secret" { + t.Fatalf("write_secret = %q, want %q", cfg.WriteSecret, "secret") + } + if cfg.Addr != ":9090" { + t.Fatalf("addr = %q, want %q", cfg.Addr, ":9090") + } +} diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..edff102 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7574ef3 --- /dev/null +++ b/static/index.html @@ -0,0 +1,22 @@ + + + + + + Power Display + + + + +
+
{{.Watts}}
+
watts
+
+ +