Files
EMS/internal/actuator/actuator.go
Lutz Finsterle 99613c52ae 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>
2026-04-06 10:02:16 +02:00

296 lines
9.0 KiB
Go

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
}