Fix Shelly unreachable: roll back engine state on failed actions

Without this, a failed Execute() left engine state diverged from hardware.
SyncHardwareState would then misread the mismatch as a manual override and
apply a 1-hour lockout — causing either a stuck-on or stuck-off loop.

Now Execute() returns per-action []error. The control loop calls
Engine.RollbackAction() for each failed action, keeping engine state in
sync with hardware so the next cycle simply retries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:44:08 +02:00
parent 2dd53d1543
commit f45d23df97
4 changed files with 57 additions and 22 deletions

View File

@@ -38,26 +38,30 @@ func NewActuator(cfg *config.Config, vc *viessmann.Client, logger *slog.Logger)
}
}
// Execute performs a list of switching actions.
func (a *Actuator) Execute(ctx context.Context, actions []engine.Action) error {
for _, action := range actions {
// 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,
)
// Continue with other actions even if one fails
errs[i] = err
continue
}
a.logger.Info("action executed",
"consumer", action.Consumer,
"turn_on", action.TurnOn,
"reason", action.Reason,
)
}
return nil
return errs
}
func (a *Actuator) executeOne(ctx context.Context, action engine.Action) error {

View File

@@ -88,11 +88,11 @@ func TestExecuteTurnOnSGReady(t *testing.T) {
act := testActuator(t, ipFrom(sgSrv), ipFrom(dummySrv), ipFrom(dummySrv))
err := act.Execute(context.Background(), []engine.Action{
errs := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerSGReady, TurnOn: true, Reason: "test"},
})
if err != nil {
t.Fatalf("Execute failed: %v", err)
if errs[0] != nil {
t.Fatalf("Execute failed: %v", errs[0])
}
if !sg.state {
@@ -112,11 +112,11 @@ func TestExecuteTurnOffWallboxA(t *testing.T) {
act := testActuator(t, ipFrom(dummySrv), ipFrom(wbASrv), ipFrom(dummySrv))
err := act.Execute(context.Background(), []engine.Action{
errs := act.Execute(context.Background(), []engine.Action{
{Consumer: engine.ConsumerWallboxA, TurnOn: false, Reason: "import"},
})
if err != nil {
t.Fatalf("Execute failed: %v", err)
if errs[0] != nil {
t.Fatalf("Execute failed: %v", errs[0])
}
if wbA.state {
@@ -164,13 +164,14 @@ func TestExecuteMultipleActions(t *testing.T) {
act := testActuator(t, ipFrom(sgSrv), ipFrom(wbASrv), ipFrom(wbBSrv))
err := act.Execute(context.Background(), []engine.Action{
for i, err := range 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 err != nil {
t.Fatalf("Execute action %d failed: %v", i, err)
}
}
if !sg.state || !wbA.state || !wbB.state {