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/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= 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..d8be815 --- /dev/null +++ b/internal/mcp/events.go @@ -0,0 +1,322 @@ +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 { + if t.UnixMilli() <= 0 { + return time.Time{}, false, bad + } + 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(), until.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. 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 + } + 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."} + } + 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. +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..92f44c1 --- /dev/null +++ b/internal/mcp/events_test.go @@ -0,0 +1,268 @@ +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, base+1000, 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) + } + } +} + +// 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) { + 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)))