From f55b265bfe1791b60e47d0a03e0d4c377bed1f14 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 8 Nov 2026 08:32:15 +0100 Subject: [PATCH 1/3] feat(mcp): events_query answers what happened at a time A new read tool asks the box's control journal (events.get, scope ftw.events.read) about a past window: each span in which a device, a source, dispatch or the mode held one verdict, each scored plan slot, and the notices the box sent. It is for questions such as why the app said FTW was not in control this morning, what happened at 06:14, or whether the battery did what FTW asked. - Inputs: since and until (RFC 3339, Unix ms, or a local time read in the home's zone; the last 24 hours by default), source, min_severity (warning or alarm) and limit (at most protocol.EventsMax). - Each span gives from and to (or ongoing) in the zone the box names in hello_ok, UTC without one; duration; kind, source, status, reason, severity and mode; askedW beside measuredW as mean, min and max; and the latest verdict's numbers, a *_ms time written as a time. - When the box cut the answer, next names the until that reads on: the oldest start or time of each list the box cut, the later of the two. - A box from before the journal answers E_UNKNOWN_OP for events.get with no field; the model hears "this box does not keep a control journal yet; update FTW". A refused src and E_UNAVAILABLE for the journal get their own words. The stub box answers events.get with spans and notices and records each query. Tests cover the default window and zone, a local since, Unix ms, source, severity, limit, paging, refused arguments and the old box. events_query stays off the public profile until it is reviewed there; events_list stays an unsupported name. Needs srcfl/ftw feat/control-journal (go/protocol EventSpan, Events with spans and notices). internal/mcp grows by about 330 lines of Go without tests. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 ++- docs/public-plugin.md | 5 +- internal/mcp/e2e_test.go | 26 ++- internal/mcp/events.go | 310 +++++++++++++++++++++++++++++++++++ internal/mcp/events_test.go | 249 ++++++++++++++++++++++++++++ internal/mcp/hints_test.go | 1 + internal/mcp/history_test.go | 16 +- internal/mcp/mcp.go | 4 +- internal/mcp/mcp_test.go | 2 +- internal/mcp/tools.go | 24 ++- 10 files changed, 635 insertions(+), 23 deletions(-) create mode 100644 internal/mcp/events.go create mode 100644 internal/mcp/events_test.go diff --git a/README.md b/README.md index 4bdd236..031b41e 100644 --- a/README.md +++ b/README.md @@ -266,17 +266,28 @@ to it yet. clients of 2025-03-26 to 2025-11-25 that start with `initialize`, without session ids or a GET stream. The read tools are `homes_list`, `status`, `chargers_list`, `cars_list`, `plan_get`, `plan_explain`, - `history_query`, `health_get`, `logs_tail` (with before/after cursors), - `drivers_status`, `config_get` and `measurement_diagnostics`, each with - typed arguments and the scope its box operation requires. + `history_query`, `events_query`, `health_get`, `logs_tail` (with + before/after cursors), `drivers_status`, `config_get` and + `measurement_diagnostics`, each with typed arguments and the scope its box + operation requires. `measurement_diagnostics` needs the owner's diagnostic grant. Both MCP profiles offer its fixed, read-only views of measurement reasons, saved charger associations, energy provenance and stored series. Names and hardware identifiers become opaque selections; other private fields stay out. Refresh the catalogue after a service restart. No arbitrary path, settings write, control or credential export is accepted by this tool. - `diag_run` and `events_list` are not listed until Core serves them. Cached - calls get an explicit unsupported result and never reach Core. + `events_query` answers "what happened at 06:14" from the box's control + journal (`events.get`, scope `ftw.events.read`): each span in which a + device, a source, dispatch or the mode held one verdict, and each scored + plan slot, in the home's time zone from the box's `hello_ok`, with its + length, asked against measured watts and the latest verdict's numbers, + then the notices the box sent. The window is the last 24 hours unless + `since` and `until` say otherwise; when the box cuts the answer, `next` + names the `until` that reads on. A box from before the journal answers + `E_UNKNOWN_OP`, and the tool says to update FTW. + `diag_run` is not listed until Core serves it, and `events_list`, an + earlier name for events, is not listed at all. Cached calls to either get + an explicit unsupported result and never reach Core. Full technical reads remain separate through `api_catalog` / `api_read` on the technical profile. `status` keeps the box's `live` body as it is, keyed by field id, and adds `readings`: each field by its registry diff --git a/docs/public-plugin.md b/docs/public-plugin.md index 54e4389..3e8586f 100644 --- a/docs/public-plugin.md +++ b/docs/public-plugin.md @@ -69,6 +69,7 @@ changing that table and this one. | `setup_connect` | false | true | true | Starts a vendor sign-in (myUplink today) on the vendor's site. When it finishes, the vendor's token replaces the driver's token in the draft. Open world: a third-party service. | | `setup_commit` | false | true | false | Replaces live settings with the draft the user confirmed. | | `setup_restart` | false | true | false | Restarts Core to apply saved settings, which briefly stops control and readings. | +| `events_query` | true | false | false | Base `/mcp` only until it is reviewed for this profile. Reads the box's control journal; nothing on the box changes. | | `api_catalog`, `api_read` | true | false | false | Base `/mcp` only. GET reads of the box's own diagnostic API, from the paths the box lists. | ## Limits @@ -77,8 +78,8 @@ changing that table and this one. profile excludes them because the directory's [plugin guidelines](https://developers.openai.com/plugins/plugin-guidelines) require each model-callable operation to appear as a separate reviewed tool. -The profile also excludes the `events_list` and `diag_run` operations that -Core does not yet serve. +The profile also excludes `diag_run`, which Core does not yet serve, and +`events_query`, which waits for its own review before this profile lists it. `history_query` returns downsampled power readings. It does not establish exact energy totals or savings. Skills and listing text must state that diff --git a/internal/mcp/e2e_test.go b/internal/mcp/e2e_test.go index 483be17..2ba6a1b 100644 --- a/internal/mcp/e2e_test.go +++ b/internal/mcp/e2e_test.go @@ -164,6 +164,10 @@ type stubBox struct { apiStarted chan struct{} apiRelease chan struct{} requests []protocol.AgentRequest + // eventsReply answers events.get in place of the stub's journal, with + // a message type and its body; eventsAsked is each events.get asked. + eventsReply func(protocol.EventsGet) (string, any) + eventsAsked []protocol.EventsGet // noNoise is a box from before the agent contract: its roster names no // Noise key. noNoise bool @@ -649,8 +653,26 @@ func (b *stubBox) handle(s *boxSession, env appwire.Envelope) { case protocol.MsgEventsGet: var q protocol.EventsGet _ = protocol.Unmarshal(env.B, &q) - ev, _ := protocol.Marshal(map[string]any{"kind": "box.updated", "ts": now.UnixMilli()}) - b.reply(s, protocol.MsgEvents, env.ID, protocol.Events{Events: []cbor.RawMessage{ev}, More: q.BeforeMs == 0}) + b.mu.Lock() + b.eventsAsked = append(b.eventsAsked, q) + fn := b.eventsReply + b.mu.Unlock() + if fn != nil { + t, body := fn(q) + b.reply(s, t, env.ID, body) + return + } + // The control journal: a battery that did not follow for a while + // this morning, a dispatch verdict that still holds, and the notice + // the box sent about the battery. Text is a device's to write. + start := now.Add(-3 * time.Hour).UnixMilli() + b.reply(s, protocol.MsgEvents, env.ID, protocol.Events{Events: []cbor.RawMessage{}, Spans: []protocol.EventSpan{ + {Kind: "dispatch", Status: "ok", Severity: "info", Mode: "self_consumption", StartMs: start + 1_057_000, Samples: 640}, + {Kind: "battery", Src: "s1", Status: "not_following", Reason: "readback_mismatch\u202e", Severity: "warning", Mode: "self_consumption", + StartMs: start, EndMs: start + 1_057_000, Samples: 212, + RequestedW: &protocol.PowerRange{Min: -3000, Max: -2000, Mean: -2500.4}, ActualW: &protocol.PowerRange{Min: -400, Max: 0, Mean: -120.6}, + Last: map[string]float64{"sent_w": -3000, "readback_w": -2000, "battery_soc": 41.256, "command_at_ms": float64(start + 1_000_000)}}, + }, Notices: []protocol.EventNotice{{Ts: start + 600_000, Type: "control.not_following", Src: "s1", Status: "sent", Priority: 4}}}) case protocol.MsgLogsGet: var q protocol.LogsGet _ = protocol.Unmarshal(env.B, &q) diff --git a/internal/mcp/events.go b/internal/mcp/events.go new file mode 100644 index 0000000..9be6ac7 --- /dev/null +++ b/internal/mcp/events.go @@ -0,0 +1,310 @@ +package mcp + +import ( + "context" + "encoding/json" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +// The box keeps a control journal: each stretch in which a device function, +// a source, the site's dispatch or the mode held one verdict, each scored +// plan slot, and the notices it sent (srcfl/ftw docs/agents.md, events.get). +// events_query asks it about a past window and writes the answer for a +// model: times in the home's own zone, durations, asked beside measured +// power, and how to read on when the box cut the answer. + +const eventsDescription = "What the box recorded about control in a past window, from its control journal: " + + "each stretch in which a battery, charger, solar, other device, a source's freshness, the site's dispatch or the mode held one verdict, " + + "each scored plan slot, and the notices the box sent. " + + "Use it for questions such as why the app said FTW was not in control this morning, what happened at 06:14, " + + "or whether the battery did what FTW asked. " + + "A span is listed when it overlaps the window, so a short window around a moment shows what held then. " + + "Each span gives its start and end in the home's time zone (ongoing while it still holds), how long it lasted, kind, source id, " + + "Core's status, reason and severity as in the app's control feedback, the power FTW asked for (askedW) beside the power measured (measuredW) as mean, min and max, " + + "and the latest verdict's numbers by name (sent_w, readback_w, offered_a, battery_soc, planned_wh, actual_wh, ratio); a number ending in _ms is shown as a time. " + + "Spans and notices come newest first. since and until take RFC 3339, Unix milliseconds, or a local time without an offset, which is read in the home's time zone; " + + "the default is the last 24 hours. When more is true, next says how to read older spans. " + + "history_query gives power curves over time; plan_get and plan_explain look forward. " + signNote + dataNote + +func eventsTool() *tool { + return &tool{ + name: "events_query", title: "What happened", + description: eventsDescription, + scope: readScope(protocol.MsgEventsGet), readOnly: true, + input: object(map[string]any{ + "since": instantArg("Start of the window; 24 hours before until when left out"), + "until": instantArg("End of the window; now when left out"), + "source": map[string]any{"type": "string", "pattern": srcPattern.String(), "description": "Only this source, by an id from drivers_status or an earlier answer"}, + "min_severity": enum("Only spans at this severity or worse", "warning", "alarm"), + "limit": integer("How many spans at most, newest first", 1, protocol.EventsMax, protocol.EventsMax), + }), + run: func(ctx context.Context, cc *callCtx, args json.RawMessage) (any, error) { + a := struct { + Since json.RawMessage `json:"since"` + Until json.RawMessage `json:"until"` + Source string `json:"source"` + MinSeverity string `json:"min_severity"` + Limit *int `json:"limit"` + }{} + if err := decodeArgs(args, &a); err != nil { + return nil, err + } + q := protocol.EventsGet{N: protocol.EventsMax, Src: a.Source, MinSeverity: a.MinSeverity} + if a.Limit != nil { + if *a.Limit < 1 || *a.Limit > protocol.EventsMax { + return nil, userError("limit must be between 1 and " + strconv.Itoa(protocol.EventsMax) + ".") + } + q.N = uint64(*a.Limit) + } + if a.MinSeverity != "" && !oneOf(a.MinSeverity, "warning", "alarm") { + return nil, userError("min_severity must be warning or alarm.") + } + if a.Source != "" && !srcPattern.MatchString(a.Source) { + return nil, userError("source must be a source id from drivers_status or an earlier answer.") + } + c, err := cc.conn(ctx) + if err != nil { + return nil, cc.away(err) + } + ctx, cancel := context.WithTimeout(ctx, cc.s.pool.Call) + defer cancel() + zone := c.zone(ctx) + now := cc.s.now() + since, until, err := eventsWindow(a.Since, a.Until, zone, now) + if err != nil { + return nil, err + } + q.AfterMs, q.BeforeMs = since.UnixMilli(), until.UnixMilli() + env, err := c.ask(ctx, protocol.MsgEventsGet, q) + if err != nil { + return nil, cc.away(words(err)) + } + var ev protocol.Events + if protocol.Unmarshal(env.B, &ev) != nil { + return nil, userError("The box's answer was not readable.") + } + return eventsData(ev, since, until, zone, now, int(q.N)), nil + }, + } +} + +// srcPattern is an agent's opaque source id: base64url, as the box cuts it. +var srcPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +func instantArg(desc string) map[string]any { + return map[string]any{"type": "string", "description": desc + + ": RFC 3339 such as 2026-11-08T06:14:00+01:00, Unix milliseconds, or a local time such as 2026-11-08T06:14 in the home's time zone"} +} + +// zone is the home's time zone as the box named it in hello_ok, or UTC +// when it named none this service can load. +func (c *agentConn) zone(ctx context.Context) *time.Location { + h, _, err := c.helloOK(ctx) + if err != nil || h == nil || h.Box.TZ == "" || h.Box.TZ == "Local" { + return time.UTC + } + loc, err := time.LoadLocation(h.Box.TZ) + if err != nil { + return time.UTC + } + return loc +} + +// eventsWindow reads since and until, the last 24 hours before now when +// both are left out. +func eventsWindow(rawSince, rawUntil json.RawMessage, zone *time.Location, now time.Time) (since, until time.Time, err error) { + until, okUntil, err := instant(rawUntil, zone) + if err != nil { + return since, until, err + } + if !okUntil { + until = now + } + since, okSince, err := instant(rawSince, zone) + if err != nil { + return since, until, err + } + if !okSince { + since = until.Add(-24 * time.Hour) + } + if !since.Before(until) { + return since, until, userError("since must be before until.") + } + return since, until, nil +} + +// timeLayouts are the times instant reads. One with an offset keeps it; +// one without is read in the home's zone. +var timeLayouts = []string{time.RFC3339, "2006-01-02T15:04Z07:00", + "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02 15:04"} + +// instant reads one time a model gives: RFC 3339, Unix milliseconds as a +// number or as digits, or a local time without an offset, read in zone. +func instant(raw json.RawMessage, zone *time.Location) (time.Time, bool, error) { + bad := userError("since and until must be times like 2026-11-08T06:14:00+01:00, 2026-11-08T06:14 in the home's time zone, or Unix milliseconds.") + if len(raw) == 0 || string(raw) == "null" { + return time.Time{}, false, nil + } + var s string + if json.Unmarshal(raw, &s) != nil { + s = string(raw) + } + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false, nil + } + if ms, err := strconv.ParseInt(s, 10, 64); err == nil { + if ms < 1e12 { + return time.Time{}, false, userError("Unix times are in milliseconds, such as 1762578840000.") + } + return time.UnixMilli(ms), true, nil + } + for _, layout := range timeLayouts { + if t, err := time.ParseInLocation(layout, s, zone); err == nil { + return t, true, nil + } + } + return time.Time{}, false, bad +} + +// eventsData writes the box's journal answer for a model. n is the limit +// the box was asked for, which tells a list it cut from one it did not. +func eventsData(ev protocol.Events, since, until time.Time, zone *time.Location, now time.Time, n int) map[string]any { + at := func(ms int64) string { return time.UnixMilli(ms).In(zone).Format(time.RFC3339) } + spans := make([]map[string]any, 0, len(ev.Spans)) + for _, sp := range ev.Spans { + m := map[string]any{"from": at(sp.StartMs), "kind": clean(sp.Kind), "status": clean(sp.Status), "samples": sp.Samples} + end := sp.EndMs + if end == 0 { + m["ongoing"] = true + end = now.UnixMilli() + } else { + m["to"] = at(end) + } + m["duration"] = (time.Duration(max(end-sp.StartMs, 0)) * time.Millisecond).Round(time.Second).String() + for k, v := range map[string]string{"source": sp.Src, "reason": sp.Reason, "severity": sp.Severity, "mode": sp.Mode} { + if v != "" { + m[k] = clean(v) + } + } + if w := watts(sp.RequestedW); w != nil { + m["askedW"] = w + } + if w := watts(sp.ActualW); w != nil { + m["measuredW"] = w + } + if last := lastNumbers(sp.Last, at); len(last) > 0 { + m["last"] = last + } + spans = append(spans, m) + } + notices := make([]map[string]any, 0, len(ev.Notices)) + for _, nt := range ev.Notices { + m := map[string]any{"at": at(nt.Ts), "type": clean(nt.Type), "status": clean(nt.Status)} + if nt.Src != "" { + m["source"] = clean(nt.Src) + } + if nt.Priority != 0 { + m["priority"] = nt.Priority + } + notices = append(notices, m) + } + out := map[string]any{ + "window": map[string]any{"since": at(since.UnixMilli()), "until": at(until.UnixMilli()), "timeZone": zone.String()}, + "spans": spans, "notices": notices, "more": ev.More, + } + if ev.More { + out["next"] = nextPage(ev, since.UnixMilli(), n, at) + } + return out +} + +// nextPage says how to read what the box left out. Spans overlap the window +// and come newest by start; notices come newest by time; each list is cut +// at the limit, and the oldest of both give way when the answer would not +// fit one message. The next until is the oldest start or time of each list +// the box cut, the later of them, so nothing is skipped; a span that starts +// at that moment shows again. +func nextPage(ev protocol.Events, sinceMs int64, n int, at func(int64) string) map[string]any { + oldest := func(ms []int64) (int64, bool) { + if len(ms) == 0 { + return 0, false + } + o := ms[0] + for _, v := range ms[1:] { + o = min(o, v) + } + return o, true + } + var starts, times []int64 + for _, sp := range ev.Spans { + starts = append(starts, sp.StartMs) + } + for _, nt := range ev.Notices { + times = append(times, nt.Ts) + } + spanEnd, hasSpans := oldest(starts) + noticeEnd, hasNotices := oldest(times) + var until int64 + var found bool + if hasSpans && len(starts) >= n { + until, found = spanEnd, true + } + if hasNotices && len(times) >= n && (!found || noticeEnd > until) { + until, found = noticeEnd, true + } + if !found && hasSpans { + // Cut to fit one message: the spans gave way first or both did. + until, found = spanEnd, true + } + if !found && hasNotices { + until, found = noticeEnd, true + } + if !found || until <= sinceMs { + return map[string]any{"how": "The box left entries out. Ask a shorter window, or filter by source or min_severity."} + } + return map[string]any{"until": at(until), "untilMs": until, + "how": "Older entries remain. Call events_query again with this until and the same since, source and min_severity. A span that starts at that moment shows again."} +} + +// watts is a span's power as whole watts: mean, min and max. +func watts(p *protocol.PowerRange) map[string]any { + if p == nil || !finite(p.Mean) || !finite(p.Min) || !finite(p.Max) { + return nil + } + return map[string]any{"mean": round(p.Mean, 1), "min": round(p.Min, 1), "max": round(p.Max, 1)} +} + +// lastNumbers keeps the latest verdict's numbers, to two decimals. A field +// ending in _ms that holds a time is written as a time, without the suffix. +func lastNumbers(last map[string]float64, at func(int64) string) map[string]any { + out := map[string]any{} + for k, v := range last { + if !finite(v) { + continue + } + k = clean(k) + if base, ok := strings.CutSuffix(k, "_ms"); ok && base != "" && v >= 1e12 { + out[base] = at(int64(v)) + continue + } + out[k] = round(v, 100) + } + return out +} + +func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) } + +// round rounds to 1/scale, and never to -0, which JSON would write as such. +func round(v, scale float64) float64 { + if r := math.Round(v*scale) / scale; r != 0 { + return r + } + return 0 +} diff --git a/internal/mcp/events_test.go b/internal/mcp/events_test.go new file mode 100644 index 0000000..779a079 --- /dev/null +++ b/internal/mcp/events_test.go @@ -0,0 +1,249 @@ +package mcp + +import ( + "encoding/json" + "math" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" +) + +// eventsCall runs events_query and returns its data and the events.get the +// box was asked. +func eventsCall(t *testing.T, a *assistant, b *stubBox, args map[string]any) (map[string]any, protocol.EventsGet) { + t.Helper() + res, isErr, text := a.call("events_query", args) + if isErr { + t.Fatalf("events_query: %s", text) + } + if strings.ContainsRune(text, '‮') { + t.Fatalf("events_query kept a character that hides text: %s", text) + } + b.mu.Lock() + defer b.mu.Unlock() + if len(b.eventsAsked) == 0 { + t.Fatal("the box was not asked events.get") + } + return res["structuredContent"].(map[string]any)["data"].(map[string]any), b.eventsAsked[len(b.eventsAsked)-1] +} + +// Asked nothing, events_query reads the last 24 hours of the journal and +// writes each span in the home's own time zone, with its length, the power +// FTW asked for beside the power measured, and the latest verdict's +// numbers, a time among them written as a time. +func TestEventsQueryAnswersWhatHappened(t *testing.T) { + w := newWorld(t) + b := w.connectBox() + a := w.connect(b, "") + before := time.Now() + data, q := eventsCall(t, a, b, nil) + + if q.N != protocol.EventsMax || q.Src != "" || q.MinSeverity != "" || + q.BeforeMs < before.UnixMilli() || q.BeforeMs > time.Now().UnixMilli() || q.BeforeMs-q.AfterMs != 24*3_600_000 { + t.Fatalf("events.get %+v", q) + } + stockholm, err := time.LoadLocation("Europe/Stockholm") + if err != nil { + t.Fatal(err) + } + window := data["window"].(map[string]any) + if window["timeZone"] != "Europe/Stockholm" || window["until"] != time.UnixMilli(q.BeforeMs).In(stockholm).Format(time.RFC3339) { + t.Fatalf("window %v", window) + } + + spans := data["spans"].([]any) + if len(spans) != 2 { + t.Fatalf("spans %v", spans) + } + held := spans[0].(map[string]any) + if held["kind"] != "dispatch" || held["ongoing"] != true || held["to"] != nil || held["source"] != nil { + t.Fatalf("an ongoing span %v", held) + } + sp := spans[1].(map[string]any) + from, err := time.Parse(time.RFC3339, sp["from"].(string)) + if err != nil || sp["from"] != from.In(stockholm).Format(time.RFC3339) { + t.Fatalf("from %v is not in the home's zone", sp["from"]) + } + to, _ := time.Parse(time.RFC3339, sp["to"].(string)) + if to.Sub(from) != 1057*time.Second || sp["duration"] != "17m37s" { + t.Fatalf("from %v to %v, %v", sp["from"], sp["to"], sp["duration"]) + } + if sp["kind"] != "battery" || sp["source"] != "s1" || sp["status"] != "not_following" || + sp["reason"] != "readback_mismatch" || sp["severity"] != "warning" || sp["samples"] != float64(212) { + t.Fatalf("battery span %v", sp) + } + asked, measured := sp["askedW"].(map[string]any), sp["measuredW"].(map[string]any) + if asked["mean"] != float64(-2500) || asked["min"] != float64(-3000) || asked["max"] != float64(-2000) || + measured["mean"] != float64(-121) || measured["max"] != float64(0) { + t.Fatalf("asked %v, measured %v", asked, measured) + } + last := sp["last"].(map[string]any) + if last["sent_w"] != float64(-3000) || last["readback_w"] != float64(-2000) || last["battery_soc"] != 41.26 || last["command_at_ms"] != nil { + t.Fatalf("last %v", last) + } + if at, err := time.Parse(time.RFC3339, last["command_at"].(string)); err != nil || at.Sub(from) != 1000*time.Second { + t.Fatalf("command_at %v", last["command_at"]) + } + + notices := data["notices"].([]any) + if len(notices) != 1 { + t.Fatalf("notices %v", notices) + } + n := notices[0].(map[string]any) + if n["type"] != "control.not_following" || n["source"] != "s1" || n["status"] != "sent" || n["priority"] != float64(4) { + t.Fatalf("notice %v", n) + } + if data["more"] != false || data["next"] != nil { + t.Fatalf("more %v, next %v", data["more"], data["next"]) + } +} + +// since and until take a local time, read in the home's zone, or Unix +// milliseconds; source, min_severity and limit reach the box as asked. +// When the box cut the answer, next names the until that reads on. +func TestEventsQueryTakesAWindowSourceAndSeverity(t *testing.T) { + w := newWorld(t) + b := w.connectBox() + a := w.connect(b, "") + stockholm, _ := time.LoadLocation("Europe/Stockholm") + since := time.Date(2026, 11, 8, 6, 0, 0, 0, stockholm) + until := time.Date(2026, 11, 8, 7, 0, 0, 0, time.UTC) + oldest := time.Date(2026, 11, 8, 6, 14, 0, 0, stockholm).UnixMilli() + b.mu.Lock() + b.eventsReply = func(protocol.EventsGet) (string, any) { + return protocol.MsgEvents, protocol.Events{More: true, Spans: []protocol.EventSpan{ + {Kind: "ev", Src: "s1", Status: "no_power", Severity: "alarm", StartMs: oldest + 600_000, EndMs: oldest + 900_000}, + {Kind: "ev", Src: "s1", Status: "no_power", Severity: "alarm", StartMs: oldest, EndMs: oldest + 300_000}, + }} + } + b.mu.Unlock() + data, q := eventsCall(t, a, b, map[string]any{"since": "2026-11-08T06:00", "until": until.UnixMilli(), + "source": "s1", "min_severity": "alarm", "limit": 2}) + if q.AfterMs != since.UnixMilli() || q.BeforeMs != until.UnixMilli() || q.Src != "s1" || q.MinSeverity != "alarm" || q.N != 2 { + t.Fatalf("events.get %+v", q) + } + if got := data["window"].(map[string]any)["since"]; got != "2026-11-08T06:00:00+01:00" { + t.Fatalf("since %v", got) + } + next := data["next"].(map[string]any) + if data["more"] != true || next["untilMs"] != float64(oldest) || next["until"] != "2026-11-08T06:14:00+01:00" || + !strings.Contains(next["how"].(string), "same since") { + t.Fatalf("more %v, next %v", data["more"], next) + } +} + +// A wrong argument is refused here, in words, and never reaches the box. +func TestEventsQueryRefusesWrongArguments(t *testing.T) { + w := newWorld(t) + b := w.connectBox() + a := w.connect(b, "") + for _, args := range []map[string]any{ + {"since": "yesterday"}, + {"since": "2026-11-08T08:00:00Z", "until": "2026-11-08T07:00:00Z"}, + {"until": 1762585200}, + {"min_severity": "info"}, + {"limit": 0}, + {"limit": protocol.EventsMax + 1}, + {"source": "../s1"}, + {"at": "2026-11-08T06:14:00Z"}, + } { + if _, isErr, text := a.call("events_query", args); !isErr { + t.Fatalf("%v was sent on: %s", args, text) + } + } + b.mu.Lock() + defer b.mu.Unlock() + if len(b.eventsAsked) != 0 { + t.Fatalf("the box was asked %+v", b.eventsAsked) + } +} + +// A box from before the control journal answers events.get E_UNKNOWN_OP +// with the message type alone; the model hears that the box needs a newer +// FTW. A box with the journal names the field it refused. +func TestEventsQueryOnABoxWithoutTheJournal(t *testing.T) { + w := newWorld(t) + b := w.connectBox() + a := w.connect(b, "") + for _, c := range []struct { + args map[string]any + want string + }{ + {map[string]any{"t": protocol.MsgEventsGet}, "this box does not keep a control journal yet; update FTW"}, + {map[string]any{"t": protocol.MsgEventsGet, "field": "src"}, "does not know that source id"}, + } { + b.mu.Lock() + b.eventsReply = func(protocol.EventsGet) (string, any) { + return protocol.MsgError, protocol.ErrorBody{Code: protocol.ErrUnknownOp, Args: c.args} + } + b.mu.Unlock() + _, isErr, text := a.call("events_query", nil) + if !isErr || !strings.Contains(strings.ToLower(text), strings.ToLower(c.want)) || !strings.Contains(text, "E_UNKNOWN_OP") { + t.Fatalf("%v: %v %s", c.args, isErr, text) + } + } +} + +// next reads on from the list the box cut, the later of the two when both +// were, so neither spans nor notices are skipped; it never names an until +// at or before since. +func TestEventsNextPageReadsOnFromTheCutList(t *testing.T) { + const base = int64(1_800_000_000_000) + at := func(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) } + spans := func(starts ...int64) (out []protocol.EventSpan) { + for _, s := range starts { + out = append(out, protocol.EventSpan{StartMs: s}) + } + return out + } + notices := func(ts ...int64) (out []protocol.EventNotice) { + for _, s := range ts { + out = append(out, protocol.EventNotice{Ts: s}) + } + return out + } + for _, c := range []struct { + name string + ev protocol.Events + want any + }{ + {"spans cut", protocol.Events{Spans: spans(base+50, base+10), Notices: notices(base + 40)}, float64(base + 10)}, + {"notices cut", protocol.Events{Spans: spans(base + 5), Notices: notices(base+40, base+30)}, float64(base + 30)}, + {"both cut", protocol.Events{Spans: spans(base+50, base+10), Notices: notices(base+40, base+30)}, float64(base + 30)}, + {"cut to fit", protocol.Events{Spans: spans(base + 20), Notices: notices(base + 40)}, float64(base + 20)}, + {"only before since", protocol.Events{Spans: spans(base-500, base-900)}, nil}, + } { + next := nextPage(c.ev, base, 2, at) + b, _ := json.Marshal(next) + var got map[string]any + _ = json.Unmarshal(b, &got) + if got["untilMs"] != c.want || got["how"] == nil { + t.Errorf("%s: %v", c.name, got) + } + } +} + +// Without a zone from the box, times are UTC; a number that is not finite +// is left out, so the answer still encodes, and -0 is written as 0. +func TestEventsDataInUTCWithoutBadNumbers(t *testing.T) { + start := int64(1_800_000_000_000) + ev := protocol.Events{Spans: []protocol.EventSpan{{Kind: "battery", Status: "ok", StartMs: start, EndMs: start + 90_000, + RequestedW: &protocol.PowerRange{Min: math.NaN(), Max: 1, Mean: 1}, ActualW: &protocol.PowerRange{Min: -0.4, Max: 0.2, Mean: -0.1}, + Last: map[string]float64{"ratio": 0.8734, "actual_wh": math.Inf(1)}}}} + data := eventsData(ev, time.UnixMilli(start-3_600_000), time.UnixMilli(start+3_600_000), time.UTC, time.UnixMilli(start), protocol.EventsMax) + b, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + text := string(b) + for _, want := range []string{`"from":"2027-01-15T08:00:00Z"`, `"timeZone":"UTC"`, `"duration":"1m30s"`, `"measuredW":{"max":0,"mean":0,"min":0}`, `"last":{"ratio":0.87}`} { + if !strings.Contains(text, want) { + t.Errorf("lacks %s: %s", want, text) + } + } + if strings.Contains(text, "askedW") || strings.Contains(text, ":-0,") || strings.Contains(text, ":-0}") { + t.Errorf("%s", text) + } +} diff --git a/internal/mcp/hints_test.go b/internal/mcp/hints_test.go index c7d468d..4dcd78a 100644 --- a/internal/mcp/hints_test.go +++ b/internal/mcp/hints_test.go @@ -16,6 +16,7 @@ var hints = map[string][3]bool{ "plan_get": {true, false, false}, "plan_explain": {true, false, false}, "history_query": {true, false, false}, + "events_query": {true, false, false}, "health_get": {true, false, false}, "logs_tail": {true, false, false}, "drivers_status": {true, false, false}, diff --git a/internal/mcp/history_test.go b/internal/mcp/history_test.go index 73f9947..c8bffc1 100644 --- a/internal/mcp/history_test.go +++ b/internal/mcp/history_test.go @@ -99,16 +99,18 @@ func TestHistoryLeavesAStepWithoutReadingsNull(t *testing.T) { } } -// FTW lists events.get and diag.run and does not serve them yet. Its box -// says so with E_UNKNOWN_OP and the message type; the model is told that, -// and what to read instead, not to look for a newer FTW that does not exist. +// FTW lists diag.run and does not serve it yet. Its box says so with +// E_UNKNOWN_OP and the message type; the model is told that, and what to +// read instead, not to look for a newer FTW that does not exist. A box from +// before the control journal answers events.get the same way, and there +// updating FTW is the answer. func TestAnUnbuiltReadSaysSoAndWhatToUseInstead(t *testing.T) { - for _, c := range []struct{ t, want string }{ - {protocol.MsgEventsGet, "logs_tail"}, - {protocol.MsgDiagRun, "drivers_status"}, + for _, c := range []struct{ t, want, not string }{ + {protocol.MsgEventsGet, "update FTW", "has not built"}, + {protocol.MsgDiagRun, "drivers_status", "newer FTW"}, } { err := words(boxError{Code: protocol.ErrUnknownOp, Args: map[string]any{"t": c.t}}) - if !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "E_UNKNOWN_OP") || strings.Contains(err.Error(), "newer FTW") { + if !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "E_UNKNOWN_OP") || strings.Contains(err.Error(), c.not) { t.Fatalf("%s: %v", c.t, err) } } diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index b2763c1..07c01eb 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -75,7 +75,9 @@ const Instructions = "FTW Cloud reaches one home's FTW box as an assistant its o "For a box with no settings, the owner first chooses Start assistant setup on its local page, sets a house password, chooses Cloud consent and approves a Set up and configure connection there. No phone app is needed. " + "Follow the box-generated guide and commissioning fields in setup_status. Use setup_discover, setup_test and setup_connect only after the user agrees. Optional solar/nameplate and annual consumption answers are estimates, never history or verified limits; distinguish bought electricity from total demand and avoid counting EV or heating twice. " + "Setup edits need a separate owner-approved setup grant. Saving does not prove measurement or control on hardware. " + - "For ordinary status, history and control, use the existing tools. For technical troubleshooting, " + + "For ordinary status, history and control, use the existing tools. " + + "For what happened at a past time, such as why the app said FTW was not in control or whether a device did what FTW asked, use events_query. " + + "For technical troubleshooting, " + "use measurement_diagnostics for redacted quality, energy and source evidence; it needs the diagnostic grant. For full technical evidence start with api_catalog: the box lists its full read API, running version, source-code links and " + "steps for gathering bug-report evidence. api_read keeps technical identifiers, addresses, logs and source code. " + "For charger faults, check charger_connections when present, then read /api/config, /api/drivers and /api/loadpoints. " + diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index a656c8f..f0ae61b 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -102,7 +102,7 @@ func TestAnAssistantReadsItsHome(t *testing.T) { if _, isErr, _ := a.call("config_get", map[string]any{"section": "../x"}); !isErr { t.Fatal("a section that is a path was sent on") } - if _, isErr, _ := a.call("events_list", map[string]any{"before": "yesterday"}); !isErr { + if _, isErr, _ := a.call("events_query", map[string]any{"since": "yesterday"}); !isErr { t.Fatal("a cursor that is no time was sent on") } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 8806a1a..5d58510 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -312,6 +312,7 @@ func readTools() []*tool { return historyData(chunks, end, fromMs, toMs, int(points)), nil }, }, + eventsTool(), { name: "health_get", title: "Health", description: "How the box is: its version, uptime and clock, how many devices are fine, degraded, faulted or offline, the grid meter's freshness, and what blocks control if anything does." + dataNote, @@ -720,13 +721,22 @@ func words(err error) error { if a, ok := be.Args["arg"].(string); ok && a != "" { return userError(fmt.Sprintf("The box does not take that %s (E_UNKNOWN_OP).", clean(a))) } - // FTW lists events.get and diag.run in its contract and does not - // serve them yet (srcfl/ftw docs/agents.md): no box version can - // be named that has them, so the model is told what to use now. switch be.Args["t"] { case protocol.MsgEventsGet: - return userError("This box's FTW does not serve events yet (E_UNKNOWN_OP for events.get): FTW has not built that read. Use logs_tail with level WARN for recent warnings and errors, and health_get for what is wrong now.") + // A box with the control journal names the field it refused; a + // box from before it names only the message. + switch f, _ := be.Args["field"].(string); f { + case "": + return userError("This box does not keep a control journal yet; update FTW (E_UNKNOWN_OP for events.get). Until then, logs_tail with level WARN gives recent warnings and errors, and health_get what is wrong now.") + case "src": + return userError("The box does not know that source id (E_UNKNOWN_OP). Use a source id from drivers_status or an earlier events_query answer.") + default: + return userError(fmt.Sprintf("The box does not take that %s (E_UNKNOWN_OP).", clean(f))) + } case protocol.MsgDiagRun: + // FTW lists diag.run in its contract and does not serve it yet + // (srcfl/ftw docs/agents.md): no box version can be named that + // has it, so the model is told what to use now. return userError("This box's FTW does not run checks yet (E_UNKNOWN_OP for diag.run): FTW has not built that read. Use health_get and drivers_status instead.") } return userError("The box does not know this request; it may need a newer FTW version (E_UNKNOWN_OP).") @@ -740,9 +750,13 @@ func words(err error) error { if r, ok := be.Args["reason"].(string); ok && r == "busy" { return errBusy } - if sub, ok := be.Args["subsystem"].(string); ok && sub == "clock" { + sub, _ := be.Args["subsystem"].(string) + if sub == "clock" { return userError("The box's clock is not set yet, so it takes no calls from assistants (E_UNAVAILABLE).") } + if sub == "events" { + return userError("The box cannot read its control journal right now (E_UNAVAILABLE). Try again in a minute; logs_tail and health_get still answer.") + } return userError("The box cannot do that right now (E_UNAVAILABLE).") } return userError(fmt.Sprintf("The box refused this request (%s).", codeText(be.Code))) From 13974efb57d82c07b8aadbe658f8e060f4e0d00d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 8 Nov 2026 09:10:04 +0100 Subject: [PATCH 2/3] chore(protocol): pin srcfl/ftw#1982 for events.get Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 43f8826..fe3683c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261024093922-2d7c8eba41f0 + github.com/srcfl/ftw/go/protocol v0.0.0-20261108080825-fd4426a107c1 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index e20c69c..fe04cb6 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261024093922-2d7c8eba41f0 h1:iK/CxTZOsxEbkXT28eaX5JUx7J0u1abH4bscHpPmAYE= -github.com/srcfl/ftw/go/protocol v0.0.0-20261024093922-2d7c8eba41f0/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261108080825-fd4426a107c1 h1:jMTkobIdBd+6uLxjfuWrBXGLOroK7EhubT6g8giZCbg= +github.com/srcfl/ftw/go/protocol v0.0.0-20261108080825-fd4426a107c1/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= From ad8ce000a93f639d69a7c37731d294ee01835d66 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 8 Nov 2026 09:13:17 +0100 Subject: [PATCH 3/3] fix(mcp): events_query paging always gets further A page full of spans from one moment gave a next until at that same moment, so a caller following next never ended. It now steps past that moment and says what that may leave out. A time at or before 1970 is refused, since the box reads 0 as no bound. Co-Authored-By: Claude Opus 5.5 --- internal/mcp/events.go | 22 +++++++++++++++++----- internal/mcp/events_test.go | 21 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/mcp/events.go b/internal/mcp/events.go index 9be6ac7..d8be815 100644 --- a/internal/mcp/events.go +++ b/internal/mcp/events.go @@ -167,6 +167,9 @@ func instant(raw json.RawMessage, zone *time.Location) (time.Time, bool, error) } for _, layout := range timeLayouts { if t, err := time.ParseInLocation(layout, s, zone); err == nil { + if t.UnixMilli() <= 0 { + return time.Time{}, false, bad + } return t, true, nil } } @@ -220,7 +223,7 @@ func eventsData(ev protocol.Events, since, until time.Time, zone *time.Location, "spans": spans, "notices": notices, "more": ev.More, } if ev.More { - out["next"] = nextPage(ev, since.UnixMilli(), n, at) + out["next"] = nextPage(ev, since.UnixMilli(), until.UnixMilli(), n, at) } return out } @@ -230,8 +233,10 @@ func eventsData(ev protocol.Events, since, until time.Time, zone *time.Location, // at the limit, and the oldest of both give way when the answer would not // fit one message. The next until is the oldest start or time of each list // the box cut, the later of them, so nothing is skipped; a span that starts -// at that moment shows again. -func nextPage(ev protocol.Events, sinceMs int64, n int, at func(int64) string) map[string]any { +// at that moment shows again. A page full of entries from one moment cannot +// read on from it, so the next until steps past that moment and says what +// that may leave out: a caller following next always gets further. +func nextPage(ev protocol.Events, sinceMs, untilMs int64, n int, at func(int64) string) map[string]any { oldest := func(ms []int64) (int64, bool) { if len(ms) == 0 { return 0, false @@ -269,8 +274,15 @@ func nextPage(ev protocol.Events, sinceMs int64, n int, at func(int64) string) m if !found || until <= sinceMs { return map[string]any{"how": "The box left entries out. Ask a shorter window, or filter by source or min_severity."} } - return map[string]any{"until": at(until), "untilMs": until, - "how": "Older entries remain. Call events_query again with this until and the same since, source and min_severity. A span that starts at that moment shows again."} + how := "Older entries remain. Call events_query again with this until and the same since, source and min_severity. A span that starts at that moment shows again." + if until >= untilMs { + until = untilMs - 1 + if until <= sinceMs { + return map[string]any{"how": "The box left entries out. Ask a shorter window, or filter by source or min_severity."} + } + how = "Older entries remain. Call events_query again with this until and the same since, source and min_severity. More entries began at the moment just after it than one page holds; filter by source to see them all." + } + return map[string]any{"until": at(until), "untilMs": until, "how": how} } // watts is a span's power as whole watts: mean, min and max. diff --git a/internal/mcp/events_test.go b/internal/mcp/events_test.go index 779a079..92f44c1 100644 --- a/internal/mcp/events_test.go +++ b/internal/mcp/events_test.go @@ -215,7 +215,7 @@ func TestEventsNextPageReadsOnFromTheCutList(t *testing.T) { {"cut to fit", protocol.Events{Spans: spans(base + 20), Notices: notices(base + 40)}, float64(base + 20)}, {"only before since", protocol.Events{Spans: spans(base-500, base-900)}, nil}, } { - next := nextPage(c.ev, base, 2, at) + next := nextPage(c.ev, base, base+1000, 2, at) b, _ := json.Marshal(next) var got map[string]any _ = json.Unmarshal(b, &got) @@ -225,6 +225,25 @@ func TestEventsNextPageReadsOnFromTheCutList(t *testing.T) { } } +// A page full of spans from one moment cannot read on from that moment. A +// caller following next must get further every time, or it never ends. +func TestEventsNextPageAlwaysGetsFurther(t *testing.T) { + const base = int64(1_800_000_000_000) + at := func(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) } + page := protocol.Events{Spans: []protocol.EventSpan{{StartMs: base + 500}, {StartMs: base + 500}}, More: true} + first := nextPage(page, base, base+1000, 2, at) + if first["untilMs"] != base+500 { + t.Fatalf("first page: %v", first) + } + second := nextPage(page, base, base+500, 2, at) + if second["untilMs"] != base+499 || !strings.Contains(second["how"].(string), "filter by source") { + t.Fatalf("a page stuck on one moment must step past it: %v", second) + } + if last := nextPage(protocol.Events{Spans: []protocol.EventSpan{{StartMs: base + 1}}, More: true}, base, base+1, 1, at); last["untilMs"] != nil { + t.Fatalf("no room left before since: %v", last) + } +} + // Without a zone from the box, times are UTC; a number that is not finite // is left out, so the answer still encodes, and -0 is written as 0. func TestEventsDataInUTCWithoutBadNumbers(t *testing.T) {