From 7d5dc4863b64e0f9d9861b449c6661072ba68455 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 29 Sep 2026 09:49:33 +0200 Subject: [PATCH 01/18] feat(control): show command limits and measured response tiers Signed-off-by: Fredrik Ahlgren --- .changeset/measured-control-feedback.md | 5 + VISION.md | 52 +++ docs/roadmap.md | 2 +- drivers/BUNDLED_SOURCE.json | 2 +- go/internal/api/api.go | 3 +- go/internal/api/api_control_feedback.go | 382 ++++++++++++++++++ go/internal/api/api_control_feedback_test.go | 93 +++++ go/internal/api/api_control_verification.go | 89 ++++ .../api/api_control_verification_test.go | 66 +++ go/internal/api/api_support_report.go | 6 + go/internal/drivers/registry.go | 20 + go/internal/ocpp/control.go | 15 +- go/internal/telemetry/command_evidence.go | 223 ++++++++++ .../telemetry/command_evidence_test.go | 118 ++++++ go/internal/telemetry/control_observations.go | 129 ++++++ .../telemetry/control_observations_test.go | 59 +++ go/internal/telemetry/store.go | 25 +- web/app.js | 14 + web/control-feedback.js | 119 ++++++ web/control-feedback.test.mjs | 33 ++ web/index.html | 3 + web/style.css | 17 + 22 files changed, 1467 insertions(+), 8 deletions(-) create mode 100644 .changeset/measured-control-feedback.md create mode 100644 go/internal/api/api_control_feedback.go create mode 100644 go/internal/api/api_control_feedback_test.go create mode 100644 go/internal/api/api_control_verification.go create mode 100644 go/internal/api/api_control_verification_test.go create mode 100644 go/internal/telemetry/command_evidence.go create mode 100644 go/internal/telemetry/command_evidence_test.go create mode 100644 go/internal/telemetry/control_observations.go create mode 100644 go/internal/telemetry/control_observations_test.go create mode 100644 web/control-feedback.js create mode 100644 web/control-feedback.test.mjs diff --git a/.changeset/measured-control-feedback.md b/.changeset/measured-control-feedback.md new file mode 100644 index 00000000..7f085e71 --- /dev/null +++ b/.changeset/measured-control-feedback.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show command results, device limits and fresh measured response in the normal overview and charging view. Distinguish driver replies from verified effect, warn when setpoints or power diverge, and include the same evidence in support reports. diff --git a/VISION.md b/VISION.md index d3aedd9f..5bf278c3 100644 --- a/VISION.md +++ b/VISION.md @@ -34,6 +34,58 @@ Core validates every plan and command, including plans supplied by another system. Fuse, equipment, SoC, freshness and other quantified safety limits always apply. Stale required site-meter data stops dispatch. +## Are you in control? + +**Don't trust what you say. Trust what you read.** + +FTW must verify control through fresh measurements. Sending a command, getting +an API success or reading back a setpoint does not prove a physical effect. +The user should be able to see whether the equipment did what they asked, +what limited it and what FTW can actually confirm. + +Trace each request through Core's limits, the command sent, the device's +response and the measured result. Keep the time, source and unit of each +step. Use three explicit levels: + +- **Tier 0 — acknowledged:** the command was sent and acknowledged. This + confirms communication, not a physical effect. +- **Tier 1 — device measured:** the device's own fresh power reading follows + the sent command. A setpoint echo alone cannot reach this level. +- **Tier 2 — independently confirmed:** a separate physical meter shows the + matching effect. For example, a new 1 kW charging command produces a 1 kW + device reading and a corresponding 1 kW increase in site import. + +Use time-aligned readings, short measurement windows, tolerances and response +waits to handle noise and different sample rates. Account for solar and other +loads. A simultaneous load change may prevent attribution; remain at Tier 1 +until the evidence supports Tier 2. A steady absolute grid value is not proof +of a command's effect. Check the change against the baseline. + +Sensor fusion must respect energy balance and retain each reading's source +and age. Two fields from the same sensor are not independent confirmation. +Neither an estimate nor house load derived from those same readings can act +as independent proof. Missing, stale or conflicting data lowers confidence; +never smooth it into a successful result. Confirmation expires when its +supporting readings stop being current. + +Keep success at each step separate. If a user asks for 11 kW, Core permits +5 kW and the meter shows 5 kW, the device follows the sent command but the +user's request remains limited. A charging goal needs its own completion +evidence. A matching reading also does not prove that FTW is the only +controller; report a changed setpoint without guessing who changed it. + +Apply this standard to every control mode and supported device. Show waiting, +limits, failed commands, missing effect and recovery in the normal view, with +the known cause and a useful next step. Say when the cause is unknown. Keep +warnings current and clear them when new evidence shows recovery. Make the +same evidence available to authorized agents and support tools. + +Traceability and robustness are product requirements: retain enough evidence to +explain what happened, and stop depending on an actuator that cannot deliver. +Recovery needs fresh proof of response. This is the product direction; each +implementation must state which devices, paths and physical outcomes it has +verified. + ## Trust through visible behaviour The live view is a core product feature. It must feel local and fast, and diff --git a/docs/roadmap.md b/docs/roadmap.md index 247f11cc..33817c12 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,7 +69,7 @@ or reopen closed issues. |---|---|---| | Simple setup and first-day value | Discover mixed equipment. Confirm the main fuse and site meter. Read battery capacity where possible and ask for kWh when needed. Power settings and solar kWp are optional where safe device information and learning allow. Provide useful initial load and PV forecasts. | A fresh install reaches useful automatic operation without panel drawings or expert settings. Missing data and wrong start estimates have tested behaviour. Record device identity, known limits and uncertainty. Validate on named hardware combinations as well as simulators. | | A clear commissioning result | Check commands and measured response within known limits. Distinguish working telemetry from working control. Exclude failed control from both the plan and dispatch. | Show request, device response, measured effect and timing. Cover delayed response, refusal, disconnect, stale site data and recovery. A short commissioning test does not claim full hardware qualification. | -| Live control that earns trust | Keep the fast local feel. Make request, acceptance, command, response, physical effect and freshness visible in the normal Flow experience. | Browser review on desktop and mobile, timing measurements on a target box, and traces with different sampling rates. A pending command or old reading never appears as completed or fresh. Examine existing Live and Flow views before deciding their final layout. | +| Verified control: “Are you in control?” | Keep the fast local feel. Make request, acceptance, command, response, physical effect and freshness visible in the normal Flow experience. | Browser review on desktop and mobile, timing measurements on a target box, and traces with different sampling rates. A successful call, echoed setpoint or old reading never appears as a verified physical result. Test Tier 0 acknowledgement, Tier 1 device power and Tier 2 separate-meter confirmation. Include direction, tolerance, response delay, simultaneous load/solar changes, shared measurement sources, overridden setpoints, device limits and recovery. A reduced request remains visibly limited even when the device follows the reduced command. Examine existing Live and Flow views before deciding their final layout. | | Good automatic planning | Use site physics and charge/discharge efficiency. Default wear cost is zero; users may opt in. Keep hard SoC limits separate from forecast-based caution and explicit backup needs. | Cold-start and learned forecasts, stale inputs, multiple assets and unavailable optimizer paths have tests. Backtests use held-out periods and report uncertainty. Defaults and persisted settings agree across UI, Core and worker. Site runs establish practical benefit. | | Reliable daily charging | Persistent weekday target and deadline. Offline-car estimates. Direct SoC slider after connection, no extra save, prompt replanning, one-action Charge now and notifications when action is needed. | Test from app intent to charger/car response and delivered energy, including missed-goal risk, absent vehicle cloud, unknown SoC, reconnect and restart. Test notifications with the app closed. Confirm physical charging separately from simulation. | | Useful analysis and fair savings | Keep enough provenance to explain plans and outcomes. Main savings target compares with ordinary self-consumption on the same installation. | Actual cost reconciles with measured import/export and prices. Specify EV behaviour, initial and final stored-energy accounting, efficiency and coverage. Show missing and negative results. Label the current no-PV/no-battery comparison as total site value until replacement is verified. | diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index a01d7afe..2a105a19 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -16,7 +16,7 @@ "from the signed channel. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "7e572fb499dda7acf6f1212b8b0b78235b9d3ba9", + "commit": "405ca9a927821fe45e73a1afb3a0a94f196c7383", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 42a909f3..86db8801 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1219,6 +1219,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { // diagnostic — incremented when actual fleet delivery diverges // from the plan's BatteryEnergyWh by > 50 % (over) or < 50 % // (under). Idle slots (|planned| ≤ 50 Wh) are ignored. + "control_feedback": s.controlFeedback(time.Now()), "slot_delivery_stats": ctrl.SlotDeliveryStats, } // A stale or missing site meter is not 0 W. Publishing zero made the @@ -3672,7 +3673,7 @@ func (s *Server) handleLoadpoints(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{ "enabled": true, "vehicle_limit_goal_supported": true, - "loadpoints": states, + "loadpoints": s.loadpointsWithFeedback(states), }) } diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go new file mode 100644 index 00000000..aa20d074 --- /dev/null +++ b/go/internal/api/api_control_feedback.go @@ -0,0 +1,382 @@ +package api + +import ( + "encoding/json" + "math" + "sort" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// ControlFeedback is read-only evidence for every mode. Missing numbers are +// null, never invented zeros. The client owns prose; reasons describe only +// what Core or the device actually reported, not a guessed physical cause. +type ControlFeedback struct { + ToleranceW *float64 `json:"tolerance_w"` + VerificationTier *int `json:"verification_tier"` + SiteConfirmation string `json:"site_confirmation"` + SiteMeter string `json:"site_meter,omitempty"` + SiteDeltaW *float64 `json:"site_delta_w"` + DeviceDeltaW *float64 `json:"device_delta_w"` + Response string `json:"response"` + VerifiedAtMs int64 `json:"verified_at_ms,omitempty"` + Driver string `json:"driver"` + Kind string `json:"kind"` + Mode string `json:"mode"` + State string `json:"state"` + Reason string `json:"reason"` + Severity string `json:"severity"` + RequestedW *float64 `json:"requested_w"` + SentW *float64 `json:"sent_w"` + ReadbackW *float64 `json:"readback_w"` + ActualW *float64 `json:"actual_w"` + RequestedA *float64 `json:"requested_a"` + OfferedA *float64 `json:"offered_a"` + DeviceLimitA *float64 `json:"device_limit_a"` + DeviceReason string `json:"device_reason,omitempty"` + SinceMs int64 `json:"since_ms,omitempty"` + CommandAtMs int64 `json:"command_at_ms,omitempty"` + ObservedAtMs int64 `json:"observed_at_ms,omitempty"` +} + +type feedbackReading struct { + SetpointW *float64 `json:"setpoint_w"` + MaxA *float64 `json:"max_a"` + DeviceLimitA *float64 `json:"device_limit_a"` + DeviceLimitAgeS *float64 `json:"device_limit_age_s"` + ControlPowerW *float64 `json:"control_power_w"` + ControlPowerAvailable *bool `json:"control_power_available"` + Reason string `json:"reason_no_current_label"` + Connected *bool `json:"connected"` + Online *bool `json:"is_online"` + PowerObservedAt string `json:"power_observed_at"` + PowerMaxAgeS float64 `json:"power_max_age_s"` +} + +func watts(v float64) *float64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return nil + } + return &v +} + +func (s *Server) controlFeedback(now time.Time) []ControlFeedback { + out := []ControlFeedback{} + if s.deps.Tel == nil { + return out + } + mode, meter := "", "" + clamped := map[string]bool{} + var hold control.BatteryManualHold + var held bool + if s.deps.Ctrl != nil && s.deps.CtrlMu != nil { + s.deps.CtrlMu.Lock() + mode = string(s.deps.Ctrl.Mode) + meter = s.deps.Ctrl.SiteMeterDriver + for _, t := range s.deps.Ctrl.LastTargets { + clamped[t.Driver] = t.Clamped + } + hold, held = s.deps.Ctrl.GetBatteryManualHold(now) + s.deps.CtrlMu.Unlock() + } + observe, disabled := map[string]bool{}, map[string]bool{} + if s.deps.Cfg != nil && s.deps.CfgMu != nil { + s.deps.CfgMu.RLock() + observe = config.ObserveOnlyDriverSet(s.deps.Cfg) + for _, d := range s.deps.Cfg.Drivers { + disabled[d.Name] = d.Disabled + } + s.deps.CfgMu.RUnlock() + } + lps := map[string]loadpoint.State{} + if s.deps.Loadpoints != nil { + states := s.deps.Loadpoints.States() + s.decorateLoadpointsWithManual(states) + for _, lp := range states { + lps[lp.DriverName] = lp + } + } + blocked := "" + if s.deps.SiteDispatchBlocked != nil { + blocked = s.deps.SiteDispatchBlocked() + } + for _, kind := range []telemetry.DerType{telemetry.DerBattery, telemetry.DerEV, telemetry.DerPV, telemetry.DerV2X} { + for _, rd := range s.deps.Tel.ReadingsByType(kind) { + cmd, commanded := s.deps.Tel.CommandEvidence(rd.Driver, kind.String()) + if kind == telemetry.DerPV && !commanded { + continue + } + f := ControlFeedback{Driver: rd.Driver, Kind: kind.String(), Mode: mode, State: "waiting", Reason: "no_command", Severity: "info", Response: "unconfirmed"} + var d feedbackReading + _ = json.Unmarshal(rd.Data, &d) + f.ReadbackW, f.OfferedA, f.DeviceReason = d.SetpointW, d.MaxA, d.Reason + if d.DeviceLimitAgeS != nil && *d.DeviceLimitAgeS >= 0 && *d.DeviceLimitAgeS <= 120 { + f.DeviceLimitA = d.DeviceLimitA + } + f.ObservedAtMs = rd.UpdatedAt.UnixMilli() + fresh := now.Sub(rd.UpdatedAt) <= time.Minute && !rd.UpdatedAt.After(now) + if h := s.deps.Tel.DriverHealth(rd.Driver); h != nil { + fresh = fresh && h.TelemetryLive() + } + if d.Online != nil && !*d.Online { + fresh = false + } + if d.PowerObservedAt != "" { + at, err := time.Parse(time.RFC3339Nano, d.PowerObservedAt) + maxAge := time.Duration(d.PowerMaxAgeS * float64(time.Second)) + if maxAge <= 0 || maxAge > 3*time.Minute { + maxAge = time.Minute + } + fresh = fresh && err == nil && !at.After(now) && now.Sub(at) <= maxAge + if err == nil { + f.ObservedAtMs = at.UnixMilli() + } + } + if fresh { + f.ActualW = watts(rd.RawW) + if d.ControlPowerW != nil { + f.ActualW = d.ControlPowerW + } + if d.ControlPowerAvailable != nil && !*d.ControlPowerAvailable { + f.ActualW = nil + } + } + if commanded { + f.SentW, f.RequestedW = cmd.PowerW, cmd.PowerW + if cmd.PowerW != nil { + f.ToleranceW = watts(telemetry.ControlToleranceW(*cmd.PowerW)) + } + f.CommandAtMs, f.SinceMs = cmd.At.UnixMilli(), cmd.Since.UnixMilli() + if cmd.Result == "accepted" { + tier := 0 + f.VerificationTier = &tier + } + } + coreReason := "" + if clamped[rd.Driver] && kind == telemetry.DerBattery { + coreReason = "core_limit" + } + if held && kind == telemetry.DerBattery && (hold.Driver == "" || hold.Driver == rd.Driver) { + f.Mode = "manual" + f.RequestedW = watts(hold.PowerW) + } + if lp, ok := lps[rd.Driver]; ok && kind == telemetry.DerEV { + f.Mode = "plan" + if lp.SurplusOnly { + f.Mode = "solar" + } + if lp.ManualActive { + f.Mode = "manual" + } + if lp.CommandedKnown { + f.RequestedW = watts(lp.CommandedW) + coreReason = lp.CommandedReason + } + if lp.ManualActive { + f.RequestedW = watts(lp.ManualChargeW) + } + if f.RequestedW != nil && lp.Phases > 0 && lp.VoltageV > 0 { + f.RequestedA = watts(*f.RequestedW / float64(lp.Phases) / lp.VoltageV) + } + if lp.PowerUnavailable { + fresh = false + f.ActualW = nil + } + } + if commanded && cmd.Result == "accepted" && fresh && f.ActualW != nil && f.SentW != nil && telemetry.PowerFollowsCommand(f.Kind, *f.SentW, *f.ActualW) && !cmd.PowerMatchSince.IsZero() && now.Sub(cmd.PowerMatchSince) >= 10*time.Second && now.Sub(cmd.LastObservation) <= 10*time.Second { + f.Response = "device_reported" + tier := 1 + f.VerificationTier = &tier + f.VerifiedAtMs = cmd.LastObservation.UnixMilli() + } + f.SiteConfirmation = "device_response_unconfirmed" + if f.VerificationTier != nil && *f.VerificationTier == 1 { + from := now.Add(-12 * time.Second) + if cmd.PowerMatchSince.After(from) { + from = cmd.PowerMatchSince + } + f.SiteMeter = meter + f.SiteConfirmation, f.DeviceDeltaW, f.SiteDeltaW = independentResponse(cmd, meter, s.separateMeterSource(rd.Driver, meter), s.deps.Tel.ControlWindows(from, now), now) + if h := s.deps.Tel.DriverHealth(meter); h == nil || !h.TelemetryLive() || h.DeviceFault || blocked != "" { + f.SiteConfirmation = "waiting_for_meter" + f.SiteDeltaW = nil + } + if f.SiteConfirmation == "confirmed" { + tier := 2 + f.VerificationTier = &tier + f.Response = "site_confirmed" + } + } + classifyControlFeedback(&f, cmd, commanded, fresh, coreReason, d.Connected, now) + if h := s.deps.Tel.DriverHealth(rd.Driver); h != nil && h.DeviceFault { + f.State, f.Reason, f.Severity = "blocked", "device_fault", "warning" + f.DeviceReason = h.DeviceFaultReason + } + if s.deps.Registry != nil { + if cs, ok := s.deps.Registry.ControlStatus(rd.Driver); ok && cs.Blocked { + f.State, f.Reason, f.Severity = "blocked", "default_failed", "warning" + } + } + if blocked != "" { + f.State, f.Reason, f.Severity = "blocked", "site_meter_stale", "warning" + } + if observe[rd.Driver] { + f.State, f.Reason, f.Severity = "observing", "observe_only", "info" + } + if disabled[rd.Driver] { + f.State, f.Reason, f.Severity = "blocked", "disabled", "info" + } + out = append(out, f) + } + } + seen := map[string]bool{} + for _, f := range out { + seen[f.Driver] = true + } + for driver, lp := range lps { + if seen[driver] { + continue + } + mode := "plan" + if lp.ManualActive { + mode = "manual" + } + if lp.SurplusOnly { + mode = "solar" + } + out = append(out, ControlFeedback{Driver: driver, Kind: "ev", Mode: mode, State: "unknown", Reason: "telemetry_stale", Severity: "warning"}) + seen[driver] = true + } + for driver, h := range s.deps.Tel.AllHealth() { + if seen[driver] || !h.DeviceFault { + continue + } + out = append(out, ControlFeedback{Driver: driver, Kind: "device", Mode: mode, State: "blocked", Reason: "device_fault", Severity: "warning", DeviceReason: h.DeviceFaultReason}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Driver == out[j].Driver { + return out[i].Kind < out[j].Kind + } + return out[i].Driver < out[j].Driver + }) + return out +} + +func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, commanded, fresh bool, coreReason string, connected *bool, now time.Time) { + set := func(state, reason, severity string) { f.State, f.Reason, f.Severity = state, reason, severity } + if !fresh { + f.ActualW, f.ReadbackW, f.OfferedA, f.DeviceLimitA = nil, nil, nil, nil + set("unknown", "telemetry_stale", "warning") + return + } + if commanded && cmd.Result == "default_failed" { + set("blocked", "default_failed", "warning") + return + } + if commanded && cmd.Result == "released" { + set("idle", "device_control", "info") + return + } + if commanded && cmd.Result == "failed" { + set("blocked", "command_failed", "warning") + return + } + if commanded && cmd.Result == "unconfirmed" { + set("unknown", "command_unconfirmed", "warning") + return + } + if f.Kind == "ev" && connected != nil && !*connected { + set("waiting", "not_connected", "info") + return + } + if f.RequestedA != nil && f.DeviceLimitA != nil && *f.RequestedA > *f.DeviceLimitA+0.5 { + set("limited", "device_limit", "warning") + return + } + if coreReason == "site_meter_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "charger_limit" { + set("limited", coreReason, "warning") + return + } + if !commanded { + if coreReason != "" { + set("waiting", coreReason, "info") + } + return + } + grace := 30 * time.Second + if f.Kind == "ev" { + grace = 2 * time.Minute + } + settled := !cmd.Since.IsZero() && now.Sub(cmd.Since) >= grace + if !cmd.ReadbackMismatchSince.IsZero() && now.Sub(cmd.ReadbackMismatchSince) >= 30*time.Second && f.ReadbackW != nil { + set("warning", "setpoint_changed", "warning") + return + } + if f.Kind == "ev" && f.SentW != nil && f.RequestedA != nil && f.OfferedA != nil && *f.SentW > 100 && *f.OfferedA+0.5 < *f.RequestedA && settled { + set("limited", "offered_current_lower", "warning") + return + } + if cmd.Result == "pending" || (!settled && f.Response == "unconfirmed") || f.ObservedAtMs < cmd.Since.UnixMilli() { + set("waiting", "waiting_response", "info") + return + } + if f.SentW == nil || f.ActualW == nil { + set("unknown", "response_unknown", "info") + return + } + gap := math.Abs(*f.SentW - *f.ActualW) + // A PV command is a ceiling; producing less is expected in weak sun. + if f.Kind == "pv" { + gap = math.Max(0, math.Abs(*f.ActualW)-math.Abs(*f.SentW)) + } + if !cmd.PowerMismatchSince.IsZero() && now.Sub(cmd.PowerMismatchSince) >= grace && gap > telemetry.ControlToleranceW(*f.SentW) { + set("warning", "power_differs", "warning") + return + } + if coreReason == "core_limit" { + set("limited", "core_limit", "info") + return + } + if math.Abs(*f.SentW) < 100 { + if coreReason == "" || coreReason == "manual_hold" { + coreReason = "idle" + } + set("idle", coreReason, "info") + return + } + if gap > telemetry.ControlToleranceW(*f.SentW) { + set("waiting", "waiting_response", "info") + return + } + if f.Response != "device_reported" && f.Response != "site_confirmed" { + set("waiting", "waiting_response", "info") + return + } + set("following", "power_observed", "info") +} + +// Keep status and the EV panel on the same explanation. This wrapper adds +// evidence to the existing endpoint without changing stored loadpoint state. +type loadpointFeedbackView struct { + loadpoint.State + ControlFeedback *ControlFeedback `json:"control_feedback,omitempty"` +} + +func (s *Server) loadpointsWithFeedback(states []loadpoint.State) []loadpointFeedbackView { + feedback := s.controlFeedback(time.Now()) + byDriver := map[string]*ControlFeedback{} + for i := range feedback { + if feedback[i].Kind == "ev" { + byDriver[feedback[i].Driver] = &feedback[i] + } + } + out := make([]loadpointFeedbackView, 0, len(states)) + for _, st := range states { + out = append(out, loadpointFeedbackView{st, byDriver[st.DriverName]}) + } + return out +} diff --git a/go/internal/api/api_control_feedback_test.go b/go/internal/api/api_control_feedback_test.go new file mode 100644 index 00000000..a0683875 --- /dev/null +++ b/go/internal/api/api_control_feedback_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/telemetry" + "sync" + "testing" + "time" +) + +func TestControlFeedbackReasons(t *testing.T) { + now := time.Now() + connected := true + for _, tc := range []struct { + name, kind, result, core, want string + fresh bool + sent, actual, readback, requestedA, limitA float64 + readMismatch, powerMismatch bool + }{ + {"driver accepted but no measured effect", "battery", "accepted", "", "power_differs", true, 1000, 0, 1000, 0, 0, false, true}, + {"Pixii overwritten while zero requested", "battery", "accepted", "", "setpoint_changed", true, 0, -1500, -1600, 0, 0, true, true}, + {"8 amp charger limit while charging", "ev", "accepted", "", "device_limit", true, 11040, 5500, 11040, 16, 8, false, true}, + {"fuse cap before dispatch", "ev", "accepted", "fuse_limit", "fuse_limit", true, 5000, 5000, 5000, 16, 0, false, false}, + {"driver refuses", "battery", "failed", "", "command_failed", true, 1000, 0, 0, 0, 0, false, false}, + {"unknown call outcome", "battery", "unconfirmed", "", "command_unconfirmed", true, 1000, 0, 0, 0, 0, false, false}, + {"default failed", "battery", "default_failed", "", "default_failed", true, 1000, 0, 0, 0, 0, false, false}, + {"device now autonomous", "battery", "released", "", "device_control", true, 1000, 0, 0, 0, 0, false, false}, + {"stale power cannot verify effect", "battery", "accepted", "", "telemetry_stale", false, 1000, 1000, 1000, 0, 0, false, false}, + {"device follows command", "battery", "accepted", "", "power_observed", true, 1000, 1000, 1000, 0, 0, false, false}, + {"Core clamp stays visible when followed", "battery", "accepted", "core_limit", "core_limit", true, 1000, 1000, 1000, 0, 0, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c := telemetry.CommandEvidence{Result: tc.result, Since: now.Add(-3 * time.Minute)} + if tc.readMismatch { + c.ReadbackMismatchSince = now.Add(-time.Minute) + } + if tc.powerMismatch { + c.PowerMismatchSince = now.Add(-3 * time.Minute) + } + f := ControlFeedback{Kind: tc.kind, SentW: watts(tc.sent), ActualW: watts(tc.actual), ReadbackW: watts(tc.readback), ObservedAtMs: now.UnixMilli(), Response: "device_reported"} + if tc.requestedA > 0 { + f.RequestedA = watts(tc.requestedA) + } + if tc.limitA > 0 { + f.DeviceLimitA = watts(tc.limitA) + } + classifyControlFeedback(&f, c, true, tc.fresh, tc.core, &connected, now) + if f.Reason != tc.want { + t.Fatalf("got %+v, want %s", f, tc.want) + } + if !tc.fresh && (f.ActualW != nil || f.ReadbackW != nil) { + t.Fatal("stale numbers kept as current") + } + }) + } +} + +func TestControlFeedbackEveryModeUsesEvidence(t *testing.T) { + for _, mode := range control.AllModes() { + t.Run(string(mode), func(t *testing.T) { + tel := telemetry.NewStore() + now := time.Now() + c := tel.BeginCommand("battery", []byte(`{"action":"battery","power_w":1000}`), now.Add(-time.Minute)) + tel.CompleteCommand(c, "failed") + tel.Update("battery", telemetry.DerBattery, 0, nil, []byte(`{}`)) + ctrl := &control.State{Mode: mode} + srv := New(&Deps{Tel: tel, Ctrl: ctrl, CtrlMu: &sync.Mutex{}, Cfg: &config.Config{}, CfgMu: &sync.RWMutex{}}) + feedback := srv.controlFeedback(time.Now()) + if len(feedback) != 1 || feedback[0].Reason != "command_failed" || feedback[0].VerificationTier != nil { + t.Fatalf("mode %s: %+v", mode, feedback) + } + }) + } +} + +func TestControlFeedbackRequiresMeasuredPowerNotSetpoint(t *testing.T) { + tel := telemetry.NewStore() + now := time.Now() + c := tel.BeginCommand("battery", []byte(`{"action":"battery","power_w":1000}`), now) + tel.CompleteCommand(c, "accepted") + tel.Update("battery", telemetry.DerBattery, 0, nil, []byte(`{"setpoint_w":1000}`)) + srv := New(&Deps{Tel: tel}) + f := srv.controlFeedback(time.Now())[0] + if f.VerificationTier == nil || *f.VerificationTier != 0 { + t.Fatalf("setpoint echo reached measured tier: %+v", f) + } + tel.EndCommandControl("battery", false) + f = srv.controlFeedback(time.Now())[0] + if f.VerificationTier != nil || f.Reason != "device_control" { + t.Fatalf("old command survived default: %+v", f) + } +} diff --git a/go/internal/api/api_control_verification.go b/go/internal/api/api_control_verification.go new file mode 100644 index 00000000..f48d0a81 --- /dev/null +++ b/go/internal/api/api_control_verification.go @@ -0,0 +1,89 @@ +package api + +import ( + "math" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Tier 2 compares a measured change, never a command with an absolute grid +// reading. Derived house load would make the check circular and is not used. +// This is corroboration under steady monitored flows, not proof of exclusive +// ownership or of what every unmetered household appliance did. +func independentResponse(cmd telemetry.CommandEvidence, meter string, separate bool, after map[string]telemetry.ControlBaseline, now time.Time) (string, *float64, *float64) { + if meter == "" { + return "no_site_meter", nil, nil + } + if cmd.Driver == meter || !separate { + return "independent_source_unknown", nil, nil + } + beforeDevice, okD := cmd.Baseline[cmd.Driver+":"+cmd.Kind] + beforeMeter, okM := cmd.Baseline[meter+":meter"] + device, okAD := after[cmd.Driver+":"+cmd.Kind] + grid, okAM := after[meter+":meter"] + if !okD || !okM { + return "no_baseline", nil, nil + } + if !okAD || !okAM || !device.Window.Usable(now) || !grid.Window.Usable(now) { + return "waiting_for_meter", nil, nil + } + if grid.Window.First.Before(cmd.Since) || device.Window.First.Before(cmd.Since) { + return "waiting_for_meter", nil, nil + } + if math.Abs(grid.Window.Last.Sub(device.Window.Last).Seconds()) > 5 { + return "readings_not_aligned", nil, nil + } + stable := func(w telemetry.ControlWindow) bool { return w.MaxW-w.MinW <= 200 } + if !stable(beforeDevice.Window) || !stable(beforeMeter.Window) || !stable(device.Window) || !stable(grid.Window) { + return "flows_changing", nil, nil + } + deltaDevice := device.Window.MeanW - beforeDevice.Window.MeanW + deltaGrid := grid.Window.MeanW - beforeMeter.Window.MeanW + if math.Abs(deltaDevice) < 500 { + return "no_clear_change", watts(deltaDevice), watts(deltaGrid) + } + // Every other monitored flow needs a stable before/after window. A missing + // stream, a new device or a changing PV/battery/EV invalidates attribution. + otherChange := 0.0 + for k, other := range after { + if other.Kind == telemetry.DerMeter || other.Kind == telemetry.DerVehicle || k == cmd.Driver+":"+cmd.Kind { + continue + } + base, ok := cmd.Baseline[k] + if !ok || !other.Window.Usable(now) || !stable(base.Window) || !stable(other.Window) || math.Abs(other.Window.MeanW-base.Window.MeanW) > 100 { + return "other_flows_changed", watts(deltaDevice), watts(deltaGrid) + } + otherChange += math.Abs(other.Window.MeanW - base.Window.MeanW) + } + if otherChange > 150 { + return "other_flows_changed", watts(deltaDevice), watts(deltaGrid) + } + for k, base := range cmd.Baseline { + if base.Kind == telemetry.DerMeter || base.Kind == telemetry.DerVehicle { + continue + } + if _, ok := after[k]; !ok { + return "other_flows_changed", watts(deltaDevice), watts(deltaGrid) + } + } + if math.Abs(deltaGrid-deltaDevice) > math.Max(250, math.Abs(deltaDevice)*0.15) { + return "site_change_differs", watts(deltaDevice), watts(deltaGrid) + } + return "confirmed", watts(deltaDevice), watts(deltaGrid) +} + +func (s *Server) separateMeterSource(driver, meter string) bool { + if driver == meter || s.deps.Registry == nil { + return false + } + a, b := s.runningDriverDevice(driver), s.runningDriverDevice(meter) + if a.Serial == "" && s.deps.OCPPChargers != nil { + if c, ok := s.deps.OCPPChargers()[driver]; ok { + a.Serial = c.Serial + a.DeviceID = c.Vendor + ":" + c.Serial + } + } + // Endpoint or configured name alone does not establish two physical meters. + return a.Serial != "" && b.Serial != "" && a.Serial != b.Serial && a.DeviceID != "" && b.DeviceID != "" && a.DeviceID != b.DeviceID +} diff --git a/go/internal/api/api_control_verification_test.go b/go/internal/api/api_control_verification_test.go new file mode 100644 index 00000000..d54e5205 --- /dev/null +++ b/go/internal/api/api_control_verification_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "github.com/srcfl/ftw/go/internal/telemetry" + "testing" + "time" +) + +func TestIndependentControlConfirmation(t *testing.T) { + now := time.Now() + window := func(power float64, at time.Time) telemetry.ControlWindow { + return telemetry.ControlWindow{MeanW: power, MinW: power - 10, MaxW: power + 10, First: at.Add(-6 * time.Second), Last: at, Count: 7} + } + base := map[string]telemetry.ControlBaseline{ + "battery:battery": {Driver: "battery", Kind: telemetry.DerBattery, Window: window(0, now.Add(-25*time.Second))}, + "grid:meter": {Driver: "grid", Kind: telemetry.DerMeter, Window: window(400, now.Add(-25*time.Second))}, + "solar:pv": {Driver: "solar", Kind: telemetry.DerPV, Window: window(-1200, now.Add(-25*time.Second))}, + } + for _, tc := range []struct { + name string + device, grid, pv float64 + separate bool + want string + }{ + {"one kW charge", 1000, 1400, -1200, true, "confirmed"}, + {"one kW discharge", -1000, -600, -1200, true, "confirmed"}, + {"noise within tolerance", 990, 1430, -1190, true, "confirmed"}, + {"new household load", 1000, 2600, -1200, true, "site_change_differs"}, + {"household load stopped", 1000, 200, -1200, true, "site_change_differs"}, + {"solar changed at the same time", 1000, 1400, -2200, true, "other_flows_changed"}, + {"same sensor twice", 1000, 1400, -1200, false, "independent_source_unknown"}, + {"too small to attribute", 100, 500, -1200, true, "no_clear_change"}, + {"wrong site direction", 1000, -600, -1200, true, "site_change_differs"}, + } { + t.Run(tc.name, func(t *testing.T) { + after := map[string]telemetry.ControlBaseline{ + "battery:battery": {Driver: "battery", Kind: telemetry.DerBattery, Window: window(tc.device, now)}, + "grid:meter": {Driver: "grid", Kind: telemetry.DerMeter, Window: window(tc.grid, now)}, + "solar:pv": {Driver: "solar", Kind: telemetry.DerPV, Window: window(tc.pv, now)}, + } + c := telemetry.CommandEvidence{Driver: "battery", Kind: "battery", Since: now.Add(-20 * time.Second), Baseline: base} + reason, _, _ := independentResponse(c, "grid", tc.separate, after, now) + if reason != tc.want { + t.Fatalf("got %s, want %s", reason, tc.want) + } + if tc.want == "confirmed" { + for _, missing := range []string{"grid:meter", "battery:battery", "solar:pv"} { + saved := after[missing] + delete(after, missing) + reason, _, _ = independentResponse(c, "grid", true, after, now) + if reason == "confirmed" { + t.Fatalf("confirmed without %s", missing) + } + after[missing] = saved + } + meter := after["grid:meter"] + meter.Window.Last = now.Add(-time.Minute) + after["grid:meter"] = meter + reason, _, _ = independentResponse(c, "grid", true, after, now) + if reason == "confirmed" { + t.Fatal("stale meter confirmed effect") + } + } + }) + } +} diff --git a/go/internal/api/api_support_report.go b/go/internal/api/api_support_report.go index 582cd08d..f2796359 100644 --- a/go/internal/api/api_support_report.go +++ b/go/internal/api/api_support_report.go @@ -27,6 +27,7 @@ package api import ( "context" + "encoding/json" "fmt" "github.com/srcfl/ftw/go/internal/units" @@ -232,6 +233,11 @@ func (s *Server) buildSupportReport(ctx context.Context, now time.Time) string { var b strings.Builder writeReportHeader(&b, s.deps.Version, now) writeFindings(&b, findings) + if feedback, err := json.MarshalIndent(s.controlFeedback(now), "", " "); err == nil { + b.WriteString("## Command feedback\n\nDriver-call results, readbacks and measured power are separate evidence. Missing values are unknown.\n\n```json\n") + b.Write(feedback) + b.WriteString("\n```\n\n") + } writeRightNow(&b, ctrl, snap, activeSlot, targets, slotEnergy, now) writePlanControlDecision(&b, plan, slotEnergy) writePlanSection(&b, plan, lastReplanAt, lastReplanReason, now) diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 290124a2..ca1f5a9e 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -819,6 +819,9 @@ func (r *Registry) runLoop(rd *runningDriver) { invalidateCommandSequence() defaultCtx, cancel := context.WithTimeout(context.Background(), defaultRecoveryTimeout) defaultErr := rd.driver.DefaultMode(defaultCtx) + if r.tel != nil { + r.tel.EndCommandControl(rd.cfg.Name, defaultErr != nil) + } cancel() if defaultErr != nil { scheduleRecovery() @@ -851,6 +854,9 @@ func (r *Registry) runLoop(rd *runningDriver) { cmdCtx, cancel = context.WithTimeout(context.Background(), defaultRecoveryTimeout) } err := rd.driver.DefaultMode(cmdCtx) + if r.tel != nil { + r.tel.EndCommandControl(rd.cfg.Name, err != nil) + } if cancel != nil { cancel() } @@ -977,10 +983,24 @@ func (r *Registry) runLoop(rd *runningDriver) { err = rejection break } + var evidence telemetry.CommandEvidence + if r.tel != nil { + evidence = r.tel.BeginCommand(rd.cfg.Name, cmd.payload, time.Now()) + } err = rd.driver.Command(commandCtx, cmd.payload) if err == nil { err = commandContextError(cmdCtx, commandCtx) } + if r.tel != nil { + result := "accepted" + if err != nil { + result = "failed" + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + result = "unconfirmed" + } + r.tel.CompleteCommand(evidence, result) + } if err != nil { err = restoreAfterCommand(err) } else { diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go index a39fd015..2d59bab1 100644 --- a/go/internal/ocpp/control.go +++ b/go/internal/ocpp/control.go @@ -114,7 +114,20 @@ var ErrNotConnected = errors.New("ocpp: charger not connected") // Command applies an EV control command to a connected charge point. The // signature matches drivers.Registry.Send so it can back a loadpoint // SenderFunc directly. -func (s *Server) Command(ctx context.Context, id string, payload []byte) error { +func (s *Server) Command(ctx context.Context, id string, payload []byte) (resultErr error) { + if s != nil && s.handler != nil && s.handler.tel != nil { + record := s.handler.tel.BeginCommand(id, payload, time.Now()) + defer func() { + result := "accepted" + if resultErr != nil { + result = "failed" + } + if errors.Is(resultErr, context.Canceled) || errors.Is(resultErr, context.DeadlineExceeded) { + result = "unconfirmed" + } + s.handler.tel.CompleteCommand(record, result) + }() + } if s == nil || s.cs == nil { return errors.New("ocpp: server not running") } diff --git a/go/internal/telemetry/command_evidence.go b/go/internal/telemetry/command_evidence.go new file mode 100644 index 00000000..4498e9df --- /dev/null +++ b/go/internal/telemetry/command_evidence.go @@ -0,0 +1,223 @@ +package telemetry + +import ( + "encoding/json" + "math" + "time" +) + +// CommandEvidence records a driver call, not proof that hardware obeyed it. +// Keep one command per device function so a hybrid's PV cap cannot replace +// its battery command. The registry clears these records on driver restart. +type CommandEvidence struct { + Driver string + Kind string + Action string + PowerW *float64 + At time.Time + Since time.Time + Result string + ReadbackMismatchSince time.Time + PowerMismatchSince time.Time + PowerMatchSince time.Time + LastObservation time.Time + Baseline map[string]ControlBaseline +} + +func commandKind(action string) string { + switch action { + case "battery": + return "battery" + case "ev_set_current", "ev_pause", "ev_resume", "ev_start": + return "ev" + case "curtail", "curtail_disable": + return "pv" + case "v2x_set_power": + return "v2x_charger" + default: + return "" + } +} + +func (s *Store) BeginCommand(driver string, payload []byte, now time.Time) CommandEvidence { + var request struct { + Action string `json:"action"` + PowerW *float64 `json:"power_w"` + } + if json.Unmarshal(payload, &request) != nil { + return CommandEvidence{} + } + if request.Action == "ev_pause" { + zero := 0.0 + request.PowerW = &zero + } + kind := commandKind(request.Action) + if kind == "" || (request.PowerW != nil && !finite(*request.PowerW)) { + return CommandEvidence{} + } + c := CommandEvidence{Driver: driver, Kind: kind, Action: request.Action, PowerW: request.PowerW, At: now, Since: now, Result: "pending"} + s.mu.Lock() + defer s.mu.Unlock() + if s.commands == nil { + s.commands = map[string]CommandEvidence{} + } + k := driver + ":" + kind + if old, ok := s.commands[k]; ok && old.Result == "accepted" && old.Action == c.Action && samePower(old.PowerW, c.PowerW) { + c.Since = old.Since + c.ReadbackMismatchSince = old.ReadbackMismatchSince + c.PowerMismatchSince = old.PowerMismatchSince + c.PowerMatchSince = old.PowerMatchSince + c.LastObservation = old.LastObservation + c.Baseline = old.Baseline + } + if c.Baseline == nil { + c.Baseline = s.controlBaseline(now) + } + s.commands[k] = c + c.Baseline = nil // the completion token never owns the stored baseline + if c.PowerW != nil { + v := *c.PowerW + c.PowerW = &v + } + return c +} + +func samePower(a, b *float64) bool { + return a == nil && b == nil || a != nil && b != nil && math.Abs(*a-*b) <= math.Max(100, math.Abs(*a)*0.05) +} + +// CompleteCommand never retains raw driver errors: they may contain vendor +// URLs or credentials. Detailed errors remain in the existing private logs. +func (s *Store) CompleteCommand(c CommandEvidence, result string) { + if c.Kind == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + k := c.Driver + ":" + c.Kind + if latest, ok := s.commands[k]; ok && latest.At.Equal(c.At) { + latest.Result = result + s.commands[k] = latest + } +} + +func (s *Store) CommandEvidence(driver, kind string) (CommandEvidence, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + c, ok := s.commands[driver+":"+kind] + if c.Baseline != nil { + copied := make(map[string]ControlBaseline, len(c.Baseline)) + for k, v := range c.Baseline { + copied[k] = v + } + c.Baseline = copied + } + if c.PowerW != nil { + v := *c.PowerW + c.PowerW = &v + } + return c, ok +} + +func (s *Store) ClearCommandEvidence(driver string) { + s.mu.Lock() + defer s.mu.Unlock() + for k, c := range s.commands { + if c.Driver == driver { + delete(s.commands, k) + } + } +} + +// EndCommandControl invalidates comparisons with an earlier command when the +// actor returns the device to its own control. Keep failed attempts visible. +func (s *Store) EndCommandControl(driver string, failed bool) { + s.mu.Lock() + defer s.mu.Unlock() + for k, c := range s.commands { + if c.Driver != driver { + continue + } + if failed { + c.Result = "default_failed" + } else if c.Result == "accepted" || c.Result == "pending" || c.Result == "default_failed" { + c.Result = "released" + } + c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} + s.commands[k] = c + } +} + +// Called under mu only when telemetry arrives. UI polling never starts or +// advances a mismatch timer. A gap in readings requires new evidence. +func (s *Store) observeCommand(driver string, kind DerType, power float64, data json.RawMessage, now time.Time) { + k := driver + ":" + kind.String() + c, ok := s.commands[k] + if !ok || c.Result != "accepted" || c.PowerW == nil { + return + } + var d struct { + SetpointW *float64 `json:"setpoint_w"` + ControlPowerW *float64 `json:"control_power_w"` + ControlPowerAvailable *bool `json:"control_power_available"` + PowerObservedAt string `json:"power_observed_at"` + PowerMaxAgeS float64 `json:"power_max_age_s"` + } + if json.Unmarshal(data, &d) != nil { + c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} + s.commands[k] = c + return + } + if now.Sub(c.LastObservation) > time.Minute { + c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} + } + c.LastObservation = now + mark := func(since *time.Time, mismatch bool) { + if !mismatch { + *since = time.Time{} + } else if since.IsZero() { + *since = now + } + } + readbackGap := 0.0 + if d.SetpointW != nil { + readbackGap = math.Abs(*d.SetpointW - *c.PowerW) + if kind == DerPV { + readbackGap = math.Abs(math.Abs(*d.SetpointW) - math.Abs(*c.PowerW)) + } + } + mark(&c.ReadbackMismatchSince, d.SetpointW != nil && finite(*d.SetpointW) && readbackGap > math.Max(100, math.Abs(*c.PowerW)*0.05)) + if d.ControlPowerW != nil { + power = *d.ControlPowerW + } + fresh := d.ControlPowerAvailable == nil || *d.ControlPowerAvailable + if d.PowerObservedAt != "" { + at, err := time.Parse(time.RFC3339Nano, d.PowerObservedAt) + age := time.Duration(d.PowerMaxAgeS * float64(time.Second)) + if age <= 0 || age > 3*time.Minute { + age = time.Minute + } + fresh = fresh && err == nil && !at.After(now) && now.Sub(at) <= age + } + gap := math.Abs(power - *c.PowerW) + if kind == DerPV { + gap = math.Max(0, math.Abs(power)-math.Abs(*c.PowerW)) + } + mark(&c.PowerMismatchSince, fresh && finite(power) && gap > ControlToleranceW(*c.PowerW)) + mark(&c.PowerMatchSince, fresh && finite(power) && gap <= ControlToleranceW(*c.PowerW)) + s.commands[k] = c +} + +// ControlToleranceW is the response tolerance, not a device rating or safety limit. +func ControlToleranceW(sent float64) float64 { return math.Max(100, math.Abs(sent)*0.1) } + +// PowerFollowsCommand handles PV's ceiling separately from a signed power target. +func PowerFollowsCommand(kind string, sent, actual float64) bool { + if !finite(sent) || !finite(actual) { + return false + } + if kind == "pv" { + return math.Abs(actual) <= math.Abs(sent)+ControlToleranceW(sent) + } + return math.Abs(actual-sent) <= ControlToleranceW(sent) +} diff --git a/go/internal/telemetry/command_evidence_test.go b/go/internal/telemetry/command_evidence_test.go new file mode 100644 index 00000000..5b5b3a11 --- /dev/null +++ b/go/internal/telemetry/command_evidence_test.go @@ -0,0 +1,118 @@ +package telemetry + +import ( + "encoding/json" + "testing" + "time" +) + +func TestCommandEvidenceTracksPhysicalResponse(t *testing.T) { + s := NewStore() + start := time.Now() + c := s.BeginCommand("pixii", []byte(`{"action":"battery","power_w":0}`), start) + s.CompleteCommand(c, "accepted") + got, _ := s.CommandEvidence("pixii", "battery") + if !got.PowerMatchSince.IsZero() { + t.Fatal("driver acceptance claimed measured success") + } + // Writes succeeded, but another writer or internal control changes the setpoint. + sample := func(at time.Time, data string, power float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.observeCommand("pixii", DerBattery, power, json.RawMessage(data), at) + } + sample(start.Add(time.Second), `{"setpoint_w":-1500,"control_power_w":-1400}`, -1300) + sample(start.Add(35*time.Second), `{"setpoint_w":-1600,"control_power_w":-1500}`, -1400) + got, _ = s.CommandEvidence("pixii", "battery") + if got.ReadbackMismatchSince != start.Add(time.Second) || got.PowerMismatchSince != start.Add(time.Second) || !got.PowerMatchSince.IsZero() { + t.Fatalf("mismatch evidence: %+v", got) + } + // Fresh repeated observations, not polling status, establish recovery. + sample(start.Add(40*time.Second), `{"setpoint_w":0,"control_power_w":30}`, 30) + sample(start.Add(52*time.Second), `{"setpoint_w":0,"control_power_w":20}`, 20) + got, _ = s.CommandEvidence("pixii", "battery") + if !got.ReadbackMismatchSince.IsZero() || !got.PowerMismatchSince.IsZero() || got.PowerMatchSince != start.Add(40*time.Second) { + t.Fatalf("recovery: %+v", got) + } + sample(start.Add(3*time.Minute), `{"setpoint_w":0,"control_power_w":0}`, 0) + got, _ = s.CommandEvidence("pixii", "battery") + if got.PowerMatchSince != start.Add(3*time.Minute) { + t.Fatal("gap retained proof of live response") + } +} + +func TestCommandEvidenceDoesNotConfuseACWithDCOrMissingPower(t *testing.T) { + s := NewStore() + now := time.Now() + c := s.BeginCommand("hybrid", []byte(`{"action":"battery","power_w":-1000}`), now) + s.CompleteCommand(c, "accepted") + s.mu.Lock() + s.observeCommand("hybrid", DerBattery, -300, json.RawMessage(`{"control_power_w":-990}`), now.Add(time.Second)) + s.mu.Unlock() + got, _ := s.CommandEvidence("hybrid", "battery") + if got.PowerMatchSince.IsZero() { + t.Fatal("compared AC request with DC measurement") + } + s.mu.Lock() + s.observeCommand("hybrid", DerBattery, -1000, json.RawMessage(`{"control_power_available":false}`), now.Add(2*time.Second)) + s.mu.Unlock() + got, _ = s.CommandEvidence("hybrid", "battery") + if !got.PowerMatchSince.IsZero() || !got.PowerMismatchSince.IsZero() { + t.Fatal("missing measurement is not proof or failure") + } +} + +func TestCommandEvidenceLifecycleAndIsolation(t *testing.T) { + s := NewStore() + now := time.Now() + c := s.BeginCommand("hybrid", []byte(`{"action":"battery","power_w":1000}`), now) + *c.PowerW = 9999 + got, _ := s.CommandEvidence("hybrid", "battery") + if *got.PowerW != 1000 { + t.Fatal("caller mutated evidence") + } + s.CompleteCommand(c, "accepted") + next := s.BeginCommand("hybrid", []byte(`{"action":"battery","power_w":1020}`), now.Add(time.Second)) + s.CompleteCommand(c, "failed") // late result from an old call + got, _ = s.CommandEvidence("hybrid", "battery") + if got.Result != "pending" || got.Since != now { + t.Fatalf("late result or small correction: %+v", got) + } + s.CompleteCommand(next, "accepted") + pv := s.BeginCommand("hybrid", []byte(`{"action":"curtail","power_w":-3000}`), now.Add(2*time.Second)) + s.CompleteCommand(pv, "accepted") + got, _ = s.CommandEvidence("hybrid", "battery") + if *got.PowerW != 1020 { + t.Fatal("PV command replaced battery evidence") + } + s.EndCommandControl("hybrid", false) + got, _ = s.CommandEvidence("hybrid", "battery") + if got.Result != "released" { + t.Fatal("default retained old command") + } + s.Remove("hybrid") + if _, ok := s.CommandEvidence("hybrid", "battery"); ok { + t.Fatal("restart retained evidence") + } +} + +func TestCommandEvidencePVIsCeilingAndStalePowerIsNotProof(t *testing.T) { + s := NewStore() + now := time.Now() + c := s.BeginCommand("pv", []byte(`{"action":"curtail","power_w":-3000}`), now) + s.CompleteCommand(c, "accepted") + s.mu.Lock() + s.observeCommand("pv", DerPV, -500, json.RawMessage(`{}`), now.Add(time.Second)) + s.mu.Unlock() + got, _ := s.CommandEvidence("pv", "pv") + if !got.PowerMismatchSince.IsZero() { + t.Fatal("weak sun is not failed curtailment") + } + s.mu.Lock() + s.observeCommand("pv", DerPV, -500, json.RawMessage(`{"power_observed_at":"2020-01-01T00:00:00Z"}`), now.Add(2*time.Second)) + s.mu.Unlock() + got, _ = s.CommandEvidence("pv", "pv") + if !got.PowerMatchSince.IsZero() { + t.Fatal("stale source claimed proof") + } +} diff --git a/go/internal/telemetry/control_observations.go b/go/internal/telemetry/control_observations.go new file mode 100644 index 00000000..8e3c1105 --- /dev/null +++ b/go/internal/telemetry/control_observations.go @@ -0,0 +1,129 @@ +package telemetry + +import ( + "encoding/json" + "math" + "time" +) + +// Short, bounded raw observations for independent response checks. These are +// never model estimates or the house-load value derived from energy balance. +type ControlObservation struct { + PowerW float64 + At time.Time +} +type ControlWindow struct { + MeanW, MinW, MaxW float64 + First, Last time.Time + Count int +} +type ControlBaseline struct { + Driver string + Kind DerType + Window ControlWindow +} + +// Caller holds mu. At most one point per second and 32 points per DER. +func (s *Store) recordControlObservation(driver string, kind DerType, power float64, data json.RawMessage, now time.Time) { + if kind == DerVehicle { + return + } + k := key(driver, kind) + invalidate := func() { delete(s.controlObservations, k) } + var d struct { + ControlPowerW *float64 `json:"control_power_w"` + ControlPowerAvailable *bool `json:"control_power_available"` + PowerObservedAt string `json:"power_observed_at"` + } + if len(data) > 0 && json.Unmarshal(data, &d) != nil { + invalidate() + return + } + if d.ControlPowerAvailable != nil && !*d.ControlPowerAvailable { + invalidate() + return + } + if d.ControlPowerW != nil { + power = *d.ControlPowerW + } + if !finite(power) { + invalidate() + return + } + at := now + if d.PowerObservedAt != "" { + var err error + at, err = time.Parse(time.RFC3339Nano, d.PowerObservedAt) + if err != nil || at.After(now) || now.Sub(at) > 10*time.Second { + invalidate() + return + } + } + if s.controlObservations == nil { + s.controlObservations = map[string][]ControlObservation{} + } + points := s.controlObservations[k] + p := ControlObservation{power, at} + if len(points) > 0 && !at.After(points[len(points)-1].At) { + return + } + if len(points) > 0 && at.Unix() == points[len(points)-1].At.Unix() { + points[len(points)-1] = p + } else { + points = append(points, p) + } + if len(points) > 32 { + copy(points, points[len(points)-32:]) + points = points[:32] + } + s.controlObservations[k] = points +} + +func observationWindow(points []ControlObservation, from, now time.Time) ControlWindow { + var w ControlWindow + for _, p := range points { + if p.At.Before(from) || p.At.After(now) { + continue + } + if w.Count == 0 { + w.MinW, w.MaxW, w.First = p.PowerW, p.PowerW, p.At + } + w.MeanW += p.PowerW + w.MinW = math.Min(w.MinW, p.PowerW) + w.MaxW = math.Max(w.MaxW, p.PowerW) + w.Last = p.At + w.Count++ + } + if w.Count > 0 { + w.MeanW /= float64(w.Count) + } + return w +} +func (w ControlWindow) Usable(now time.Time) bool { + return w.Count >= 2 && w.Last.Sub(w.First) >= 5*time.Second && !w.Last.After(now) && now.Sub(w.Last) <= 10*time.Second +} +func (s *Store) controlBaseline(now time.Time) map[string]ControlBaseline { + result := map[string]ControlBaseline{} + for k, rd := range s.readings { + if rd.DerType == DerVehicle { + continue + } + w := observationWindow(s.controlObservations[k], now.Add(-12*time.Second), now) + if w.Usable(now) { + result[k] = ControlBaseline{rd.Driver, rd.DerType, w} + } + } + return result +} +func (s *Store) ControlWindows(from, now time.Time) map[string]ControlBaseline { + s.mu.RLock() + defer s.mu.RUnlock() + result := map[string]ControlBaseline{} + for k, rd := range s.readings { + if rd.DerType == DerVehicle { + continue + } + result[k] = ControlBaseline{rd.Driver, rd.DerType, observationWindow(s.controlObservations[k], from, now)} + } + return result +} diff --git a/go/internal/telemetry/control_observations_test.go b/go/internal/telemetry/control_observations_test.go new file mode 100644 index 00000000..3b20fd96 --- /dev/null +++ b/go/internal/telemetry/control_observations_test.go @@ -0,0 +1,59 @@ +package telemetry + +import ( + "testing" + "time" +) + +func TestControlWindowsAreBoundedAndMissingDataBreaksProof(t *testing.T) { + s := NewStore() + now := time.Now() + for i := 0; i < 1000; i++ { + s.recordControlObservation("battery", DerBattery, 1000, []byte(`{}`), now.Add(time.Duration(i)*time.Second)) + } + points := s.controlObservations["battery:battery"] + if len(points) != 32 { + t.Fatalf("unbounded series: %d", len(points)) + } + at := now.Add(999 * time.Second) + w := observationWindow(points, at.Add(-12*time.Second), at) + if !w.Usable(at) || w.MeanW != 1000 { + t.Fatalf("stable window: %+v", w) + } + s.recordControlObservation("battery", DerBattery, 1000, []byte(`{"control_power_available":false}`), at.Add(time.Second)) + if len(s.controlObservations["battery:battery"]) != 0 { + t.Fatal("missing power retained an apparently complete window") + } + s.recordControlObservation("battery", DerBattery, 1000, []byte(`{"power_observed_at":"2020-01-01T00:00:00Z"}`), at.Add(2*time.Second)) + if len(s.controlObservations["battery:battery"]) != 0 { + t.Fatal("stale source became a new measurement") + } +} + +func TestProofDoesNotDependOnStatusPolling(t *testing.T) { + s := NewStore() + start := time.Now() + // Let the site settle before requesting a change. + for i := 0; i <= 6; i++ { + at := start.Add(time.Duration(i) * time.Second) + s.mu.Lock() + s.readings["battery:battery"] = &DerReading{Driver: "battery", DerType: DerBattery} + s.recordControlObservation("battery", DerBattery, 0, []byte(`{}`), at) + s.mu.Unlock() + } + c := s.BeginCommand("battery", []byte(`{"action":"battery","power_w":1000}`), start.Add(7*time.Second)) + s.CompleteCommand(c, "accepted") + got, _ := s.CommandEvidence("battery", "battery") + if got.Baseline["battery:battery"].Window.Count != 7 { + t.Fatalf("no before-command window: %+v", got) + } + for i := 8; i <= 20; i++ { + s.mu.Lock() + s.observeCommand("battery", DerBattery, 1000, []byte(`{}`), start.Add(time.Duration(i)*time.Second)) + s.mu.Unlock() + } + got, _ = s.CommandEvidence("battery", "battery") + if got.PowerMatchSince.IsZero() || got.LastObservation.Sub(got.PowerMatchSince) < 10*time.Second { + t.Fatal("no proof without an API poll") + } +} diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index dffc0211..05a55e24 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -290,10 +290,12 @@ type MetricSample struct { // Store is the central telemetry sink that drivers emit into and that the // control loop reads from. Thread-safe. type Store struct { - mu sync.RWMutex - readings map[string]*DerReading // key = driver + ":" + der_type - filters map[string]*KalmanFilter1D - health map[string]*DriverHealth + mu sync.RWMutex + readings map[string]*DerReading // key = driver + ":" + der_type + filters map[string]*KalmanFilter1D + health map[string]*DriverHealth + commands map[string]CommandEvidence + controlObservations map[string][]ControlObservation processNoise float64 measurementNoise float64 @@ -455,6 +457,9 @@ func (s *Store) Update(driver string, t DerType, rawW float64, soc *float64, dat UpdatedAt: now, } + s.observeCommand(driver, t, rawW, data, now) + s.recordControlObservation(driver, t, rawW, data, now) + // Auto-buffer the standard fields (raw, not smoothed — we store ground // truth and let consumers smooth as they like). tsMs := now.UnixMilli() @@ -740,6 +745,18 @@ func (s *Store) Remove(driver string) { delete(s.filters, k) } delete(s.health, driver) + for _, kind := range allDerTypes { + delete(s.controlObservations, key(driver, kind)) + } + for k, c := range s.commands { + c.Baseline = nil + s.commands[k] = c + } + for k, command := range s.commands { + if command.Driver == driver { + delete(s.commands, k) + } + } s.mu.Unlock() prefix := driver + ":" diff --git a/web/app.js b/web/app.js index ee0b4e5e..d166f8b7 100644 --- a/web/app.js +++ b/web/app.js @@ -2,6 +2,7 @@ (function () { "use strict"; + var controlFeedbackExpiry; const evPlanUI = import("/ev-plan.js").catch(function () { return null; }); const POLL_INTERVAL = 2000; // status poll cadence — snappier cards @@ -1012,6 +1013,11 @@ // Drivers renderDrivers(data.drivers || {}, dispatchByDriver); + if (window.FTWControlFeedback) { + window.FTWControlFeedback.render(document.getElementById("control-results"), data.control_feedback, true); + clearTimeout(controlFeedbackExpiry); + controlFeedbackExpiry = setTimeout(function () { window.FTWControlFeedback.render(document.getElementById("control-results"), data.control_feedback, false); }, 15000); + } // Dispatch renderDispatch(data.dispatch || []); @@ -2752,6 +2758,10 @@ // when no hold is active. function manualStatusText(lp, d) { if (lp && lp.manual_restore_unconfirmed) return "Confirm how to continue charging. FTW could not confirm the charger or connection."; + if (lp && lp.control_feedback && lp.control_feedback.severity === "warning" && window.FTWControlFeedback) { + var feedback = window.FTWControlFeedback.text(lp.control_feedback); + return feedback.detail + " " + feedback.action; + } var m = lp && lp.manual; if (!lp || !lp.manual_active || !m || !m.active) return null; var reqA = m.requested_a > 0 ? Math.round(m.requested_a) + " A" : formatW(m.requested_w || lp.manual_charge_w || 0); @@ -2821,6 +2831,10 @@ var hasSchedule = lp.schedule && (lp.schedule.finish_at_vehicle_limit === true || lp.schedule.soc > 0); if (lp.manual_restore_unconfirmed) { text = manualStatusText(lp, d); + } else if (lp.control_feedback && lp.control_feedback.severity === "warning" && window.FTWControlFeedback) { + var feedback = window.FTWControlFeedback.text(lp.control_feedback); + text = feedback.detail + " " + feedback.action; + tone = "var(--amber)"; } else if (lp.charger && !lp.charger.available) { text = lp.charger.known ? "Charger status is out of date. FTW cannot confirm whether the car is charging." diff --git a/web/control-feedback.js b/web/control-feedback.js new file mode 100644 index 00000000..64f480ed --- /dev/null +++ b/web/control-feedback.js @@ -0,0 +1,119 @@ +// Presentation only. Core owns evidence, freshness, limits and response checks. +const messages = { + device_limit: ['Charger limit', 'The charger’s own current limit is below this request.', 'Check the charger’s current limit in its app or settings.'], + offered_current_lower: ['Charger offers less current', 'The charger reports a lower current offer than FTW requested.', 'Check its load balancing and current limits. The reason is not confirmed.'], + setpoint_changed: ['Setpoint does not match', 'The device reports a different setpoint from the command FTW sent.', 'Check for another controller or a device mode that changes the setpoint. FTW cannot tell which caused this.'], + power_differs: ['Power does not follow the command', 'Fresh measurements differ from the command after the response wait.', 'The device has not reported a confirmed cause. Check its app or status display.'], + command_failed: ['Command failed', 'The driver could not complete this command. The device may still have received part of it.', 'Check the device connection and status. FTW attempts to restore its safe default.'], + command_unconfirmed: ['Command result unknown', 'FTW stopped waiting before it could confirm the call’s result.', 'The command may have reached the device. Check fresh power readings before trying again.'], + default_failed: ['Safe default not confirmed', 'FTW has blocked further control while it retries the device’s safe default.', 'Check the device connection and its own status.'], + telemetry_stale: ['Waiting for fresh readings', 'FTW cannot verify the effect with the readings available.', 'Check the device connection. This view updates when fresh readings arrive.'], + site_meter_stale: ['Waiting for the site meter', 'FTW has stopped dispatch because site power readings are missing or too old.', 'Check the site meter connection. Dispatch resumes when readings recover.'], + device_fault: ['Device cannot follow commands', 'The device or its driver reports a fault that blocks control.', 'Check the reported reason and the device’s status.'], + fuse_limit: ['Limited by the main fuse', 'FTW reduced the request to keep site current within its limit.', 'The rate can rise when other household demand falls.'], + fuse_cooldown: ['Waiting after a fuse limit', 'FTW is waiting before restoring the charging rate.', 'Charging resumes when the fuse protection allows it.'], + charger_limit: ['Limited by FTW’s charger setting', 'FTW reduced the request to the configured charging limit.', 'Check the charger limit in FTW settings.'], + core_limit: ['Adjusted by FTW', 'FTW changed the target through its safety or ramp limits.', 'The control tick does not report which limit applied.'], + observe_only: ['Monitoring only', 'FTW reads this device but does not send commands to it.', 'Enable control in the device settings if you want FTW to control it.'], + disabled: ['Device disabled', 'FTW does not control this device while it is disabled.', 'Check its device settings.'], + device_control: ['Device controls itself', 'FTW returned the device to its own control.', 'The earlier FTW command no longer describes its target.'], + not_connected: ['No car connected', 'The charger reports that no car is connected.', 'Connect a car to charge.'], + waiting_response: ['Waiting for measured response', 'FTW is checking fresh power readings against the command.', 'Starting and changing phases can take time. A driver reply alone does not confirm the effect.'], + response_unknown: ['Effect not verified', 'FTW does not have enough evidence to compare the command with measured power.', 'A successful driver call alone does not confirm the effect.'], + no_command: ['No command recorded yet', 'FTW has no command result for this device since it started.', 'Fresh measurements will show the effect once control runs.'], + power_observed: ['Device follows the command', 'The device’s own power readings follow the command within the response tolerance.', 'Independent confirmation is a separate step. A charging goal needs its own completion evidence.'], + idle: ['No power requested', 'FTW currently requests no charge or discharge.', 'The active mode decides when the next request is needed.'], + no_plan_budget: ['Waiting for the plan', 'The plan assigns no charging energy to this interval.', 'Check the next charging window, or choose Charge now.'], + pv_surplus_pause: ['Waiting for spare solar', 'There is not enough spare solar to start charging.', 'Charging resumes when the solar rule allows it.'], + pv_surplus: ['Using spare solar', 'FTW is matching charging to the available solar surplus.', 'The rate changes with household demand and solar output.'], + plan: ['Following the plan', 'FTW is using the charging plan for this interval.', 'Power readings show whether the charger follows the request.'], + manual_hold: ['Manual request', 'FTW is using your manual charging request.', 'Power readings show whether the charger follows it.'], + wake_kick: ['Starting the charger', 'FTW is trying to wake the connected charger.', 'Waiting for fresh power readings.'], +}; + +export function feedbackRows(value) { + if (!Array.isArray(value)) return []; + return value.filter(row => row && typeof row === 'object' && typeof row.driver === 'string' && typeof row.reason === 'string'); +} + +export function feedbackText(row, live = true) { + if (!live) return {title:'Last known control status', detail:'The box’s current control result is unavailable.', action:'Waiting for a fresh status report.'}; + const words = messages[row.reason] || ['Effect not verified', 'This box reports a control state this app does not recognise.', 'Check the device status.']; + return {title:words[0], detail:words[1], action:words[2]}; +} +const number = value => typeof value === 'number' && Number.isFinite(value); +export function feedbackPower(value, kind) { + if (!number(value)) return 'Unknown'; + if (Math.abs(value) < 1) return '0 W'; + const size = Math.abs(value) >= 1000 ? `${(Math.abs(value)/1000).toFixed(1)} kW` : `${Math.round(Math.abs(value))} W`; + return `${size} ${kind === 'pv' ? 'generation' : value < 0 ? 'discharge' : 'charge'}`; +} +export function feedbackValues(row, live = true) { + const values = [ + ['Requested', feedbackPower(row.requested_w, row.kind)], + ['Sent to driver', feedbackPower(row.sent_w, row.kind)], + ['Device setpoint', live ? feedbackPower(row.readback_w, row.kind) : 'Not current'], + ['Measured', live ? feedbackPower(row.actual_w, row.kind) : 'Not current'], + ]; + if (number(row.tolerance_w)) values.push(['Response tolerance', `${Math.round(row.tolerance_w)} W`]); + if (number(row.requested_a)) values.push(['Requested current', `${row.requested_a.toFixed(1)} A`]); + if (number(row.offered_a)) values.push(['Charger offer', live ? `${row.offered_a.toFixed(1)} A` : 'Not current']); + if (number(row.device_limit_a)) values.push(['Charger limit', live ? `${row.device_limit_a.toFixed(1)} A` : 'Not current']); + if (number(row.device_delta_w)) values.push(['Device change', live ? `${(Math.abs(row.device_delta_w)/1000).toFixed(2)} kW ${row.device_delta_w < 0 ? "less" : "more"} site demand` : 'Not current']); + if (number(row.site_delta_w)) values.push(['Site change', live ? `${(Math.abs(row.site_delta_w)/1000).toFixed(2)} kW ${row.site_delta_w < 0 ? 'less' : 'more'} import` : 'Not current']); + return values; +} +export function feedbackProof(row, live = true) { + if (!live) return 'Current effect unknown'; + if (row.verification_tier === 2) return 'Tier 2 · Confirmed at site meter'; + if (row.verification_tier === 1) return 'Tier 1 · Device reports the expected power'; + if (row.verification_tier === 0) return 'Tier 0 · Driver accepted the command'; + return 'No command acknowledgement'; +} +export function feedbackSite(row, live = true) { + if (!live) return 'Waiting for fresh measurements.'; + const text = { + confirmed: 'A separate site meter shows the matching power change while other monitored flows stayed steady.', + independent_source_unknown: 'Independent confirmation needs a separate, identified meter. These sources do not establish that.', + no_site_meter: 'No site meter is available for independent confirmation.', + no_baseline: 'No steady reading before the command is available for a site comparison.', + waiting_for_meter: 'Waiting for a fresh, stable site-meter window.', + readings_not_aligned: 'Device and site readings are too far apart in time to compare.', + flows_changing: 'Power is changing during the comparison. Independent confirmation remains uncertain.', + no_clear_change: 'The change is too small to distinguish from other site activity.', + other_flows_changed: 'Other equipment changed or its readings are missing. FTW cannot isolate this response.', + site_change_differs: 'The site-meter change does not match. Another household load may have changed; the cause is not confirmed.', + device_response_unconfirmed: 'Independent confirmation waits for the device’s measured response.', + }; + return text[row.site_confirmation] || 'Independent confirmation is not available.'; +} + +// On-box renderer. The app uses the same presentation functions in Svelte. +export function renderFeedback(root, value, live = true) { + if (!root) return; + const rows = feedbackRows(value); + root.hidden = rows.length === 0; + // Preserve an open evidence table across the two-second status refresh. + const open = new Set(Array.from(root.querySelectorAll('details[open]')).map(el => el.dataset.device)); + root.replaceChildren(); + if (!rows.length) return; + const el = (tag, text, parent, cls) => { const node=document.createElement(tag); if (text) node.textContent=text; if(cls) node.className=cls; parent.appendChild(node); return node; }; + el('h2','Are you in control?',root); + for (const row of rows) { + const text=feedbackText(row,live); + const card=el('article','',root,'control-result' + (live && row.severity === 'warning' ? ' needs-attention' : '') + (!live ? ' not-current' : '')); + el('div',`${row.driver} · ${row.kind || 'device'}`,card,'control-device'); + el('h3',text.title,card); + el('p',text.detail,card); + if (live && row.device_reason) el('p',`Device reports: ${row.device_reason}`,card,'device-reason'); + el('p',text.action,card,'control-action'); + el('p',feedbackProof(row,live),card,'control-proof'); + el('p',feedbackSite(row,live),card,'control-action'); + const details=el('details','',card); details.dataset.device=`${row.driver}:${row.kind}`; details.open=open.has(details.dataset.device); + el('summary','Request and measurements',details); + const dl=el('dl','',details); + for (const [label,value] of feedbackValues(row,live)) {el('dt',label,dl);el('dd',value,dl);} + if (number(row.observed_at_ms)) el('p',`Last reading: ${new Date(row.observed_at_ms).toLocaleTimeString()}`,details,'control-time'); + } +} +if (typeof window !== 'undefined') window.FTWControlFeedback = {render:renderFeedback,text:feedbackText}; diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs new file mode 100644 index 00000000..c35d88f9 --- /dev/null +++ b/web/control-feedback.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { feedbackText,feedbackPower,feedbackProof,feedbackRows,feedbackValues,feedbackSite } from './control-feedback.js'; + +test('command acknowledgement, device measurement and site confirmation stay distinct',()=>{ + const row={driver:'battery',kind:'battery',reason:'power_observed',response:'device_reported'}; + assert.match(feedbackProof({...row,verification_tier:0}),/Tier 0/); + assert.match(feedbackProof({...row,verification_tier:1}),/Tier 1/); + assert.match(feedbackProof({...row,verification_tier:2}),/Tier 2/); + assert.doesNotMatch(feedbackProof({...row,verification_tier:2},false),/Confirmed|Tier 2/); + assert.match(feedbackText(row).detail,/device’s own/); + assert.match(feedbackSite({...row,site_confirmation:'other_flows_changed'}),/cannot isolate/); +}); + +test('unknown causes and missing readings never become success or zero',()=>{ + assert.equal(feedbackPower(null,'battery'),'Unknown'); + assert.equal(feedbackPower(NaN,'battery'),'Unknown'); + assert.equal(feedbackPower(0,'battery'),'0 W'); + assert.equal(feedbackPower(-1000,'battery'),'1.0 kW discharge'); + assert.match(feedbackText({reason:'power_differs'}).action,/not reported a confirmed cause/); + assert.match(feedbackText({reason:'future_reason'}).title,/not verified/); + assert.deepEqual(feedbackRows(undefined),[]); + assert.deepEqual(feedbackRows([null,{driver:'a'},42]),[]); + const values=feedbackValues({actual_w:1000,device_limit_a:8},false); + assert.equal(values.find(v=>v[0]==='Measured')[1],'Not current'); +}); + +test('known charger limits do not disappear while power flows',()=>{ + const text=feedbackText({reason:'device_limit',actual_w:5500}); + assert.match(text.detail,/charger’s own current limit/); + const values=feedbackValues({requested_a:16,device_limit_a:8,actual_w:5500,kind:'ev'}); + assert.deepEqual(values.find(v=>v[0]==='Charger limit'),['Charger limit','8.0 A']); +}); diff --git a/web/index.html b/web/index.html index 8165326d..6d8c8d94 100644 --- a/web/index.html +++ b/web/index.html @@ -180,6 +180,8 @@

Power now

load="0" ev="0" soc="0"> + +