Phase 6: fix the live Home Assistant write (Local Calendar, no event id)

The first live run against the new calendar.school entity failed loudly twice,
and both causes are fixed here.

- The Local Calendar rejects `return_response` on calendar.create_event. The
  client retries the call without it and ignores the changed-states array that
  Home Assistant returns instead.
- Home Assistant then reports no event id. The item keeps the description
  marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set
  and is never written twice. The live retry proved it: both events already in
  the calendar were adopted by their markers, with no duplicate.
- calendar.get_events reports `start` / `end` (a date-time or a bare date), not
  `start_date_time`; the reader now accepts all three shapes.

A model must no longer classify events. The calendar write depends on the
school-event rule, and the LLM labelled every event "normal"; events now keep
the deterministic rule under both classifiers.

Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan`
class, so select each banner by its labelled section.

Docs record the accepted decision: Sam chose a Home Assistant Local Calendar,
so the target is calendar.school.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 19:09:06 +11:00
parent f56dbb46ac
commit da57efad73
9 changed files with 279 additions and 68 deletions

View File

@@ -166,6 +166,11 @@ type Event struct {
// Assistant event id when the service reports one. It asks for the service
// response, so the id can be stored on the item and a re-run cannot duplicate
// the event.
//
// Not every calendar answers with a response: the Local Calendar integration
// rejects `return_response` outright. That is not a write failure, so the call
// is retried without it and the empty id is reported to the caller, which then
// falls back to the description marker for idempotency.
func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) {
if strings.TrimSpace(ev.Summary) == "" {
return "", errors.New("calendar: event summary is empty")
@@ -186,13 +191,29 @@ func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) {
body["location"] = ev.Location
}
resp, err := c.callService(ctx, "calendar", "create_event", body)
resp, err := c.callService(ctx, "calendar", "create_event", body, true)
if err != nil {
if noResponseSupport(err) {
if _, rerr := c.callService(ctx, "calendar", "create_event", body, false); rerr != nil {
return "", rerr
}
return "", nil
}
return "", err
}
return serviceUID(resp, c.entity), nil
}
// noResponseSupport reports whether Home Assistant refused the request only
// because this calendar does not answer with a service response.
func noResponseSupport(err error) bool {
var haErr *Error
if !errors.As(err, &haErr) {
return false
}
return strings.Contains(strings.ToLower(haErr.Message), "does not support responses")
}
// DeleteEvent removes an event by its Home Assistant uid. It exists so a test
// event can be cleaned up, and so a wrong write can be undone.
func (c *Client) DeleteEvent(ctx context.Context, uid string) error {
@@ -202,7 +223,7 @@ func (c *Client) DeleteEvent(ctx context.Context, uid string) error {
_, err := c.callService(ctx, "calendar", "delete_event", map[string]any{
"entity_id": c.entity,
"uid": uid,
})
}, false)
return err
}
@@ -214,7 +235,7 @@ func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, err
"entity_id": c.entity,
"start_date_time": start.Format(time.RFC3339),
"end_date_time": end.Format(time.RFC3339),
})
}, true)
if err != nil {
return nil, err
}
@@ -223,12 +244,20 @@ func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, err
// ---------- transport ----------
// callService posts to /api/services/<domain>/<service> with return_response,
// so a service that reports data (the created event id) answers with it.
func (c *Client) callService(ctx context.Context, domain, service string, body map[string]any) (map[string]any, error) {
path := fmt.Sprintf("/api/services/%s/%s?return_response", domain, service)
// callService posts to /api/services/<domain>/<service>. When withResponse is
// true it adds ?return_response, so a service that reports data (the created
// event id) answers with it. Without a response Home Assistant replies with the
// changed states, a JSON array, which the caller does not need.
func (c *Client) callService(ctx context.Context, domain, service string, body map[string]any, withResponse bool) (map[string]any, error) {
path := fmt.Sprintf("/api/services/%s/%s", domain, service)
if !withResponse {
if err := c.do(ctx, http.MethodPost, path, body, nil); err != nil {
return nil, err
}
return nil, nil
}
var out map[string]any
if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil {
if err := c.do(ctx, http.MethodPost, path+"?return_response", body, &out); err != nil {
return nil, err
}
return out, nil
@@ -372,13 +401,19 @@ func servicePayloads(resp map[string]any, entity string) []map[string]any {
return out
}
// parseHATime reads an event time from a calendar.get_events result. Home
// Assistant reports `start` / `end` as either a date-time with an offset or a
// bare date for an all-day event; the *_date_time / *_date shapes are also
// accepted for older replies.
func parseHATime(m map[string]any, prefix string) (*time.Time, string) {
if raw := str(m[prefix+"_date_time"]); raw != "" {
for _, key := range []string{prefix + "_date_time", prefix + "_date", prefix} {
raw := str(m[key])
if raw == "" {
continue
}
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return &t, raw
}
}
if raw := str(m[prefix+"_date"]); raw != "" {
if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil {
return &t, raw
}

View File

@@ -18,15 +18,16 @@ import (
type fakeHA struct {
*httptest.Server
mu sync.Mutex
entity string
events []map[string]any
createCalls int
getCalls int
failCreate bool
returnUID bool
lastCreate map[string]any
lastGet map[string]any
mu sync.Mutex
entity string
events []map[string]any
createCalls int
getCalls int
failCreate bool
returnUID bool
rejectReturnResponse bool
lastCreate map[string]any
lastGet map[string]any
}
func newFakeHA(t *testing.T, entity string) *fakeHA {
@@ -57,6 +58,14 @@ func newFakeHA(t *testing.T, entity string) *fakeHA {
if !f.authOK(w, r) {
return
}
if f.rejectReturnResponse && r.URL.Query().Has("return_response") {
w.WriteHeader(http.StatusBadRequest)
writeJSON(w, map[string]any{"error": map[string]any{
"code": "unknown_error",
"message": "Service does not support responses. Remove return_response from request.",
}})
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
@@ -76,12 +85,17 @@ func newFakeHA(t *testing.T, entity string) *fakeHA {
uid = "uid-" + str(body["summary"])
}
f.events = append(f.events, map[string]any{
"uid": uid,
"summary": body["summary"],
"description": body["description"],
"start_date_time": body["start_date_time"],
"end_date_time": body["end_date_time"],
"uid": uid,
"summary": body["summary"],
"description": body["description"],
"start": body["start_date_time"],
"end": body["end_date_time"],
})
if !r.URL.Query().Has("return_response") {
// Home Assistant replies with the changed states, a JSON array.
writeJSON(w, []map[string]any{})
return
}
writeJSON(w, map[string]any{
"changed_entities": map[string]any{},
"service_response": map[string]any{f.entity: map[string]any{"uid": uid}},
@@ -309,3 +323,52 @@ func TestFindMarker(t *testing.T) {
}
}
}
// TestCreateEventFallsBackWhenTheCalendarHasNoResponses proves the Local
// Calendar integration is usable: it rejects `return_response`, which is not a
// write failure.
func TestCreateEventFallsBackWhenTheCalendarHasNoResponses(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
ha.rejectReturnResponse = true
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
uid, err := c.CreateEvent(context.Background(), Event{
Summary: "Concert", Start: &start, End: ptrTime(start.Add(time.Hour)),
Description: "source: " + Marker(29),
})
if err != nil {
t.Fatalf("CreateEvent: %v", err)
}
if uid != "" {
t.Errorf("want no uid, got %q", uid)
}
if ha.created() != 1 {
t.Fatalf("event was not written (%d create calls)", ha.created())
}
}
// TestParseHATime covers the shapes calendar.get_events reports.
func TestParseHATime(t *testing.T) {
timed := map[string]any{"start": "2026-10-15T15:05:00+11:00"}
got, raw := parseHATime(timed, "start")
if got == nil || raw != "2026-10-15T15:05:00+11:00" {
t.Fatalf("timed = %v %q", got, raw)
}
if got.Hour() != 15 || got.Minute() != 5 {
t.Errorf("timed = %v", got)
}
allDay := map[string]any{"start": "2026-10-15"}
got, raw = parseHATime(allDay, "start")
if got == nil || raw != "2026-10-15" {
t.Fatalf("all-day = %v %q", got, raw)
}
if y, m, d := got.Date(); y != 2026 || m != time.October || d != 15 {
t.Errorf("all-day = %v", got)
}
if got, raw := parseHATime(map[string]any{}, "start"); got != nil || raw != "" {
t.Errorf("missing = %v %q", got, raw)
}
}

View File

@@ -192,17 +192,25 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
continue
}
if uid == "" {
// Home Assistant did not report an event id. The marker in the
// description still makes the next run idempotent, but say so.
log.Warn("home assistant returned no event id; relying on the description marker",
// Home Assistant did not report an event id (the Local Calendar does
// not answer with one). Use the description marker as the item's
// reference, so the item leaves the candidate set and is never
// written twice.
log.Warn("home assistant returned no event id; using the description marker as the item reference",
"item", first.ID, "child", children, "title", first.Title, "entity", c.Entity())
}
for _, it := range g {
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
ref := uid
if ref == "" {
ref = Marker(it.ID)
}
if err := st.MarkItemCalendarSynced(ctx, it.ID, ref, now); err != nil {
log.Error("mark calendar item", "item", it.ID, "err", err)
}
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), ref, true, ""); err != nil {
log.Error("record calendar push", "item", it.ID, "err", err)
}
}
recordPush(ctx, st, c.Entity(), g, uid, true, "", log)
res.Created++
created.UID = uid
res.Events = append(res.Events, created)

View File

@@ -106,10 +106,10 @@ func TestSyncAdoptsAnEventByItsMarker(t *testing.T) {
// The event exists in Home Assistant, but the item has no id (a crashed
// run, or a Home Assistant that reports no uid). The marker adopts it.
ha.events = []map[string]any{{
"uid": "uid-existing",
"summary": "Harry — Year 11 Assembly - HALL - CMA",
"description": "source: " + Marker(item.ID),
"start_date_time": due.Format(time.RFC3339),
"uid": "uid-existing",
"summary": "Harry — Year 11 Assembly - HALL - CMA",
"description": "source: " + Marker(item.ID),
"start": due.Format(time.RFC3339),
}}
c := testClient(t, ha.URL, "calendar.school")
@@ -290,3 +290,43 @@ func TestSyncWritesASchoolWideEventOnce(t *testing.T) {
}
}
}
// TestSyncWithoutEventIDsStillNeverDuplicates proves the fallback path: when
// Home Assistant reports no event id, the item keeps the description marker as
// its reference, so a re-run writes nothing.
func TestSyncWithoutEventIDsStillNeverDuplicates(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
ha.returnUID = false
c := testClient(t, ha.URL, "calendar.school")
first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if first.Created != 1 || ha.created() != 1 {
t.Fatalf("first result = %+v (create calls %d)", first, ha.created())
}
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil || ci == nil {
t.Fatalf("item: %v %v", ci, err)
}
if ci.HAEventUID != Marker(item.ID) {
t.Errorf("reference = %q, want the description marker %q", ci.HAEventUID, Marker(item.ID))
}
second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Candidates != 0 || second.Created != 0 {
t.Fatalf("second result = %+v", second)
}
if ha.created() != 1 {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
}
}