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 and returns one error per action // (nil on success). Failed actions do not prevent subsequent actions from running. // The caller should roll back engine state for any failed action using // Engine.RollbackAction so that the next SyncHardwareState cycle does not mistake // the divergence for a manual override. func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) []error { errs := make([]error, len(actions)) for i, 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, ) errs[i] = err continue } a.logger.Info("action executed", "consumer", action.Consumer, "turn_on", action.TurnOn, "reason", action.Reason, ) } return errs } 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:///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:///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 }