Initial commit: EMS — Energie Management System
Complete self-consumption optimisation system for 7 kWp PV installation: - Prometheus collector (grid power, SOC, PV, per-phase, compressor) - Pure decision engine with SOC gates, hysteresis, priority ordering - Shelly Gen1/Gen2 actuator (SHA-256 Digest auth, PM power readback) - Viessmann OAuth2 client for DHW temperature control - PV forecast integration (forecast.solar) - Wallbox mutual exclusion (VX3 4.6 kW AC output constraint) - Car-not-charging detection via Shelly PM - Compressor idle → early SG-Ready release - Per-phase grid power for single-phase wallbox decisions - Manual override detection and web UI with override buttons - Full unit test coverage for decision engine - systemd service, Makefile, complete documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
179
internal/actuator/actuator_test.go
Normal file
179
internal/actuator/actuator_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tb/ems/internal/config"
|
||||
"github.com/tb/ems/internal/engine"
|
||||
)
|
||||
|
||||
// mockShelly simulates both Gen1 and Gen2 Shelly HTTP APIs.
|
||||
type mockShelly struct {
|
||||
state bool // current relay state
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (m *mockShelly) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Gen1: GET /relay/0?turn=on|off or GET /relay/0 (read state)
|
||||
mux.HandleFunc("/relay/0", func(w http.ResponseWriter, r *http.Request) {
|
||||
m.calls = append(m.calls, r.Method+" "+r.URL.String())
|
||||
if turn := r.URL.Query().Get("turn"); turn != "" {
|
||||
m.state = turn == "on"
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ison": m.state})
|
||||
})
|
||||
|
||||
// Gen2: POST /rpc/Switch.Set or POST /rpc/Switch.GetStatus
|
||||
mux.HandleFunc("/rpc/Switch.Set", func(w http.ResponseWriter, r *http.Request) {
|
||||
m.calls = append(m.calls, r.Method+" "+r.URL.Path)
|
||||
var body struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
wasOn := m.state
|
||||
m.state = body.On
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"was_on": wasOn})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/rpc/Switch.GetStatus", func(w http.ResponseWriter, r *http.Request) {
|
||||
m.calls = append(m.calls, r.Method+" "+r.URL.Path)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"output": m.state})
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func (m *mockShelly) start(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(m.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ipFrom extracts host:port from an httptest server URL.
|
||||
func ipFrom(srv *httptest.Server) string {
|
||||
u, _ := url.Parse(srv.URL)
|
||||
return u.Host
|
||||
}
|
||||
|
||||
func testActuator(t *testing.T, sgReadyIP, wallboxAIP, wallboxBIP string) *Actuator {
|
||||
t.Helper()
|
||||
cfg := &config.Config{
|
||||
Shelly: config.ShellyConfig{
|
||||
SGReady: config.ShellyDevice{IP: sgReadyIP, Gen: 1},
|
||||
WallboxA: config.ShellyDevice{IP: wallboxAIP, Gen: 2},
|
||||
WallboxB: config.ShellyDevice{IP: wallboxBIP, Gen: 2},
|
||||
},
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
return NewActuator(cfg, nil, logger)
|
||||
}
|
||||
|
||||
func TestExecuteTurnOnSGReady(t *testing.T) {
|
||||
sg := &mockShelly{}
|
||||
sgSrv := sg.start(t)
|
||||
|
||||
dummy := &mockShelly{}
|
||||
dummySrv := dummy.start(t)
|
||||
|
||||
act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv))
|
||||
|
||||
err := act.Execute(context.Background(), []engine.Action{
|
||||
{Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute failed: %v", err)
|
||||
}
|
||||
|
||||
if !sg.state {
|
||||
t.Error("SG-Ready should be ON after TurnOn action")
|
||||
}
|
||||
if len(sg.calls) != 1 || !strings.Contains(sg.calls[0], "turn=on") {
|
||||
t.Errorf("expected one GET /relay/0?turn=on call, got %v", sg.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTurnOffWallboxA(t *testing.T) {
|
||||
wbA := &mockShelly{state: true} // starts ON
|
||||
wbASrv := wbA.start(t)
|
||||
|
||||
dummy := &mockShelly{}
|
||||
dummySrv := dummy.start(t)
|
||||
|
||||
act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv))
|
||||
|
||||
err := act.Execute(context.Background(), []engine.Action{
|
||||
{Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute failed: %v", err)
|
||||
}
|
||||
|
||||
if wbA.state {
|
||||
t.Error("Wallbox A should be OFF after TurnOff action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllStates(t *testing.T) {
|
||||
sg := &mockShelly{state: true}
|
||||
sgSrv := sg.start(t)
|
||||
|
||||
wbA := &mockShelly{state: false}
|
||||
wbASrv := wbA.start(t)
|
||||
|
||||
wbB := &mockShelly{state: true}
|
||||
wbBSrv := wbB.start(t)
|
||||
|
||||
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
|
||||
|
||||
states, err := act.ReadAllStates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAllStates failed: %v", err)
|
||||
}
|
||||
|
||||
if !states[engine.ConsumerSGReady].On {
|
||||
t.Error("SG-Ready should be ON")
|
||||
}
|
||||
if states[engine.ConsumerWallboxA].On {
|
||||
t.Error("Wallbox A should be OFF")
|
||||
}
|
||||
if !states[engine.ConsumerWallboxB].On {
|
||||
t.Error("Wallbox B should be ON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMultipleActions(t *testing.T) {
|
||||
sg := &mockShelly{}
|
||||
sgSrv := sg.start(t)
|
||||
|
||||
wbA := &mockShelly{}
|
||||
wbASrv := wbA.start(t)
|
||||
|
||||
wbB := &mockShelly{}
|
||||
wbBSrv := wbB.start(t)
|
||||
|
||||
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
|
||||
|
||||
err := act.Execute(context.Background(), []engine.Action{
|
||||
{Consumer: engine.ConsumerSGReady, TurnOn: true},
|
||||
{Consumer: engine.ConsumerWallboxA, TurnOn: true},
|
||||
{Consumer: engine.ConsumerWallboxB, TurnOn: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute failed: %v", err)
|
||||
}
|
||||
|
||||
if !sg.state || !wbA.state || !wbB.state {
|
||||
t.Error("all consumers should be ON")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user