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")
|
||||
}
|
||||
}
|
||||
195
internal/collector/collector.go
Normal file
195
internal/collector/collector.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/config"
|
||||
)
|
||||
|
||||
// SystemState represents the current state of the energy system,
|
||||
// collected from Prometheus metrics.
|
||||
type SystemState struct {
|
||||
Timestamp time.Time
|
||||
GridPowerW float64 // positive = import, negative = export
|
||||
BatterySOC float64 // 0-100
|
||||
PVProductionW float64 // current PV production in watts
|
||||
BatteryPowerW float64 // battery charge/discharge power
|
||||
CompressorPowerW float64 // heat pump compressor power
|
||||
AmbientTempC float64 // outdoor temperature
|
||||
PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export)
|
||||
PhaseL2PowerW float64 // per-phase grid power L2
|
||||
PhaseL3PowerW float64 // per-phase grid power L3
|
||||
}
|
||||
|
||||
// IsExporting returns true if the system is exporting to grid.
|
||||
func (s SystemState) IsExporting() bool {
|
||||
return s.GridPowerW < 0
|
||||
}
|
||||
|
||||
// ExportW returns the export power as a positive number, or 0 if importing.
|
||||
func (s SystemState) ExportW() float64 {
|
||||
if s.GridPowerW < 0 {
|
||||
return -s.GridPowerW
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ImportW returns the import power as a positive number, or 0 if exporting.
|
||||
func (s SystemState) ImportW() float64 {
|
||||
if s.GridPowerW > 0 {
|
||||
return s.GridPowerW
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Collector reads the current system state from a Prometheus instance.
|
||||
type Collector struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
metrics map[string]string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewCollector creates a new Prometheus collector.
|
||||
func NewCollector(cfg *config.Config, logger *slog.Logger) *Collector {
|
||||
return &Collector{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
baseURL: cfg.Prometheus.URL,
|
||||
metrics: cfg.Prometheus.Metrics,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Collect queries Prometheus for the current system state.
|
||||
func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
|
||||
state := SystemState{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
type metricTarget struct {
|
||||
name string
|
||||
dest *float64
|
||||
scale float64 // unit conversion multiplier (1.0 = no conversion)
|
||||
}
|
||||
|
||||
targets := []metricTarget{
|
||||
{"grid_power_exchange", &state.GridPowerW, 1},
|
||||
{"battery_soc", &state.BatterySOC, 1},
|
||||
{"pv_production", &state.PVProductionW, 1000}, // kW → W
|
||||
{"battery_power", &state.BatteryPowerW, 1},
|
||||
{"compressor_power", &state.CompressorPowerW, 1},
|
||||
{"ambient_temp", &state.AmbientTempC, 1},
|
||||
{"phase_l1_power", &state.PhaseL1PowerW, 1},
|
||||
{"phase_l2_power", &state.PhaseL2PowerW, 1},
|
||||
{"phase_l3_power", &state.PhaseL3PowerW, 1},
|
||||
}
|
||||
|
||||
for _, t := range targets {
|
||||
metricName, ok := c.metrics[t.name]
|
||||
if !ok {
|
||||
c.logger.Warn("metric not configured", "key", t.name)
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := c.queryInstant(ctx, metricName)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to query metric",
|
||||
"key", t.name,
|
||||
"metric", metricName,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
*t.dest = val * t.scale
|
||||
}
|
||||
|
||||
c.logger.Info("collected system state",
|
||||
"grid_w", state.GridPowerW,
|
||||
"soc", state.BatterySOC,
|
||||
"pv_w", state.PVProductionW,
|
||||
"compressor_w", state.CompressorPowerW,
|
||||
"l1_w", state.PhaseL1PowerW,
|
||||
"l2_w", state.PhaseL2PowerW,
|
||||
"l3_w", state.PhaseL3PowerW,
|
||||
)
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// promResponse represents the Prometheus API response for instant queries.
|
||||
type promResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data promData `json:"data"`
|
||||
}
|
||||
|
||||
type promData struct {
|
||||
ResultType string `json:"resultType"`
|
||||
Result []promResult `json:"result"`
|
||||
}
|
||||
|
||||
type promResult struct {
|
||||
Metric map[string]string `json:"metric"`
|
||||
Value [2]interface{} `json:"value"` // [timestamp, "value"]
|
||||
}
|
||||
|
||||
// queryInstant performs a Prometheus instant query and returns the scalar value.
|
||||
func (c *Collector) queryInstant(ctx context.Context, query string) (float64, error) {
|
||||
u, err := url.Parse(c.baseURL + "/api/v1/query")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing URL: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("query", query)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("querying prometheus: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("prometheus returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var promResp promResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&promResp); err != nil {
|
||||
return 0, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
if promResp.Status != "success" {
|
||||
return 0, fmt.Errorf("prometheus query failed: %s", promResp.Status)
|
||||
}
|
||||
|
||||
if len(promResp.Data.Result) == 0 {
|
||||
return 0, fmt.Errorf("no data for query %q", query)
|
||||
}
|
||||
|
||||
// Value is [timestamp, "string_value"]
|
||||
valStr, ok := promResp.Data.Result[0].Value[1].(string)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected value type for %q", query)
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(valStr, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing value %q: %w", valStr, err)
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
203
internal/config/config.go
Normal file
203
internal/config/config.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config is the top-level EMS configuration.
|
||||
type Config struct {
|
||||
Prometheus PrometheusConfig `yaml:"prometheus"`
|
||||
Shelly ShellyConfig `yaml:"shelly"`
|
||||
Viessmann ViessmannConfig `yaml:"viessmann"`
|
||||
SOC SOCThresholds `yaml:"soc_thresholds"`
|
||||
Hysteresis HysteresisConfig `yaml:"hysteresis"`
|
||||
Thresholds PowerThresholds `yaml:"thresholds"`
|
||||
Consumers ConsumersConfig `yaml:"consumers"`
|
||||
Strategic StrategicConfig `yaml:"strategic"`
|
||||
Season SeasonConfig `yaml:"season"`
|
||||
Forecast ForecastConfig `yaml:"forecast"`
|
||||
EMS EMSConfig `yaml:"ems"`
|
||||
}
|
||||
|
||||
// PrometheusConfig holds Prometheus connection settings.
|
||||
type PrometheusConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
Metrics map[string]string `yaml:"metrics"`
|
||||
}
|
||||
|
||||
// ShellyConfig holds all Shelly actuator addresses.
|
||||
type ShellyConfig struct {
|
||||
SGReady ShellyDevice `yaml:"sg_ready"`
|
||||
WallboxA ShellyDevice `yaml:"wallbox_a"`
|
||||
WallboxB ShellyDevice `yaml:"wallbox_b"`
|
||||
}
|
||||
|
||||
// ShellyDevice represents a single Shelly device.
|
||||
type ShellyDevice struct {
|
||||
IP string `yaml:"ip"`
|
||||
Gen int `yaml:"gen"`
|
||||
PowerW int `yaml:"power_w"`
|
||||
Password string `yaml:"password"` // optional; Gen2 uses HTTP Digest auth
|
||||
}
|
||||
|
||||
// ViessmannConfig holds Viessmann API credentials (write access for WW temp).
|
||||
type ViessmannConfig struct {
|
||||
TokenFile string `yaml:"token_file"`
|
||||
ClientID string `yaml:"client_id"`
|
||||
InstallationID string `yaml:"installation_id"`
|
||||
GatewaySerial string `yaml:"gateway_serial"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
}
|
||||
|
||||
// SOCThresholds defines the battery SOC levels that gate consumers.
|
||||
type SOCThresholds struct {
|
||||
BlockAll float64 `yaml:"block_all"`
|
||||
SGReadyOnly float64 `yaml:"sg_ready_only"`
|
||||
PlusWallboxA float64 `yaml:"plus_wallbox_a"`
|
||||
AllConsumers float64 `yaml:"all_consumers"`
|
||||
}
|
||||
|
||||
// HysteresisConfig defines timing parameters for switching decisions.
|
||||
type HysteresisConfig struct {
|
||||
ExportOnDuration string `yaml:"export_on_duration"`
|
||||
ImportOffDuration string `yaml:"import_off_duration"`
|
||||
MinRuntimeWallbox string `yaml:"min_runtime_wallbox"`
|
||||
MinRuntimeSGReady string `yaml:"min_runtime_sg_ready"`
|
||||
}
|
||||
|
||||
func (h *HysteresisConfig) ExportOnDurationParsed() time.Duration {
|
||||
d, _ := time.ParseDuration(h.ExportOnDuration)
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *HysteresisConfig) ImportOffDurationParsed() time.Duration {
|
||||
d, _ := time.ParseDuration(h.ImportOffDuration)
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *HysteresisConfig) MinRuntimeWallboxParsed() time.Duration {
|
||||
d, _ := time.ParseDuration(h.MinRuntimeWallbox)
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *HysteresisConfig) MinRuntimeSGReadyParsed() time.Duration {
|
||||
d, _ := time.ParseDuration(h.MinRuntimeSGReady)
|
||||
return d
|
||||
}
|
||||
|
||||
// PowerThresholds defines the grid power levels that trigger switching.
|
||||
// Export thresholds are negative (grid exports = negative grid power).
|
||||
type PowerThresholds struct {
|
||||
SGReadyExportW float64 `yaml:"sg_ready_export_w"`
|
||||
WWExportW float64 `yaml:"ww_export_w"`
|
||||
WallboxAExportW float64 `yaml:"wallbox_a_export_w"`
|
||||
WallboxAPhaseExportW float64 `yaml:"wallbox_a_phase_export_w"` // per-phase export for single-phase WallboxA
|
||||
WallboxBExportW float64 `yaml:"wallbox_b_export_w"`
|
||||
ImportOffW float64 `yaml:"import_off_w"`
|
||||
}
|
||||
|
||||
// ConsumersConfig holds per-consumer behavior thresholds.
|
||||
type ConsumersConfig struct {
|
||||
CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W)
|
||||
WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W)
|
||||
IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release
|
||||
}
|
||||
|
||||
// StrategicConfig holds PV forecast based strategic settings.
|
||||
type StrategicConfig struct {
|
||||
ForecastHighKWh float64 `yaml:"forecast_high_kwh"`
|
||||
ForecastMidKWh float64 `yaml:"forecast_mid_kwh"`
|
||||
WWBoostHighC float64 `yaml:"ww_boost_high_c"`
|
||||
WWBoostMidC float64 `yaml:"ww_boost_mid_c"`
|
||||
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
|
||||
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
|
||||
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
|
||||
ScheduleOn string `yaml:"schedule_on"`
|
||||
ScheduleOff string `yaml:"schedule_off"`
|
||||
}
|
||||
|
||||
// SeasonConfig defines the heating season by month range.
|
||||
type SeasonConfig struct {
|
||||
HeatingStartMonth int `yaml:"heating_start_month"`
|
||||
HeatingEndMonth int `yaml:"heating_end_month"`
|
||||
}
|
||||
|
||||
// ForecastConfig holds forecast.solar API parameters.
|
||||
type ForecastConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Lat float64 `yaml:"lat"`
|
||||
Lon float64 `yaml:"lon"`
|
||||
Declination int `yaml:"declination"` // panel tilt in degrees
|
||||
Azimuth int `yaml:"azimuth"` // degrees from south (south=0, west=90)
|
||||
KWp float64 `yaml:"kwp"` // installed peak power
|
||||
}
|
||||
|
||||
// EMSConfig holds operational settings for the EMS daemon.
|
||||
type EMSConfig struct {
|
||||
PollInterval string `yaml:"poll_interval"`
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
StateFile string `yaml:"state_file"`
|
||||
RecoveryTimeout string `yaml:"recovery_timeout"`
|
||||
OverrideTimeout string `yaml:"override_timeout"`
|
||||
}
|
||||
|
||||
func (e *EMSConfig) PollIntervalParsed() time.Duration {
|
||||
d, _ := time.ParseDuration(e.PollInterval)
|
||||
return d
|
||||
}
|
||||
|
||||
func (e *EMSConfig) RecoveryTimeoutParsed() time.Duration {
|
||||
d, err := time.ParseDuration(e.RecoveryTimeout)
|
||||
if err != nil {
|
||||
return time.Hour // safe default
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (e *EMSConfig) OverrideTimeoutParsed() time.Duration {
|
||||
d, err := time.ParseDuration(e.OverrideTimeout)
|
||||
if err != nil {
|
||||
return time.Hour // safe default
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Load reads and parses the YAML config file at the given path.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config file: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Prometheus.URL == "" {
|
||||
return fmt.Errorf("prometheus.url is required")
|
||||
}
|
||||
if c.EMS.PollInterval == "" {
|
||||
return fmt.Errorf("ems.poll_interval is required")
|
||||
}
|
||||
if _, err := time.ParseDuration(c.EMS.PollInterval); err != nil {
|
||||
return fmt.Errorf("ems.poll_interval %q: %w", c.EMS.PollInterval, err)
|
||||
}
|
||||
if c.EMS.ListenAddr == "" {
|
||||
return fmt.Errorf("ems.listen_addr is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
622
internal/engine/engine.go
Normal file
622
internal/engine/engine.go
Normal file
@@ -0,0 +1,622 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/collector"
|
||||
"github.com/tb/ems/internal/config"
|
||||
)
|
||||
|
||||
// Consumer identifies a controllable load.
|
||||
type Consumer int
|
||||
|
||||
const (
|
||||
ConsumerSGReady Consumer = iota
|
||||
ConsumerWW // domestic hot water boost via Viessmann API
|
||||
ConsumerWallboxA
|
||||
ConsumerWallboxB
|
||||
)
|
||||
|
||||
func (c Consumer) String() string {
|
||||
switch c {
|
||||
case ConsumerSGReady:
|
||||
return "sg_ready"
|
||||
case ConsumerWW:
|
||||
return "ww"
|
||||
case ConsumerWallboxA:
|
||||
return "wallbox_a"
|
||||
case ConsumerWallboxB:
|
||||
return "wallbox_b"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Action represents a switching decision.
|
||||
type Action struct {
|
||||
Consumer Consumer
|
||||
TurnOn bool
|
||||
Reason string
|
||||
TargetTempC float64 // non-zero for ConsumerWW: the absolute temperature to set
|
||||
}
|
||||
|
||||
// DeviceStatus holds the hardware-reported state of a consumer device,
|
||||
// as read back from the physical device each cycle.
|
||||
type DeviceStatus struct {
|
||||
On bool
|
||||
PowerW float64 // measured active power; 0 if device has no power meter
|
||||
}
|
||||
|
||||
// ConsumerState tracks the runtime state of a single consumer.
|
||||
type ConsumerState struct {
|
||||
Active bool
|
||||
ActivatedAt time.Time // when it was last turned on
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
LowPowerCycles int // consecutive cycles with power below minimum threshold
|
||||
}
|
||||
|
||||
// OverrideInfo is returned to callers that need to display or record override state.
|
||||
type OverrideInfo struct {
|
||||
Active bool
|
||||
Until time.Time
|
||||
}
|
||||
|
||||
// HysteresisState tracks the timing for hysteresis decisions.
|
||||
type HysteresisState struct {
|
||||
// How long has export been above the on-threshold continuously?
|
||||
ExportSinceAbove map[Consumer]time.Time
|
||||
// How long has import been above the off-threshold continuously?
|
||||
ImportSinceAbove time.Time
|
||||
}
|
||||
|
||||
// Engine is the EMS decision engine.
|
||||
// It is pure: given a state snapshot and timing info, it returns actions.
|
||||
// No network calls, no side effects — fully testable.
|
||||
type Engine struct {
|
||||
cfg *config.Config
|
||||
consumers map[Consumer]*ConsumerState
|
||||
hyst HysteresisState
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewEngine creates a new decision engine.
|
||||
func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
cfg: cfg,
|
||||
consumers: map[Consumer]*ConsumerState{
|
||||
ConsumerSGReady: {},
|
||||
ConsumerWW: {},
|
||||
ConsumerWallboxA: {},
|
||||
ConsumerWallboxB: {},
|
||||
},
|
||||
hyst: HysteresisState{
|
||||
ExportSinceAbove: make(map[Consumer]time.Time),
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Decide evaluates the current system state and returns a list of actions.
|
||||
// wwBoostC is the WW temperature boost in °C derived from the PV forecast
|
||||
// (0 = no forecast / forecast too low to warrant boosting).
|
||||
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC float64) []Action {
|
||||
var actions []Action
|
||||
|
||||
soc := state.BatterySOC
|
||||
gridW := state.GridPowerW // positive = import, negative = export
|
||||
heatingPeriod := e.isHeatingPeriod(now)
|
||||
wwWindow := e.isWWWindow(now)
|
||||
allowed := e.allowedConsumers(soc)
|
||||
|
||||
e.logger.Debug("decision input",
|
||||
"grid_w", gridW,
|
||||
"soc", soc,
|
||||
"heating_period", heatingPeriod,
|
||||
"ww_window", wwWindow,
|
||||
"ww_boost_c", wwBoostC,
|
||||
"allowed", allowed,
|
||||
)
|
||||
|
||||
// --- SOC emergency brake ---
|
||||
actions = append(actions, e.socEmergencyBrake(soc, now)...)
|
||||
|
||||
// --- WW window shutdown ---
|
||||
// If WW is active but we're outside the allowed time window, reset immediately.
|
||||
if cs := e.consumers[ConsumerWW]; cs.Active && !wwWindow {
|
||||
e.logger.Info("WW window ended, resetting DHW temperature")
|
||||
cs.Active = false
|
||||
actions = append(actions, Action{
|
||||
Consumer: ConsumerWW,
|
||||
TurnOn: false,
|
||||
TargetTempC: e.cfg.Strategic.WWBaseC,
|
||||
Reason: "WW time window ended",
|
||||
})
|
||||
}
|
||||
|
||||
// --- Shutdown logic (reverse priority order) ---
|
||||
if gridW > e.cfg.Thresholds.ImportOffW {
|
||||
if e.hyst.ImportSinceAbove.IsZero() {
|
||||
e.hyst.ImportSinceAbove = now
|
||||
}
|
||||
importDuration := now.Sub(e.hyst.ImportSinceAbove)
|
||||
if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() {
|
||||
if a := e.shutdownLastConsumer(now); a != nil {
|
||||
actions = append(actions, *a)
|
||||
e.hyst.ImportSinceAbove = time.Time{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
e.hyst.ImportSinceAbove = time.Time{}
|
||||
}
|
||||
|
||||
// --- Compressor idle: release SG-Ready early if heat pump stopped ---
|
||||
if cs := e.consumers[ConsumerSGReady]; cs.Active {
|
||||
if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) {
|
||||
cs.LowPowerCycles++
|
||||
e.logger.Debug("SG-Ready: compressor idle cycle",
|
||||
"compressor_w", state.CompressorPowerW,
|
||||
"idle_cycles", cs.LowPowerCycles,
|
||||
)
|
||||
if cs.LowPowerCycles >= e.cfg.Consumers.IdleCycles {
|
||||
e.logger.Info("SG-Ready released early: compressor idle",
|
||||
"idle_cycles", cs.LowPowerCycles,
|
||||
"compressor_w", state.CompressorPowerW,
|
||||
)
|
||||
cs.Active = false
|
||||
cs.LowPowerCycles = 0
|
||||
actions = append(actions, Action{
|
||||
Consumer: ConsumerSGReady,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("compressor idle for %d cycles", e.cfg.Consumers.IdleCycles),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
cs.LowPowerCycles = 0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Car not charging: release wallbox if Shelly PM shows no draw ---
|
||||
for _, wb := range []Consumer{ConsumerWallboxA, ConsumerWallboxB} {
|
||||
cs := e.consumers[wb]
|
||||
if !cs.Active {
|
||||
continue
|
||||
}
|
||||
if cs.LowPowerCycles >= e.cfg.Consumers.IdleCycles {
|
||||
e.logger.Info("wallbox released: car not charging",
|
||||
"consumer", wb,
|
||||
"low_power_cycles", cs.LowPowerCycles,
|
||||
)
|
||||
cs.Active = false
|
||||
cs.LowPowerCycles = 0
|
||||
actions = append(actions, Action{
|
||||
Consumer: wb,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("car not charging for %d cycles", e.cfg.Consumers.IdleCycles),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Turn-on logic (priority order) ---
|
||||
if gridW <= 0 {
|
||||
// P1: SG-Ready (heating period only)
|
||||
if heatingPeriod {
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
|
||||
// P2: WW boost (time window + forecast required)
|
||||
if wwWindow && wwBoostC > 0 {
|
||||
actions = append(actions, e.evaluateWWTurnOn(gridW, wwBoostC, allowed, now)...)
|
||||
}
|
||||
|
||||
// P3: Wallbox A (2kW, single-phase) — only if Wallbox B is not active.
|
||||
// Uses per-phase export check if available, otherwise falls back to total.
|
||||
// Re-reads Active state directly so a same-cycle activation of WallboxB blocks WallboxA.
|
||||
if !e.consumers[ConsumerWallboxB].Active {
|
||||
phaseGridW := gridW // fallback: total grid power
|
||||
if state.PhaseL1PowerW != 0 || state.PhaseL2PowerW != 0 || state.PhaseL3PowerW != 0 {
|
||||
phaseGridW = min3(state.PhaseL1PowerW, state.PhaseL2PowerW, state.PhaseL3PowerW)
|
||||
}
|
||||
threshold := e.cfg.Thresholds.WallboxAExportW
|
||||
if e.cfg.Thresholds.WallboxAPhaseExportW != 0 {
|
||||
threshold = e.cfg.Thresholds.WallboxAPhaseExportW
|
||||
}
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerWallboxA, phaseGridW, threshold,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
|
||||
// P4: Wallbox B (4kW, 3-phase) — only if Wallbox A is not active.
|
||||
// Re-reads Active state so a same-cycle activation of WallboxA blocks WallboxB.
|
||||
if !e.consumers[ConsumerWallboxA].Active {
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerWallboxB, gridW, e.cfg.Thresholds.WallboxBExportW,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
// min3 returns the minimum of three float64 values.
|
||||
func min3(a, b, c float64) float64 {
|
||||
if b < a {
|
||||
a = b
|
||||
}
|
||||
if c < a {
|
||||
return c
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// allowedConsumers returns which consumers are allowed based on SOC.
|
||||
func (e *Engine) allowedConsumers(soc float64) map[Consumer]bool {
|
||||
allowed := make(map[Consumer]bool)
|
||||
|
||||
if soc >= e.cfg.SOC.AllConsumers {
|
||||
allowed[ConsumerSGReady] = true
|
||||
allowed[ConsumerWW] = true
|
||||
allowed[ConsumerWallboxA] = true
|
||||
allowed[ConsumerWallboxB] = true
|
||||
} else if soc >= e.cfg.SOC.SGReadyOnly {
|
||||
allowed[ConsumerSGReady] = true
|
||||
allowed[ConsumerWW] = true
|
||||
allowed[ConsumerWallboxA] = true
|
||||
} else if soc >= e.cfg.SOC.BlockAll {
|
||||
allowed[ConsumerSGReady] = true
|
||||
allowed[ConsumerWW] = true
|
||||
}
|
||||
// below BlockAll: nothing allowed
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
// evaluateTurnOn checks if a consumer should be turned on.
|
||||
func (e *Engine) evaluateTurnOn(
|
||||
consumer Consumer,
|
||||
gridW float64,
|
||||
threshold float64,
|
||||
allowed map[Consumer]bool,
|
||||
now time.Time,
|
||||
) []Action {
|
||||
cs := e.consumers[consumer]
|
||||
|
||||
// Already active — nothing to do
|
||||
if cs.Active {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Manually overridden to OFF — respect until timeout
|
||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Not allowed by SOC
|
||||
if !allowed[consumer] {
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if export exceeds the threshold
|
||||
// gridW is negative for export, threshold is negative (e.g. -1800)
|
||||
// export > 1800W means gridW < -1800
|
||||
if gridW > threshold {
|
||||
// Not enough export
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Export is above threshold — track how long
|
||||
if _, ok := e.hyst.ExportSinceAbove[consumer]; !ok {
|
||||
e.hyst.ExportSinceAbove[consumer] = now
|
||||
}
|
||||
|
||||
exportDuration := now.Sub(e.hyst.ExportSinceAbove[consumer])
|
||||
if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
|
||||
// Not long enough yet
|
||||
return nil
|
||||
}
|
||||
|
||||
// All conditions met — turn on
|
||||
e.logger.Info("turning on consumer",
|
||||
"consumer", consumer,
|
||||
"grid_w", gridW,
|
||||
"threshold", threshold,
|
||||
"export_duration", exportDuration,
|
||||
)
|
||||
|
||||
cs.Active = true
|
||||
cs.ActivatedAt = now
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
|
||||
return []Action{{
|
||||
Consumer: consumer,
|
||||
TurnOn: true,
|
||||
Reason: fmt.Sprintf(
|
||||
"export %.0fW > %.0fW for %s",
|
||||
-gridW, -threshold, exportDuration,
|
||||
),
|
||||
}}
|
||||
}
|
||||
|
||||
// shutdownLastConsumer turns off the lowest-priority active consumer
|
||||
// that has exceeded its minimum runtime.
|
||||
func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
|
||||
// Reverse priority: WallboxB → WallboxA → WW → SGReady
|
||||
order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady}
|
||||
|
||||
for _, c := range order {
|
||||
cs := e.consumers[c]
|
||||
if !cs.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
// Manually overridden to ON — don't shut down until override expires
|
||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||
e.logger.Debug("skipping shutdown, consumer is manually overridden",
|
||||
"consumer", c,
|
||||
"override_until", cs.OverrideUntil.Format("15:04"),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
minRuntime := e.minRuntime(c)
|
||||
runtime := now.Sub(cs.ActivatedAt)
|
||||
if runtime < minRuntime {
|
||||
e.logger.Debug("skipping shutdown, min runtime not reached",
|
||||
"consumer", c,
|
||||
"runtime", runtime,
|
||||
"min_runtime", minRuntime,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
e.logger.Info("shutting down consumer",
|
||||
"consumer", c,
|
||||
"runtime", runtime,
|
||||
)
|
||||
|
||||
cs.Active = false
|
||||
a := &Action{
|
||||
Consumer: c,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("import detected, runtime %s", runtime),
|
||||
}
|
||||
if c == ConsumerWW {
|
||||
a.TargetTempC = e.cfg.Strategic.WWBaseC
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// socEmergencyBrake immediately shuts off consumers whose SOC threshold
|
||||
// is no longer met, ignoring minimum runtimes.
|
||||
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
var actions []Action
|
||||
allowed := e.allowedConsumers(soc)
|
||||
|
||||
for c, cs := range e.consumers {
|
||||
if !cs.Active {
|
||||
continue
|
||||
}
|
||||
if allowed[c] {
|
||||
continue
|
||||
}
|
||||
|
||||
e.logger.Warn("SOC emergency brake",
|
||||
"consumer", c,
|
||||
"soc", soc,
|
||||
)
|
||||
|
||||
cs.Active = false
|
||||
a := Action{
|
||||
Consumer: c,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("SOC emergency brake: %.0f%%", soc),
|
||||
}
|
||||
if c == ConsumerWW {
|
||||
a.TargetTempC = e.cfg.Strategic.WWBaseC
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
// isHeatingPeriod returns true if the current month is within the heating season.
|
||||
func (e *Engine) isHeatingPeriod(now time.Time) bool {
|
||||
month := int(now.Month())
|
||||
start := e.cfg.Season.HeatingStartMonth
|
||||
end := e.cfg.Season.HeatingEndMonth
|
||||
|
||||
// Handles wrap-around: e.g. October(10) to April(4)
|
||||
if start > end {
|
||||
return month >= start || month <= end
|
||||
}
|
||||
return month >= start && month <= end
|
||||
}
|
||||
|
||||
// minRuntime returns the minimum runtime for a consumer.
|
||||
func (e *Engine) minRuntime(c Consumer) time.Duration {
|
||||
switch c {
|
||||
case ConsumerSGReady:
|
||||
return e.cfg.Hysteresis.MinRuntimeSGReadyParsed()
|
||||
case ConsumerWallboxA, ConsumerWallboxB:
|
||||
return e.cfg.Hysteresis.MinRuntimeWallboxParsed()
|
||||
default:
|
||||
return 0 // WW has no minimum runtime
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateWWTurnOn checks whether WW boost should be activated.
|
||||
// Prerequisites (time window and forecast) are already verified by the caller.
|
||||
func (e *Engine) evaluateWWTurnOn(gridW, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
|
||||
cs := e.consumers[ConsumerWW]
|
||||
if cs.Active {
|
||||
return nil
|
||||
}
|
||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
if !allowed[ConsumerWW] {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
if gridW > e.cfg.Thresholds.WWExportW {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := e.hyst.ExportSinceAbove[ConsumerWW]; !ok {
|
||||
e.hyst.ExportSinceAbove[ConsumerWW] = now
|
||||
}
|
||||
exportDuration := now.Sub(e.hyst.ExportSinceAbove[ConsumerWW])
|
||||
if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetTemp := e.cfg.Strategic.WWBaseC + wwBoostC
|
||||
e.logger.Info("activating WW boost",
|
||||
"grid_w", gridW,
|
||||
"ww_boost_c", wwBoostC,
|
||||
"target_temp_c", targetTemp,
|
||||
"export_duration", exportDuration,
|
||||
)
|
||||
|
||||
cs.Active = true
|
||||
cs.ActivatedAt = now
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
|
||||
return []Action{{
|
||||
Consumer: ConsumerWW,
|
||||
TurnOn: true,
|
||||
TargetTempC: targetTemp,
|
||||
Reason: fmt.Sprintf("export %.0fW for %s, WW boost +%.0f°C", -gridW, exportDuration, wwBoostC),
|
||||
}}
|
||||
}
|
||||
|
||||
// isWWWindow returns true if the current time falls within the configured WW boost window.
|
||||
func (e *Engine) isWWWindow(now time.Time) bool {
|
||||
start, err1 := parseTimeOfDay(e.cfg.Strategic.WWWindowStart, now)
|
||||
end, err2 := parseTimeOfDay(e.cfg.Strategic.WWWindowEnd, now)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return now.After(start) && now.Before(end)
|
||||
}
|
||||
|
||||
// parseTimeOfDay parses "HH:MM" and returns a time.Time on the same day as ref.
|
||||
func parseTimeOfDay(s string, ref time.Time) (time.Time, error) {
|
||||
var h, m int
|
||||
if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid time-of-day %q: %w", s, err)
|
||||
}
|
||||
return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location()), nil
|
||||
}
|
||||
|
||||
// ConsumerStates returns a snapshot of all consumer states (for metrics).
|
||||
func (e *Engine) ConsumerStates() map[Consumer]bool {
|
||||
states := make(map[Consumer]bool)
|
||||
for c, cs := range e.consumers {
|
||||
states[c] = cs.Active
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// RecoverState injects externally-read consumer states on startup.
|
||||
// Does not set override flags — startup state is treated as the EMS baseline.
|
||||
func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) {
|
||||
for c, status := range states {
|
||||
if cs, ok := e.consumers[c]; ok {
|
||||
cs.Active = status.On
|
||||
cs.ManualOverride = false
|
||||
cs.OverrideUntil = time.Time{}
|
||||
cs.LowPowerCycles = 0
|
||||
// ActivatedAt left as zero: unknown start time means the consumer
|
||||
// is always considered to have exceeded its minimum runtime.
|
||||
cs.ActivatedAt = time.Time{}
|
||||
}
|
||||
}
|
||||
e.logger.Info("consumer state recovered from Shelly read-back",
|
||||
"sg_ready", states[ConsumerSGReady].On,
|
||||
"wallbox_a", states[ConsumerWallboxA].On,
|
||||
"wallbox_b", states[ConsumerWallboxB].On,
|
||||
)
|
||||
}
|
||||
|
||||
// SyncHardwareState compares live hardware states against the engine's internal state.
|
||||
// Discrepancies indicate an external change (manual override via Shelly app etc.).
|
||||
// On mismatch: engine state is updated to match hardware, and the consumer is locked
|
||||
// from EMS control for overrideTimeout.
|
||||
// On match: expired overrides are cleared, resuming normal EMS control.
|
||||
// Power readings (from PM-capable devices) update the low-power cycle counter for
|
||||
// car-not-charging detection.
|
||||
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration) {
|
||||
for c, status := range states {
|
||||
cs, ok := e.consumers[c]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if cs.Active != status.On {
|
||||
// External change detected
|
||||
e.logger.Info("manual override detected — external state change",
|
||||
"consumer", c,
|
||||
"engine_state", cs.Active,
|
||||
"hardware_state", status.On,
|
||||
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
||||
)
|
||||
cs.Active = status.On
|
||||
cs.ManualOverride = true
|
||||
cs.OverrideUntil = now.Add(overrideTimeout)
|
||||
cs.LowPowerCycles = 0
|
||||
if !status.On {
|
||||
cs.ActivatedAt = time.Time{}
|
||||
} else {
|
||||
cs.ActivatedAt = now
|
||||
}
|
||||
} else if cs.ManualOverride && now.After(cs.OverrideUntil) {
|
||||
// Override expired and state matches — resume EMS control
|
||||
cs.ManualOverride = false
|
||||
cs.OverrideUntil = time.Time{}
|
||||
e.logger.Info("manual override expired, resuming EMS control", "consumer", c)
|
||||
}
|
||||
|
||||
// Track low-power cycles for car-not-charging detection (PM devices only).
|
||||
// Only meaningful when PowerW > 0 (i.e., device has a power meter and is on).
|
||||
if status.PowerW > 0 {
|
||||
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
|
||||
cs.LowPowerCycles++
|
||||
e.logger.Debug("wallbox low power cycle",
|
||||
"consumer", c,
|
||||
"power_w", status.PowerW,
|
||||
"low_power_cycles", cs.LowPowerCycles,
|
||||
)
|
||||
} else {
|
||||
cs.LowPowerCycles = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides returns current override info for all consumers that are overridden.
|
||||
func (e *Engine) Overrides() map[Consumer]OverrideInfo {
|
||||
result := make(map[Consumer]OverrideInfo)
|
||||
for c, cs := range e.consumers {
|
||||
if cs.ManualOverride {
|
||||
result[c] = OverrideInfo{Active: true, Until: cs.OverrideUntil}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
386
internal/engine/engine_test.go
Normal file
386
internal/engine/engine_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/collector"
|
||||
"github.com/tb/ems/internal/config"
|
||||
)
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
SOC: config.SOCThresholds{
|
||||
BlockAll: 50,
|
||||
SGReadyOnly: 70,
|
||||
PlusWallboxA: 90,
|
||||
AllConsumers: 90,
|
||||
},
|
||||
Hysteresis: config.HysteresisConfig{
|
||||
ExportOnDuration: "4m",
|
||||
ImportOffDuration: "6m",
|
||||
MinRuntimeWallbox: "15m",
|
||||
MinRuntimeSGReady: "30m",
|
||||
},
|
||||
Thresholds: config.PowerThresholds{
|
||||
SGReadyExportW: -500,
|
||||
WWExportW: -500,
|
||||
WallboxAExportW: -1800,
|
||||
WallboxBExportW: -3800,
|
||||
ImportOffW: 200,
|
||||
},
|
||||
Consumers: config.ConsumersConfig{
|
||||
CompressorIdleW: 50,
|
||||
WallboxMinChargeW: 50,
|
||||
IdleCycles: 3,
|
||||
},
|
||||
Season: config.SeasonConfig{
|
||||
HeatingStartMonth: 10,
|
||||
HeatingEndMonth: 4,
|
||||
},
|
||||
Strategic: config.StrategicConfig{
|
||||
WWBaseC: 50,
|
||||
WWWindowStart: "12:30",
|
||||
WWWindowEnd: "18:00",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
}
|
||||
|
||||
func TestSOCBlocksAll(t *testing.T) {
|
||||
eng := NewEngine(testConfig(), testLogger())
|
||||
now := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // January = heating period
|
||||
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -3000, // 3kW export
|
||||
BatterySOC: 40, // below 50% → all blocked
|
||||
}
|
||||
|
||||
actions := eng.Decide(state, now, 0)
|
||||
if len(actions) != 0 {
|
||||
t.Errorf("expected no actions with SOC 40%%, got %d actions", len(actions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCAllowsSGReady(t *testing.T) {
|
||||
eng := NewEngine(testConfig(), testLogger())
|
||||
|
||||
// Simulate export for >4 minutes to pass hysteresis
|
||||
base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // January = heating
|
||||
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -600, // 600W export, above SG-Ready threshold
|
||||
BatterySOC: 60, // 50-70% → SG-Ready only
|
||||
}
|
||||
|
||||
// First call — starts hysteresis timer
|
||||
actions := eng.Decide(state, base, 0)
|
||||
if len(actions) != 0 {
|
||||
t.Errorf("expected no actions on first call (hysteresis), got %d", len(actions))
|
||||
}
|
||||
|
||||
// Second call after 5 minutes — hysteresis passed
|
||||
actions = eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("expected 1 action after hysteresis, got %d", len(actions))
|
||||
}
|
||||
if actions[0].Consumer != ConsumerSGReady {
|
||||
t.Errorf("expected SG-Ready, got %v", actions[0].Consumer)
|
||||
}
|
||||
if !actions[0].TurnOn {
|
||||
t.Error("expected TurnOn=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCBlocksWallboxAt60(t *testing.T) {
|
||||
eng := NewEngine(testConfig(), testLogger())
|
||||
base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -5000, // massive export
|
||||
BatterySOC: 60, // only SG-Ready allowed
|
||||
}
|
||||
|
||||
// Pass hysteresis
|
||||
eng.Decide(state, base, 0)
|
||||
actions := eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||
|
||||
// Should only get SG-Ready, no wallboxes
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerWallboxA || a.Consumer == ConsumerWallboxB {
|
||||
t.Errorf("wallbox should not be activated at SOC 60%%, got %v", a.Consumer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSGReadyOnlyInHeatingPeriod(t *testing.T) {
|
||||
eng := NewEngine(testConfig(), testLogger())
|
||||
|
||||
// July = NOT heating period
|
||||
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -600,
|
||||
BatterySOC: 95, // all consumers allowed
|
||||
}
|
||||
|
||||
// Pass hysteresis
|
||||
eng.Decide(state, base, 0)
|
||||
actions := eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerSGReady {
|
||||
t.Error("SG-Ready should not activate outside heating period")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCEmergencyBrake(t *testing.T) {
|
||||
eng := NewEngine(testConfig(), testLogger())
|
||||
base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// First, activate SG-Ready with high SOC
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -600,
|
||||
BatterySOC: 95,
|
||||
}
|
||||
eng.Decide(state, base, 0)
|
||||
eng.Decide(state, base.Add(5*time.Minute), 0)
|
||||
|
||||
// Now SOC drops below threshold
|
||||
state.BatterySOC = 45
|
||||
state.GridPowerW = -600 // still exporting, but SOC is too low
|
||||
|
||||
actions := eng.Decide(state, base.Add(10*time.Minute), 0)
|
||||
|
||||
foundBrake := false
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerSGReady && !a.TurnOn {
|
||||
foundBrake = true
|
||||
}
|
||||
}
|
||||
if !foundBrake {
|
||||
t.Error("expected SOC emergency brake to shut off SG-Ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownReverseOrder(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Hysteresis.MinRuntimeWallbox = "0s"
|
||||
cfg.Hysteresis.MinRuntimeSGReady = "0s"
|
||||
cfg.Hysteresis.ImportOffDuration = "0s"
|
||||
|
||||
eng := NewEngine(cfg, testLogger())
|
||||
base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Inject WallboxB + SG-Ready as active (simulating recovery from a previous run
|
||||
// where WallboxB was switched on manually, bypassing the mutex).
|
||||
eng.RecoverState(map[Consumer]DeviceStatus{
|
||||
ConsumerWallboxB: {On: true},
|
||||
ConsumerSGReady: {On: true},
|
||||
})
|
||||
|
||||
// Import detected — WallboxB should be shut down first (reverse priority order)
|
||||
state := collector.SystemState{
|
||||
GridPowerW: 500, // importing
|
||||
BatterySOC: 95,
|
||||
}
|
||||
actions := eng.Decide(state, base, 0)
|
||||
|
||||
if len(actions) == 0 {
|
||||
t.Fatal("expected shutdown action")
|
||||
}
|
||||
found := false
|
||||
for _, a := range actions {
|
||||
if !a.TurnOn && a.Consumer == ConsumerWallboxB {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected WallboxB to be shut down first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeatingPeriodDetection(t *testing.T) {
|
||||
eng := NewEngine(testConfig(), testLogger())
|
||||
|
||||
tests := []struct {
|
||||
month time.Month
|
||||
expected bool
|
||||
}{
|
||||
{time.January, true},
|
||||
{time.February, true},
|
||||
{time.March, true},
|
||||
{time.April, true},
|
||||
{time.May, false},
|
||||
{time.June, false},
|
||||
{time.July, false},
|
||||
{time.August, false},
|
||||
{time.September, false},
|
||||
{time.October, true},
|
||||
{time.November, true},
|
||||
{time.December, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.month.String(), func(t *testing.T) {
|
||||
date := time.Date(2025, tt.month, 15, 12, 0, 0, 0, time.UTC)
|
||||
if got := eng.isHeatingPeriod(date); got != tt.expected {
|
||||
t.Errorf("month %s: got %v, want %v", tt.month, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWallboxMutualExclusion(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Hysteresis.ExportOnDuration = "0s"
|
||||
cfg.Hysteresis.ImportOffDuration = "0s"
|
||||
|
||||
eng := NewEngine(cfg, testLogger())
|
||||
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer (no SG-Ready)
|
||||
|
||||
// Massive export — enough to meet both wallbox thresholds
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -5000,
|
||||
BatterySOC: 95,
|
||||
}
|
||||
|
||||
// First Decide: WallboxA should activate (P3), WallboxB must be blocked (mutex)
|
||||
actions := eng.Decide(state, base, 0)
|
||||
|
||||
var wbAOn, wbBOn bool
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerWallboxA && a.TurnOn {
|
||||
wbAOn = true
|
||||
}
|
||||
if a.Consumer == ConsumerWallboxB && a.TurnOn {
|
||||
wbBOn = true
|
||||
}
|
||||
}
|
||||
if !wbAOn {
|
||||
t.Error("expected WallboxA to activate")
|
||||
}
|
||||
if wbBOn {
|
||||
t.Error("WallboxB must not activate while WallboxA is active (mutex)")
|
||||
}
|
||||
|
||||
// Second Decide with WallboxA still active: WallboxB must still be blocked
|
||||
actions = eng.Decide(state, base.Add(2*time.Minute), 0)
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerWallboxB && a.TurnOn {
|
||||
t.Error("WallboxB must not activate while WallboxA is active (second cycle)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarNotChargingReleasesWallbox(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Hysteresis.ExportOnDuration = "0s"
|
||||
cfg.Consumers.IdleCycles = 3
|
||||
cfg.Consumers.WallboxMinChargeW = 50
|
||||
|
||||
eng := NewEngine(cfg, testLogger())
|
||||
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Activate WallboxA
|
||||
state := collector.SystemState{GridPowerW: -2000, BatterySOC: 95}
|
||||
actions := eng.Decide(state, base, 0)
|
||||
if len(actions) != 1 || actions[0].Consumer != ConsumerWallboxA || !actions[0].TurnOn {
|
||||
t.Fatalf("expected WallboxA to activate, got %v", actions)
|
||||
}
|
||||
|
||||
// Simulate 3 cycles with Shelly PM reading near zero (car not charging / unplugged)
|
||||
lowPower := DeviceStatus{On: true, PowerW: 10} // 10W < 50W threshold
|
||||
for i := 0; i < 3; i++ {
|
||||
eng.SyncHardwareState(
|
||||
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
|
||||
base.Add(time.Duration(i+1)*2*time.Minute),
|
||||
time.Hour,
|
||||
)
|
||||
}
|
||||
|
||||
// Decide should now release WallboxA
|
||||
actions = eng.Decide(state, base.Add(8*time.Minute), 0)
|
||||
found := false
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerWallboxA && !a.TurnOn {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected WallboxA to be turned off after 3 low-power cycles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressorIdleReleasesSGReady(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Hysteresis.ExportOnDuration = "0s"
|
||||
cfg.Hysteresis.MinRuntimeSGReady = "30m" // long min-runtime
|
||||
cfg.Consumers.IdleCycles = 3
|
||||
cfg.Consumers.CompressorIdleW = 50
|
||||
|
||||
eng := NewEngine(cfg, testLogger())
|
||||
base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // January = heating period
|
||||
|
||||
// Activate SG-Ready
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -600,
|
||||
BatterySOC: 95,
|
||||
CompressorPowerW: 1500, // compressor running
|
||||
}
|
||||
actions := eng.Decide(state, base, 0)
|
||||
if len(actions) != 1 || actions[0].Consumer != ConsumerSGReady || !actions[0].TurnOn {
|
||||
t.Fatalf("expected SG-Ready to activate, got %v", actions)
|
||||
}
|
||||
|
||||
// Compressor drops to idle — 3 consecutive cycles
|
||||
state.CompressorPowerW = 10 // below idle threshold
|
||||
for i := 1; i <= 3; i++ {
|
||||
actions = eng.Decide(state, base.Add(time.Duration(i)*2*time.Minute), 0)
|
||||
}
|
||||
|
||||
// After 3 idle cycles, SG-Ready should be released despite min-runtime not reached
|
||||
found := false
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerSGReady && !a.TurnOn {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected SG-Ready to be released early when compressor is idle for 3 cycles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinRuntimeRespected(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Hysteresis.ExportOnDuration = "0s"
|
||||
cfg.Hysteresis.MinRuntimeWallbox = "15m"
|
||||
|
||||
eng := NewEngine(cfg, testLogger())
|
||||
base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer
|
||||
|
||||
// Activate Wallbox A
|
||||
state := collector.SystemState{
|
||||
GridPowerW: -2000,
|
||||
BatterySOC: 95,
|
||||
}
|
||||
eng.Decide(state, base, 0)
|
||||
|
||||
// Try to shutdown after 5 minutes (< 15min minimum)
|
||||
state.GridPowerW = 500
|
||||
eng.Decide(state, base.Add(1*time.Minute), 0) // start import timer
|
||||
|
||||
actions := eng.Decide(state, base.Add(8*time.Minute), 0) // import for >6min
|
||||
|
||||
for _, a := range actions {
|
||||
if a.Consumer == ConsumerWallboxA && !a.TurnOn {
|
||||
t.Error("Wallbox A should not be shut down before 15 min runtime")
|
||||
}
|
||||
}
|
||||
}
|
||||
171
internal/forecast/forecast.go
Normal file
171
internal/forecast/forecast.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/config"
|
||||
)
|
||||
|
||||
// Result holds the fetched forecast for a single day.
|
||||
type Result struct {
|
||||
Date time.Time
|
||||
TotalKWh float64
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
// Quality returns a human-readable label based on configured thresholds.
|
||||
func (r Result) Quality(cfg config.StrategicConfig) string {
|
||||
switch {
|
||||
case r.TotalKWh >= cfg.ForecastHighKWh:
|
||||
return "Sehr gut"
|
||||
case r.TotalKWh >= cfg.ForecastMidKWh:
|
||||
return "Gut"
|
||||
case r.TotalKWh >= 5:
|
||||
return "Mittel"
|
||||
default:
|
||||
return "Schwach"
|
||||
}
|
||||
}
|
||||
|
||||
// QualityIcon returns a weather icon for the forecast quality.
|
||||
func (r Result) QualityIcon(cfg config.StrategicConfig) string {
|
||||
switch {
|
||||
case r.TotalKWh >= cfg.ForecastHighKWh:
|
||||
return "☀️"
|
||||
case r.TotalKWh >= cfg.ForecastMidKWh:
|
||||
return "🌤️"
|
||||
case r.TotalKWh >= 5:
|
||||
return "⛅"
|
||||
default:
|
||||
return "☁️"
|
||||
}
|
||||
}
|
||||
|
||||
// Client fetches daily PV forecasts from forecast.solar and caches the result.
|
||||
type Client struct {
|
||||
cfg config.ForecastConfig
|
||||
strategic config.StrategicConfig
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
cached *Result
|
||||
}
|
||||
|
||||
// NewClient creates a new forecast client.
|
||||
func NewClient(cfg config.ForecastConfig, strategic config.StrategicConfig, logger *slog.Logger) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
strategic: strategic,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Today returns the forecast for today, fetching from the API if needed.
|
||||
// Returns a zero Result and no error if forecasting is disabled.
|
||||
func (c *Client) Today(ctx context.Context) (Result, error) {
|
||||
if !c.cfg.Enabled {
|
||||
return Result{}, nil
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
cached := c.cached
|
||||
c.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
if cached != nil && cached.Date.Equal(today) {
|
||||
return *cached, nil
|
||||
}
|
||||
|
||||
result, err := c.fetch(ctx, today)
|
||||
if err != nil {
|
||||
// Return stale cache rather than nothing, if we have it
|
||||
if cached != nil {
|
||||
c.logger.Warn("forecast fetch failed, using stale cache",
|
||||
"error", err,
|
||||
"cached_date", cached.Date.Format("2006-01-02"),
|
||||
)
|
||||
return *cached, nil
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.cached = &result
|
||||
c.mu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// forecastResponse is the forecast.solar API response structure.
|
||||
type forecastResponse struct {
|
||||
Result struct {
|
||||
WattHoursDay map[string]float64 `json:"watt_hours_day"`
|
||||
} `json:"result"`
|
||||
Message struct {
|
||||
Code int `json:"code"`
|
||||
Text string `json:"text"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, day time.Time) (Result, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://api.forecast.solar/estimate/%.4f/%.4f/%d/%d/%.1f",
|
||||
c.cfg.Lat, c.cfg.Lon,
|
||||
c.cfg.Declination, c.cfg.Azimuth,
|
||||
c.cfg.KWp,
|
||||
)
|
||||
|
||||
c.logger.Debug("fetching forecast", "url", url)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("fetching forecast: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Result{}, fmt.Errorf("forecast.solar returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var fr forecastResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fr); err != nil {
|
||||
return Result{}, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
dateKey := day.Format("2006-01-02")
|
||||
wh, ok := fr.Result.WattHoursDay[dateKey]
|
||||
if !ok {
|
||||
return Result{}, fmt.Errorf("no forecast data for %s", dateKey)
|
||||
}
|
||||
|
||||
result := Result{
|
||||
Date: day,
|
||||
TotalKWh: wh / 1000.0,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
c.logger.Info("forecast fetched",
|
||||
"date", dateKey,
|
||||
"kwh", result.TotalKWh,
|
||||
"quality", result.Quality(c.strategic),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
120
internal/metrics/metrics.go
Normal file
120
internal/metrics/metrics.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/tb/ems/internal/engine"
|
||||
)
|
||||
|
||||
// Metrics holds all EMS Prometheus metrics.
|
||||
type Metrics struct {
|
||||
// Current system state
|
||||
GridPowerW prometheus.Gauge
|
||||
BatterySOC prometheus.Gauge
|
||||
PVProductionW prometheus.Gauge
|
||||
|
||||
// Consumer states
|
||||
ConsumerActive *prometheus.GaugeVec
|
||||
|
||||
// Decision tracking
|
||||
SwitchCyclesTotal *prometheus.CounterVec
|
||||
DecisionDuration prometheus.Histogram
|
||||
|
||||
// Actuator health
|
||||
ActuatorErrors *prometheus.CounterVec
|
||||
APILatency *prometheus.HistogramVec
|
||||
|
||||
// Cumulative
|
||||
SelfConsumedKWh prometheus.Counter
|
||||
GridExportKWh prometheus.Counter
|
||||
GridImportKWh prometheus.Counter
|
||||
}
|
||||
|
||||
// NewMetrics creates and registers all EMS metrics.
|
||||
func NewMetrics(reg prometheus.Registerer) *Metrics {
|
||||
m := &Metrics{
|
||||
GridPowerW: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ems_grid_power_watts",
|
||||
Help: "Current grid power exchange in watts (positive=import, negative=export)",
|
||||
}),
|
||||
BatterySOC: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ems_battery_soc_percent",
|
||||
Help: "Current battery state of charge in percent",
|
||||
}),
|
||||
PVProductionW: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ems_pv_production_watts",
|
||||
Help: "Current PV production in watts",
|
||||
}),
|
||||
ConsumerActive: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "ems_consumer_active",
|
||||
Help: "Whether a consumer is currently active (1=on, 0=off)",
|
||||
}, []string{"consumer"}),
|
||||
SwitchCyclesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ems_switch_cycles_total",
|
||||
Help: "Total number of switch cycles per consumer",
|
||||
}, []string{"consumer", "action"}),
|
||||
DecisionDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "ems_decision_duration_seconds",
|
||||
Help: "Time taken for the decision engine to evaluate",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}),
|
||||
ActuatorErrors: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ems_actuator_errors_total",
|
||||
Help: "Total actuator errors per consumer",
|
||||
}, []string{"consumer"}),
|
||||
APILatency: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "ems_api_latency_seconds",
|
||||
Help: "API call latency per source",
|
||||
Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||
}, []string{"source"}),
|
||||
SelfConsumedKWh: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ems_self_consumed_kwh_total",
|
||||
Help: "Total energy self-consumed in kWh (estimated)",
|
||||
}),
|
||||
GridExportKWh: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ems_grid_export_kwh_total",
|
||||
Help: "Total energy exported to grid in kWh (estimated)",
|
||||
}),
|
||||
GridImportKWh: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ems_grid_import_kwh_total",
|
||||
Help: "Total energy imported from grid in kWh (estimated)",
|
||||
}),
|
||||
}
|
||||
|
||||
reg.MustRegister(
|
||||
m.GridPowerW,
|
||||
m.BatterySOC,
|
||||
m.PVProductionW,
|
||||
m.ConsumerActive,
|
||||
m.SwitchCyclesTotal,
|
||||
m.DecisionDuration,
|
||||
m.ActuatorErrors,
|
||||
m.APILatency,
|
||||
m.SelfConsumedKWh,
|
||||
m.GridExportKWh,
|
||||
m.GridImportKWh,
|
||||
)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// RecordActions records switching actions in the metrics.
|
||||
func (m *Metrics) RecordActions(actions []engine.Action) {
|
||||
for _, a := range actions {
|
||||
action := "on"
|
||||
if !a.TurnOn {
|
||||
action = "off"
|
||||
}
|
||||
m.SwitchCyclesTotal.WithLabelValues(a.Consumer.String(), action).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateConsumerStates updates the consumer active gauges.
|
||||
func (m *Metrics) UpdateConsumerStates(states map[engine.Consumer]bool) {
|
||||
for c, active := range states {
|
||||
val := 0.0
|
||||
if active {
|
||||
val = 1.0
|
||||
}
|
||||
m.ConsumerActive.WithLabelValues(c.String()).Set(val)
|
||||
}
|
||||
}
|
||||
531
internal/status/status.go
Normal file
531
internal/status/status.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/collector"
|
||||
"github.com/tb/ems/internal/config"
|
||||
"github.com/tb/ems/internal/engine"
|
||||
"github.com/tb/ems/internal/forecast"
|
||||
)
|
||||
|
||||
// consumerMeta holds static display info for each consumer.
|
||||
var consumerMeta = map[engine.Consumer]struct{ Label, Icon string }{
|
||||
engine.ConsumerSGReady: {"Wärmepumpe Boost (SG-Ready)", "🔥"},
|
||||
engine.ConsumerWW: {"Warmwasser Boost", "🌡️"},
|
||||
engine.ConsumerWallboxA: {"Wallbox A (2 kW)", "🔌"},
|
||||
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
|
||||
}
|
||||
|
||||
// consumerOrder defines the display order of consumers.
|
||||
var consumerOrder = []engine.Consumer{
|
||||
engine.ConsumerSGReady,
|
||||
engine.ConsumerWW,
|
||||
engine.ConsumerWallboxA,
|
||||
engine.ConsumerWallboxB,
|
||||
}
|
||||
|
||||
// consumerRecord tracks the state of a single consumer across cycles.
|
||||
type consumerRecord struct {
|
||||
active bool
|
||||
since time.Time
|
||||
reason string
|
||||
manualOverride bool
|
||||
overrideUntil time.Time
|
||||
}
|
||||
|
||||
// Store holds the latest EMS snapshot and is safe for concurrent use.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
lastUpdate time.Time
|
||||
errMsg string
|
||||
state collector.SystemState
|
||||
dryRun bool
|
||||
wwConfigured bool
|
||||
strategic config.StrategicConfig
|
||||
consumers map[engine.Consumer]*consumerRecord
|
||||
fcResult *forecast.Result
|
||||
}
|
||||
|
||||
// NewStore creates a new status store.
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig) *Store {
|
||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||
for _, c := range consumerOrder {
|
||||
consumers[c] = &consumerRecord{}
|
||||
}
|
||||
return &Store{
|
||||
dryRun: dryRun,
|
||||
wwConfigured: wwConfigured,
|
||||
strategic: strategic,
|
||||
consumers: consumers,
|
||||
}
|
||||
}
|
||||
|
||||
// Update records the latest state, actions, overrides, and forecast for this cycle.
|
||||
func (s *Store) Update(state collector.SystemState, actions []engine.Action, overrides map[engine.Consumer]engine.OverrideInfo, fcResult *forecast.Result, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.lastUpdate = time.Now()
|
||||
s.state = state
|
||||
|
||||
if err != nil {
|
||||
s.errMsg = err.Error()
|
||||
} else {
|
||||
s.errMsg = ""
|
||||
}
|
||||
|
||||
if fcResult != nil {
|
||||
s.fcResult = fcResult
|
||||
}
|
||||
|
||||
for _, a := range actions {
|
||||
rec, ok := s.consumers[a.Consumer]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rec.active = a.TurnOn
|
||||
rec.since = time.Now()
|
||||
rec.reason = a.Reason
|
||||
}
|
||||
|
||||
// Sync override state for all consumers
|
||||
for c, rec := range s.consumers {
|
||||
if info, overridden := overrides[c]; overridden {
|
||||
rec.manualOverride = info.Active
|
||||
rec.overrideUntil = info.Until
|
||||
} else {
|
||||
rec.manualOverride = false
|
||||
rec.overrideUntil = time.Time{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Template data types ---
|
||||
|
||||
type consumerView struct {
|
||||
Icon string
|
||||
Label string
|
||||
ConsumerKey string // e.g. "wallbox_a" — used for the override form
|
||||
Active bool
|
||||
Since time.Time
|
||||
Reason string
|
||||
Unconfigured bool
|
||||
CanOverride bool // true for Shelly consumers (hardware read-back available)
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
}
|
||||
|
||||
type forecastView struct {
|
||||
Available bool
|
||||
KWh float64
|
||||
Icon string
|
||||
Quality string
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
LastUpdate time.Time
|
||||
ErrMsg string
|
||||
DryRun bool
|
||||
BatterySOC float64
|
||||
GridPowerW float64
|
||||
PVProductionW float64
|
||||
AmbientTempC float64
|
||||
IsExporting bool
|
||||
AbsGridW float64
|
||||
Forecast forecastView
|
||||
Consumers []consumerView
|
||||
}
|
||||
|
||||
// SyncConsumerStates updates active flags for all consumers directly from the
|
||||
// engine's current state. Called every cycle so the status page reflects reality
|
||||
// even when no Action was produced (e.g. after a manual override is detected).
|
||||
func (s *Store) SyncConsumerStates(states map[engine.Consumer]bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for c, active := range states {
|
||||
if rec, ok := s.consumers[c]; ok {
|
||||
rec.active = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns an HTTP handler that renders the status page.
|
||||
func (s *Store) Handler() http.HandlerFunc {
|
||||
tmpl := template.Must(template.New("status").Funcs(template.FuncMap{
|
||||
"formatW": formatW,
|
||||
"formatSince": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
d := time.Since(t).Round(time.Minute)
|
||||
if d < time.Minute {
|
||||
return "gerade eben"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("seit %d min", int(d.Minutes()))
|
||||
}
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("seit %d h", h)
|
||||
}
|
||||
return fmt.Sprintf("seit %d h %d min", h, m)
|
||||
},
|
||||
"socColor": func(soc float64) string {
|
||||
switch {
|
||||
case soc >= 80:
|
||||
return "#16a34a"
|
||||
case soc >= 50:
|
||||
return "#d97706"
|
||||
default:
|
||||
return "#dc2626"
|
||||
}
|
||||
},
|
||||
}).Parse(htmlTemplate))
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
data := pageData{
|
||||
LastUpdate: s.lastUpdate,
|
||||
ErrMsg: s.errMsg,
|
||||
DryRun: s.dryRun,
|
||||
BatterySOC: s.state.BatterySOC,
|
||||
GridPowerW: s.state.GridPowerW,
|
||||
PVProductionW: s.state.PVProductionW,
|
||||
AmbientTempC: s.state.AmbientTempC,
|
||||
IsExporting: s.state.GridPowerW < 0,
|
||||
AbsGridW: abs(s.state.GridPowerW),
|
||||
}
|
||||
if s.fcResult != nil {
|
||||
data.Forecast = forecastView{
|
||||
Available: true,
|
||||
KWh: s.fcResult.TotalKWh,
|
||||
Icon: s.fcResult.QualityIcon(s.strategic),
|
||||
Quality: s.fcResult.Quality(s.strategic),
|
||||
}
|
||||
}
|
||||
for _, c := range consumerOrder {
|
||||
rec := s.consumers[c]
|
||||
meta := consumerMeta[c]
|
||||
cv := consumerView{
|
||||
Icon: meta.Icon,
|
||||
Label: meta.Label,
|
||||
ConsumerKey: c.String(),
|
||||
Active: rec.active,
|
||||
Since: rec.since,
|
||||
Reason: rec.reason,
|
||||
CanOverride: c != engine.ConsumerWW,
|
||||
ManualOverride: rec.manualOverride,
|
||||
OverrideUntil: rec.overrideUntil,
|
||||
}
|
||||
if c == engine.ConsumerWW && !s.wwConfigured {
|
||||
cv.Unconfigured = true
|
||||
}
|
||||
data.Consumers = append(data.Consumers, cv)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func formatW(w float64) string {
|
||||
if w >= 1000 || w <= -1000 {
|
||||
return fmt.Sprintf("%.1f kW", w/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.0f W", w)
|
||||
}
|
||||
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>Solar Status</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f0fdf4;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
.updated { font-size: 0.75rem; color: #6b7280; }
|
||||
|
||||
.banner {
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.banner.warn { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
|
||||
.banner.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
||||
}
|
||||
.card-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.card-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* SOC bar */
|
||||
.soc-bar {
|
||||
background: #e5e7eb;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
margin-top: 0.6rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.soc-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Forecast card — full width */
|
||||
.card.full { grid-column: 1 / -1; }
|
||||
.forecast-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.forecast-icon { font-size: 1.4rem; line-height: 1; }
|
||||
.forecast-quality { font-size: 0.85rem; color: #6b7280; margin-top: 0.2rem; }
|
||||
|
||||
/* Grid card direction arrow */
|
||||
.arrow { font-size: 1.1rem; margin-right: 0.1rem; }
|
||||
|
||||
/* Consumer list */
|
||||
.section-title {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.consumer {
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.consumer:last-child { margin-bottom: 0; }
|
||||
.consumer.unconfigured { opacity: 0.45; }
|
||||
|
||||
.indicator {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.indicator.on {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 3px #bbf7d0;
|
||||
}
|
||||
.indicator.off { background: #d1d5db; }
|
||||
.indicator.override {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 3px #fde68a;
|
||||
}
|
||||
|
||||
.consumer-icon { font-size: 1.2rem; flex-shrink: 0; }
|
||||
.consumer-body { flex: 1; min-width: 0; }
|
||||
.consumer-name { font-weight: 600; font-size: 0.9rem; }
|
||||
.consumer-detail {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.15rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.consumer-detail.active { color: #16a34a; font-weight: 500; }
|
||||
.consumer-detail.override { color: #d97706; font-weight: 500; }
|
||||
|
||||
.override-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.override-btn:active { opacity: 0.7; }
|
||||
.override-btn.turn-on { background: #dcfce7; color: #15803d; }
|
||||
.override-btn.turn-off { background: #fee2e2; color: #b91c1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>☀️ Solar Status</h1>
|
||||
{{if not .LastUpdate.IsZero}}
|
||||
<span class="updated">{{.LastUpdate.Format "15:04:05"}}</span>
|
||||
{{end}}
|
||||
</header>
|
||||
|
||||
{{if .DryRun}}
|
||||
<div class="banner warn">⚠️ Testmodus — keine echten Schaltvorgänge</div>
|
||||
{{end}}
|
||||
{{if .ErrMsg}}
|
||||
<div class="banner error">⚠️ {{.ErrMsg}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- Battery -->
|
||||
<div class="card">
|
||||
<div class="card-label">Batterie</div>
|
||||
<div class="card-value" style="color: {{socColor .BatterySOC}}">
|
||||
{{printf "%.0f" .BatterySOC}}<span style="font-size:1rem;font-weight:400"> %</span>
|
||||
</div>
|
||||
<div class="soc-bar">
|
||||
<div class="soc-fill" style="width:{{printf "%.0f" .BatterySOC}}%; background:{{socColor .BatterySOC}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="card">
|
||||
<div class="card-label">Netz</div>
|
||||
{{if .IsExporting}}
|
||||
<div class="card-value" style="color:#16a34a">
|
||||
<span class="arrow">↑</span>{{formatW .AbsGridW}}
|
||||
</div>
|
||||
<div class="card-sub">Einspeisung</div>
|
||||
{{else}}
|
||||
<div class="card-value" style="color:#dc2626">
|
||||
<span class="arrow">↓</span>{{formatW .AbsGridW}}
|
||||
</div>
|
||||
<div class="card-sub">Bezug</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- PV -->
|
||||
<div class="card">
|
||||
<div class="card-label">PV-Leistung</div>
|
||||
<div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Temperature -->
|
||||
<div class="card">
|
||||
<div class="card-label">Außentemperatur</div>
|
||||
<div class="card-value">{{printf "%.1f" .AmbientTempC}}<span style="font-size:1rem;font-weight:400"> °C</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Forecast (full width) -->
|
||||
{{if .Forecast.Available}}
|
||||
<div class="card full">
|
||||
<div class="card-label">Prognose heute</div>
|
||||
<div class="forecast-row">
|
||||
<span class="forecast-icon">{{.Forecast.Icon}}</span>
|
||||
<span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span>
|
||||
</div>
|
||||
<div class="forecast-quality">{{.Forecast.Quality}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section-title">Verbraucher</div>
|
||||
|
||||
{{range .Consumers}}
|
||||
<div class="consumer{{if .Unconfigured}} unconfigured{{end}}">
|
||||
<div class="indicator {{if .ManualOverride}}override{{else if .Active}}on{{else}}off{{end}}"></div>
|
||||
<div class="consumer-icon">{{.Icon}}</div>
|
||||
<div class="consumer-body">
|
||||
<div class="consumer-name">{{.Label}}</div>
|
||||
<div class="consumer-detail{{if and .Active (not .ManualOverride)}} active{{end}}{{if .ManualOverride}} override{{end}}">
|
||||
{{if .Unconfigured}}
|
||||
nicht konfiguriert
|
||||
{{else if .ManualOverride}}
|
||||
Manuell bis {{.OverrideUntil.Format "15:04"}}
|
||||
{{else if .Active}}
|
||||
{{formatSince .Since}}
|
||||
{{else if .Reason}}
|
||||
{{.Reason}}
|
||||
{{else}}
|
||||
Ausgeschaltet
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .CanOverride}}
|
||||
<form method="post" action="/override">
|
||||
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
||||
{{if .Active}}
|
||||
<input type="hidden" name="state" value="off">
|
||||
<button type="submit" class="override-btn turn-off">Aus</button>
|
||||
{{else}}
|
||||
<input type="hidden" name="state" value="on">
|
||||
<button type="submit" class="override-btn turn-on">Ein</button>
|
||||
{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
171
internal/viessmann/client.go
Normal file
171
internal/viessmann/client.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package viessmann
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenEndpoint = "https://iam.viessmann-climatesolutions.com/idp/v3/token"
|
||||
apiBase = "https://api.viessmann.com/iot/v2"
|
||||
)
|
||||
|
||||
// tokenFile mirrors the JSON structure stored on disk.
|
||||
type tokenFile struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ValidToTimeDate int64 `json:"validToTimeDate"` // Unix milliseconds
|
||||
}
|
||||
|
||||
// Client manages Viessmann OAuth2 tokens and sends commands to the IoT API.
|
||||
type Client struct {
|
||||
cfg config.ViessmannConfig
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
token tokenFile
|
||||
}
|
||||
|
||||
// NewClient creates a new Viessmann client and loads the token from disk.
|
||||
func NewClient(cfg config.ViessmannConfig, logger *slog.Logger) (*Client, error) {
|
||||
c := &Client{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
logger: logger,
|
||||
}
|
||||
if err := c.loadToken(); err != nil {
|
||||
return nil, fmt.Errorf("loading token: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) loadToken() error {
|
||||
data, err := os.ReadFile(c.cfg.TokenFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", c.cfg.TokenFile, err)
|
||||
}
|
||||
return json.Unmarshal(data, &c.token)
|
||||
}
|
||||
|
||||
func (c *Client) saveToken() {
|
||||
data, err := json.MarshalIndent(c.token, "", " ")
|
||||
if err != nil {
|
||||
c.logger.Warn("could not marshal token", "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(c.cfg.TokenFile, data, 0600); err != nil {
|
||||
c.logger.Warn("could not save token file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureToken refreshes the access token if it expires within 5 minutes.
|
||||
// Caller must hold c.mu.
|
||||
func (c *Client) ensureToken(ctx context.Context) error {
|
||||
remaining := time.Until(time.UnixMilli(c.token.ValidToTimeDate))
|
||||
if remaining > 5*time.Minute {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.logger.Info("refreshing Viessmann access token", "remaining", remaining.Round(time.Second))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("client_id", c.cfg.ClientID)
|
||||
form.Set("refresh_token", c.token.RefreshToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token refresh: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("token refresh returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var fresh struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fresh); err != nil {
|
||||
return fmt.Errorf("decoding token response: %w", err)
|
||||
}
|
||||
|
||||
c.token.AccessToken = fresh.AccessToken
|
||||
if fresh.RefreshToken != "" {
|
||||
c.token.RefreshToken = fresh.RefreshToken
|
||||
}
|
||||
c.token.TokenType = fresh.TokenType
|
||||
c.token.ExpiresIn = fresh.ExpiresIn
|
||||
c.token.ValidToTimeDate = time.Now().Add(time.Duration(fresh.ExpiresIn) * time.Second).UnixMilli()
|
||||
|
||||
c.saveToken()
|
||||
c.logger.Info("Viessmann token refreshed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// featureURL builds the IoT API URL for a device feature.
|
||||
func (c *Client) featureURL(feature string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s/features/installations/%s/gateways/%s/devices/%s/features/%s",
|
||||
apiBase,
|
||||
c.cfg.InstallationID,
|
||||
c.cfg.GatewaySerial,
|
||||
c.cfg.DeviceID,
|
||||
feature,
|
||||
)
|
||||
}
|
||||
|
||||
// SetDHWTemperature sets the domestic hot water target temperature via the Viessmann API.
|
||||
func (c *Client) SetDHWTemperature(ctx context.Context, tempC float64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureToken(ctx); err != nil {
|
||||
return fmt.Errorf("ensuring token: %w", err)
|
||||
}
|
||||
|
||||
cmdURL := c.featureURL("heating.dhw.temperature.main") + "/commands/setTargetTemperature"
|
||||
body := fmt.Sprintf(`{"temperature":%g}`, tempC)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cmdURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.token.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("API call: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("Viessmann API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c.logger.Info("DHW temperature set", "temp_c", tempC)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user