diff --git a/internal/mcp/control.go b/internal/mcp/control.go index c661972..6d54677 100644 --- a/internal/mcp/control.go +++ b/internal/mcp/control.go @@ -97,7 +97,14 @@ func controlTools() []*tool { if err := cc.withinGrant(protocol.OpLoadpointHold, body); err != nil { return nil, err } - return cc.command(ctx, protocol.OpLoadpointHold, body) + report, err := cc.command(ctx, protocol.OpLoadpointHold, body) + if err != nil { + return nil, err + } + if !a.Stop { + report["requestedHoldSeconds"] = body["hold_s"] + } + return report, nil }, }, { @@ -304,7 +311,7 @@ func (cc *callCtx) request(ctx context.Context, r protocol.AgentRequest) (any, e // a conflict; a stale one is learnt from E_CONFLICT and tried once more. A // client's retry of the same call within a minute reuses its command id, // so the box acts once. -func (cc *callCtx) command(ctx context.Context, op string, args map[string]any) (any, error) { +func (cc *callCtx) command(ctx context.Context, op string, args map[string]any) (map[string]any, error) { c, err := cc.conn(ctx) if err != nil { return nil, err @@ -496,7 +503,10 @@ func commandReport(res *protocol.CmdResult, ack *protocol.CmdAck, uptime int64) out["note"] = "The box did not answer in time; it may or may not act." } if ack != nil && ack.ExpiresAtMs > 0 { - out["endsInSeconds"] = max((ack.ExpiresAtMs-uptime)/1000, 0) + // CmdAck names the command lease, not the wall-clock hold. Core + // gives a ten-minute hold the same lease as a five-minute hold. + out["leaseExpiresInSeconds"] = max((ack.ExpiresAtMs-uptime)/1000, 0) + out["leaseNote"] = "The command lease expiry is not the charging hold duration or proof of measured charging." } return out } diff --git a/internal/mcp/control_test.go b/internal/mcp/control_test.go index 4dface9..46bf8ea 100644 --- a/internal/mcp/control_test.go +++ b/internal/mcp/control_test.go @@ -61,9 +61,12 @@ func TestChargeNowIsOneTemporaryCommand(t *testing.T) { out := structured(t, a, "ev_charge_now", map[string]any{"loadpoint": "lpA1", "power_w": 7400, "minutes": 10, "phase_mode": "3p"}) // The first command expects the revision the chargers' answer named: // one command, no conflict spent on the grant's rate. - if out["state"] != protocol.CmdApplied || out["endsInSeconds"] == nil { + if out["state"] != protocol.CmdApplied || out["leaseExpiresInSeconds"] == nil || out["requestedHoldSeconds"] != float64(600) { t.Fatalf("report %v", out) } + if _, misleading := out["endsInSeconds"]; misleading { + t.Fatalf("the command lease was reported as a charging deadline: %v", out) + } b.mu.Lock() cmds := slices.Clone(b.cmds) b.mu.Unlock() @@ -85,6 +88,31 @@ func TestChargeNowIsOneTemporaryCommand(t *testing.T) { } } +func TestCommandLeaseIsNotTheChargingHold(t *testing.T) { + _, b, a := withControl(t) + b.mu.Lock() + b.cmdReply = func(c protocol.Cmd) []any { + return []any{protocol.CmdAck{CmdID: c.CmdID, ExpiresAtMs: 3_600_000 + 900_000}, + protocol.CmdResult{CmdID: c.CmdID, State: protocol.CmdApplied}} + } + b.mu.Unlock() + out := structured(t, a, "ev_charge_now", map[string]any{"loadpoint": "lpA1", "power_w": 2300, "minutes": 10}) + if out["requestedHoldSeconds"] != float64(600) || out["leaseExpiresInSeconds"].(float64) < 899 || + !strings.Contains(out["leaseNote"].(string), "not the charging hold duration") { + t.Fatalf("report confused a ten-minute hold with its fifteen-minute lease: %v", out) + } + stop := structured(t, a, "ev_charge_now", map[string]any{"loadpoint": "lpA1", "stop": true}) + if _, hasHold := stop["requestedHoldSeconds"]; hasHold { + t.Fatalf("release reported a requested charging duration: %v", stop) + } + for _, res := range []*protocol.CmdResult{nil, {State: protocol.CmdApplied}} { + r := commandReport(res, &protocol.CmdAck{ExpiresAtMs: 900_000}, 0) + if num(r["leaseExpiresInSeconds"]) != 900 || r["endsInSeconds"] != nil { + t.Fatalf("acknowledgement claimed a hold expiry: %v", r) + } + } +} + func num(v any) int64 { switch n := v.(type) { case uint64: