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:
295
internal/actuator/actuator.go
Normal file
295
internal/actuator/actuator.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/config"
|
||||
"github.com/tb/ems/internal/engine"
|
||||
"github.com/tb/ems/internal/viessmann"
|
||||
)
|
||||
|
||||
// Actuator executes switching decisions on physical devices.
|
||||
type Actuator struct {
|
||||
client *http.Client
|
||||
cfg *config.Config
|
||||
viessmann *viessmann.Client // nil if not configured
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewActuator creates a new actuator.
|
||||
// vc may be nil if Viessmann integration is not configured.
|
||||
func NewActuator(cfg *config.Config, vc *viessmann.Client, logger *slog.Logger) *Actuator {
|
||||
return &Actuator{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
cfg: cfg,
|
||||
viessmann: vc,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute performs a list of switching actions.
|
||||
func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) error {
|
||||
for _, action := range actions {
|
||||
if err := a.executeOne(ctx, action); err != nil {
|
||||
a.logger.Error("action failed",
|
||||
"consumer", action.Consumer,
|
||||
"turn_on", action.TurnOn,
|
||||
"error", err,
|
||||
)
|
||||
// Continue with other actions even if one fails
|
||||
continue
|
||||
}
|
||||
|
||||
a.logger.Info("action executed",
|
||||
"consumer", action.Consumer,
|
||||
"turn_on", action.TurnOn,
|
||||
"reason", action.Reason,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Actuator) executeOne(ctx context.Context, action engine.Action) error {
|
||||
switch action.Consumer {
|
||||
case engine.ConsumerSGReady:
|
||||
return a.switchShellyGen1(ctx, a.cfg.Shelly.SGReady, action.TurnOn)
|
||||
case engine.ConsumerWW:
|
||||
return a.setDHWTemperature(ctx, action.TargetTempC)
|
||||
case engine.ConsumerWallboxA:
|
||||
return a.switchShellyGen2(ctx, a.cfg.Shelly.WallboxA, action.TurnOn)
|
||||
case engine.ConsumerWallboxB:
|
||||
return a.switchShellyGen2(ctx, a.cfg.Shelly.WallboxB, action.TurnOn)
|
||||
default:
|
||||
return fmt.Errorf("unknown consumer: %v", action.Consumer)
|
||||
}
|
||||
}
|
||||
|
||||
// setDHWTemperature sets the WW temperature via the Viessmann API.
|
||||
func (a *Actuator) setDHWTemperature(ctx context.Context, tempC float64) error {
|
||||
if a.viessmann == nil {
|
||||
return fmt.Errorf("Viessmann client not configured")
|
||||
}
|
||||
return a.viessmann.SetDHWTemperature(ctx, tempC)
|
||||
}
|
||||
|
||||
// switchShellyGen1 controls a Shelly Gen1 device (relay endpoint).
|
||||
// API: http://<ip>/relay/0?turn=on|off
|
||||
func (a *Actuator) switchShellyGen1(ctx context.Context, dev config.ShellyDevice, turnOn bool) error {
|
||||
state := "off"
|
||||
if turnOn {
|
||||
state = "on"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/relay/0?turn=%s", dev.IP, state)
|
||||
|
||||
a.logger.Debug("shelly gen1 request", "url", url)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shelly gen1 %s: %w", dev.IP, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("shelly gen1 %s returned %d", dev.IP, resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// switchShellyGen2 controls a Shelly Gen2/Plus device (RPC endpoint).
|
||||
// API: http://<ip>/rpc/Switch.Set {"id":0,"on":true|false}
|
||||
func (a *Actuator) switchShellyGen2(ctx context.Context, dev config.ShellyDevice, turnOn bool) error {
|
||||
payload := fmt.Sprintf(`{"id":0,"on":%t}`, turnOn)
|
||||
body, err := a.gen2Request(ctx, dev, "/rpc/Switch.Set", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var result struct {
|
||||
WasOn bool `json:"was_on"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
a.logger.Warn("could not parse shelly gen2 response", "body", string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gen2Request performs a POST to a Shelly Gen2 RPC endpoint, handling Digest auth
|
||||
// transparently when a password is configured on the device.
|
||||
func (a *Actuator) gen2Request(ctx context.Context, dev config.ShellyDevice, path, payload string) ([]byte, error) {
|
||||
url := "http://" + dev.IP + path
|
||||
a.logger.Debug("shelly gen2 request", "url", url)
|
||||
|
||||
do := func(authHeader string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if authHeader != "" {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
return a.client.Do(req)
|
||||
}
|
||||
|
||||
resp, err := do("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shelly gen2 %s: %w", dev.IP, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized && dev.Password != "" {
|
||||
// Digest auth: parse challenge, compute response, retry
|
||||
challenge := resp.Header.Get("WWW-Authenticate")
|
||||
authHeader := digestAuthHeader("admin", dev.Password, http.MethodPost, path, challenge)
|
||||
|
||||
resp2, err := do(authHeader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shelly gen2 %s: %w", dev.IP, err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp2.Body)
|
||||
return nil, fmt.Errorf("shelly gen2 %s returned %d: %s", dev.IP, resp2.StatusCode, b)
|
||||
}
|
||||
return io.ReadAll(resp2.Body)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("shelly gen2 %s returned %d: %s", dev.IP, resp.StatusCode, b)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// digestAuthHeader computes an HTTP Digest Authorization header.
|
||||
// Shelly Gen2 requires SHA-256 with qop=auth (RFC 7616).
|
||||
func digestAuthHeader(username, password, method, uri, challenge string) string {
|
||||
realm := digestParam(challenge, "realm")
|
||||
nonce := digestParam(challenge, "nonce")
|
||||
// Fixed nc/cnonce — one request per nonce is sufficient for our use case
|
||||
const nc = "00000001"
|
||||
const cnonce = "ems00001"
|
||||
ha1 := sha256hex(username + ":" + realm + ":" + password)
|
||||
ha2 := sha256hex(method + ":" + uri)
|
||||
response := sha256hex(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":auth:" + ha2)
|
||||
return fmt.Sprintf(
|
||||
`Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm=SHA-256, qop=auth, nc=%s, cnonce="%s", response="%s"`,
|
||||
username, realm, nonce, uri, nc, cnonce, response,
|
||||
)
|
||||
}
|
||||
|
||||
var digestParamRe = regexp.MustCompile(`(\w+)="([^"]*)"`)
|
||||
|
||||
func digestParam(header, key string) string {
|
||||
for _, m := range digestParamRe.FindAllStringSubmatch(header, -1) {
|
||||
if m[1] == key {
|
||||
return m[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sha256hex(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
|
||||
// ReadAllStates reads the current relay state (and power, if available) from every
|
||||
// configured Shelly device. Unreachable devices are logged and skipped — only
|
||||
// successfully read devices are returned, so callers should not assume all consumers
|
||||
// are present in the map.
|
||||
func (a *Actuator) ReadAllStates(ctx context.Context) (map[engine.Consumer]engine.DeviceStatus, error) {
|
||||
states := make(map[engine.Consumer]engine.DeviceStatus)
|
||||
|
||||
type entry struct {
|
||||
consumer engine.Consumer
|
||||
label string
|
||||
read func() (engine.DeviceStatus, error)
|
||||
}
|
||||
devices := []entry{
|
||||
{engine.ConsumerSGReady, "sg_ready", func() (engine.DeviceStatus, error) {
|
||||
on, err := a.ReadShellyGen1State(ctx, a.cfg.Shelly.SGReady)
|
||||
return engine.DeviceStatus{On: on}, err
|
||||
}},
|
||||
{engine.ConsumerWallboxA, "wallbox_a", func() (engine.DeviceStatus, error) {
|
||||
return a.ReadShellyGen2Status(ctx, a.cfg.Shelly.WallboxA)
|
||||
}},
|
||||
{engine.ConsumerWallboxB, "wallbox_b", func() (engine.DeviceStatus, error) {
|
||||
return a.ReadShellyGen2Status(ctx, a.cfg.Shelly.WallboxB)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, d := range devices {
|
||||
status, err := d.read()
|
||||
if err != nil {
|
||||
a.logger.Warn("could not read Shelly state", "device", d.label, "error", err)
|
||||
continue
|
||||
}
|
||||
states[d.consumer] = status
|
||||
}
|
||||
|
||||
if len(states) == 0 {
|
||||
return nil, fmt.Errorf("all Shelly devices unreachable")
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
// ReadShellyGen1State reads the current state of a Shelly Gen1 relay.
|
||||
func (a *Actuator) ReadShellyGen1State(ctx context.Context, dev config.ShellyDevice) (bool, error) {
|
||||
url := fmt.Sprintf("http://%s/relay/0", dev.IP)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reading shelly gen1 %s: %w", dev.IP, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var state struct {
|
||||
IsOn bool `json:"ison"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
|
||||
return false, fmt.Errorf("decoding shelly state: %w", err)
|
||||
}
|
||||
|
||||
return state.IsOn, nil
|
||||
}
|
||||
|
||||
// ReadShellyGen2Status reads the current state and active power of a Shelly Gen2 switch.
|
||||
// Power is only meaningful when the relay is on; it is 0 for devices without a power meter.
|
||||
func (a *Actuator) ReadShellyGen2Status(ctx context.Context, dev config.ShellyDevice) (engine.DeviceStatus, error) {
|
||||
body, err := a.gen2Request(ctx, dev, "/rpc/Switch.GetStatus", `{"id":0}`)
|
||||
if err != nil {
|
||||
return engine.DeviceStatus{}, err
|
||||
}
|
||||
var resp struct {
|
||||
Output bool `json:"output"`
|
||||
APower float64 `json:"apower"` // active power in W; present on PM variants
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return engine.DeviceStatus{}, fmt.Errorf("decoding shelly gen2 status: %w", err)
|
||||
}
|
||||
return engine.DeviceStatus{On: resp.Output, PowerW: resp.APower}, nil
|
||||
}
|
||||
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