Files
compass_teams_nhs/internal/calendar/ha.go
Sam Rolfe f6be17fe4f Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that
writes important school events to Sam's Home Assistant calendar (FR-8). The
school stays read-only; the only write is to the calendar (rule 7).

- internal/calendar: HA REST client (states, create_event, get_events,
  delete_event) and the sync that selects important future events, adopts an
  existing event by its description marker, records the HA event id on the
  item, and records every attempt.
- internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and
  the append-only calendar_pushes table), candidates, mark-synced and the
  push log.
- internal/parse: a school event is important; a routine class period is not.
- cmd/school: `school calendar entities|status|push|events`.
- web: an "Add to calendar" item action and a fail-loud calendar banner, so a
  failed write is never swallowed (FR-12).
- scripts/calendar-push.sh, Makefile target, env example, docs and tests.

The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no
non-Google writable calendar today, so the entity is asked on AUD-110.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 18:01:30 +11:00

394 lines
12 KiB
Go

// Package calendar writes important school events to Sam's Home Assistant
// calendar (AGENTS.md §4 FR-8, §8, §11 Phase 6). It talks to the Home Assistant
// REST API only; the school itself stays read-only. The token lives in the
// environment and is never logged.
package calendar
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
)
// ErrorID returns the Home Assistant error id from a failed call, plus the
// HTTP status. It exists so a caller can tell an expired token from a missing
// entity without parsing a message.
type Error struct {
// Status is the HTTP status code, or 0 for a transport error.
Status int
// ID is the Home Assistant error id such as "unauthorized" or "not_found".
ID string
// Message is the human-readable detail.
Message string
}
func (e *Error) Error() string {
switch {
case e.ID != "" && e.Message != "":
return fmt.Sprintf("home assistant %s: %s", e.ID, e.Message)
case e.ID != "":
return "home assistant: " + e.ID
case e.Message != "":
return "home assistant: " + e.Message
default:
return "home assistant call failed"
}
}
// Client is a Home Assistant REST client for one calendar entity.
type Client struct {
baseURL string
token string
entity string
http *http.Client
log *slog.Logger
}
// New builds a client from configuration. It fails loudly when the target is
// incomplete, so the caller never silently writes nowhere.
func New(cfg config.CalendarConfig, log *slog.Logger) (*Client, error) {
if log == nil {
log = slog.Default()
}
if missing := cfg.Missing(); len(missing) > 0 {
return nil, fmt.Errorf("home assistant calendar is not configured: set %s", strings.Join(missing, ", "))
}
if _, err := url.Parse(cfg.BaseURL); err != nil {
return nil, fmt.Errorf("SCHOOL_HA_URL %q: %w", cfg.BaseURL, err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
transport := http.DefaultTransport.(*http.Transport).Clone()
if cfg.Insecure {
// Internal CA only (the .lab Caddy cert). Never enabled by default.
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
token: cfg.Token,
entity: cfg.EntityID,
http: &http.Client{Timeout: timeout, Transport: transport},
log: log,
}, nil
}
// Entity is the configured calendar entity id.
func (c *Client) Entity() string { return c.entity }
// CalendarEntity is one calendar.* entity reported by Home Assistant.
type CalendarEntity struct {
EntityID string
Name string
State string
// Features is the raw supported_features bit field.
Features int
}
// CanWrite reports whether the entity advertises the create-event feature
// (Home Assistant CalendarEntityFeature.CREATE_EVENT = 1).
func (e CalendarEntity) CanWrite() bool { return e.Features&1 != 0 }
// Healthy verifies the base URL and the token with GET /api/.
func (c *Client) Healthy(ctx context.Context) error {
var out struct {
Message string `json:"message"`
}
if err := c.do(ctx, http.MethodGet, "/api/", nil, &out); err != nil {
return fmt.Errorf("home assistant ping: %w", err)
}
if !strings.Contains(strings.ToLower(out.Message), "running") {
return fmt.Errorf("home assistant ping: unexpected reply %q", out.Message)
}
return nil
}
// Calendars lists every calendar entity, so the operator can pick one. It is
// the discovery step behind `school calendar entities`.
func (c *Client) Calendars(ctx context.Context) ([]CalendarEntity, error) {
var states []struct {
EntityID string `json:"entity_id"`
State string `json:"state"`
Attributes struct {
FriendlyName string `json:"friendly_name"`
SupportedFeatures int `json:"supported_features"`
} `json:"attributes"`
}
if err := c.do(ctx, http.MethodGet, "/api/states", nil, &states); err != nil {
return nil, fmt.Errorf("home assistant states: %w", err)
}
var out []CalendarEntity
for _, s := range states {
if !strings.HasPrefix(s.EntityID, "calendar.") {
continue
}
name := s.Attributes.FriendlyName
if name == "" {
name = s.EntityID
}
out = append(out, CalendarEntity{
EntityID: s.EntityID,
Name: name,
State: s.State,
Features: s.Attributes.SupportedFeatures,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].EntityID < out[j].EntityID })
return out, nil
}
// Event is one calendar event. UID is Home Assistant's id, when it reports one.
type Event struct {
UID string `json:"uid,omitempty"`
Summary string `json:"summary"`
Description string `json:"description,omitempty"`
Location string `json:"location,omitempty"`
Start *time.Time `json:"-"`
End *time.Time `json:"-"`
StartRaw string `json:"-"`
EndRaw string `json:"-"`
}
// CreateEvent writes one event with calendar.create_event and returns the Home
// 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.
func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) {
if strings.TrimSpace(ev.Summary) == "" {
return "", errors.New("calendar: event summary is empty")
}
if ev.Start == nil || ev.End == nil {
return "", errors.New("calendar: event start and end are required")
}
body := map[string]any{
"entity_id": c.entity,
"summary": ev.Summary,
"start_date_time": ev.Start.Format(time.RFC3339),
"end_date_time": ev.End.Format(time.RFC3339),
}
if ev.Description != "" {
body["description"] = ev.Description
}
if ev.Location != "" {
body["location"] = ev.Location
}
resp, err := c.callService(ctx, "calendar", "create_event", body)
if err != nil {
return "", err
}
return serviceUID(resp, c.entity), nil
}
// 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 {
if strings.TrimSpace(uid) == "" {
return errors.New("calendar: delete needs a uid")
}
_, err := c.callService(ctx, "calendar", "delete_event", map[string]any{
"entity_id": c.entity,
"uid": uid,
})
return err
}
// Events lists the events on the calendar between start and end (exclusive).
// The sync uses it to see whether a marker is already present, so a re-run is
// idempotent even when Home Assistant reports no uid.
func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, error) {
resp, err := c.callService(ctx, "calendar", "get_events", map[string]any{
"entity_id": c.entity,
"start_date_time": start.Format(time.RFC3339),
"end_date_time": end.Format(time.RFC3339),
})
if err != nil {
return nil, err
}
return serviceEvents(resp, c.entity), nil
}
// ---------- 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)
var out map[string]any
if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil {
return nil, err
}
return out, nil
}
// do performs one authenticated request. A non-2xx answer becomes an *Error
// carrying the Home Assistant error id, so the caller can act (and report) with
// the real reason instead of a bare status code.
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return &Error{Message: fmt.Sprintf("request to %s failed: %v", path, err)}
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return &Error{Status: resp.StatusCode, Message: "reading the reply failed: " + err.Error()}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return haError(resp.StatusCode, raw)
}
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected reply body: %v", err)}
}
return nil
}
// haError turns a Home Assistant error body into an *Error.
func haError(status int, raw []byte) *Error {
var shape struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &shape)
msg := shape.Error.Message
if msg == "" {
msg = shape.Message
}
if msg == "" {
msg = strings.TrimSpace(string(raw))
}
return &Error{Status: status, ID: shape.Error.Code, Message: msg}
}
// serviceUID digs the created event id out of a service response. Home
// Assistant answers with {"service_response": {"calendar.x": {"uid": "..."}}};
// some integrations nest the event under "result" or "event", so all three are
// tried. An empty result is not an error: the description marker keeps the
// write idempotent.
func serviceUID(resp map[string]any, entity string) string {
for _, payload := range servicePayloads(resp, entity) {
if uid, ok := payload["uid"].(string); ok && uid != "" {
return uid
}
for _, key := range []string{"result", "event"} {
if nested, ok := payload[key].(map[string]any); ok {
if uid, ok := nested["uid"].(string); ok && uid != "" {
return uid
}
}
}
}
return ""
}
// serviceEvents reads the event list out of a calendar.get_events response.
func serviceEvents(resp map[string]any, entity string) []Event {
var out []Event
for _, payload := range servicePayloads(resp, entity) {
raw, ok := payload["events"].([]any)
if !ok {
continue
}
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
ev := Event{
UID: str(m["uid"]),
Summary: str(m["summary"]),
Description: str(m["description"]),
Location: str(m["location"]),
}
// Home Assistant reports a date or a date-time; try date-time first.
ev.Start, ev.StartRaw = parseHATime(m, "start")
ev.End, ev.EndRaw = parseHATime(m, "end")
out = append(out, ev)
}
}
return out
}
// servicePayloads returns the per-entity payload in a service response,
// tolerating both the wrapped ({"service_response": {...}}) and the bare
// shape. The entity key wins; other keys are only a fallback when Home
// Assistant renamed it.
func servicePayloads(resp map[string]any, entity string) []map[string]any {
if resp == nil {
return nil
}
root := resp
if sr, ok := resp["service_response"].(map[string]any); ok {
root = sr
}
if payload, ok := root[entity].(map[string]any); ok {
return []map[string]any{payload}
}
var out []map[string]any
for _, v := range root {
if payload, ok := v.(map[string]any); ok {
out = append(out, payload)
}
}
return out
}
func parseHATime(m map[string]any, prefix string) (*time.Time, string) {
if raw := str(m[prefix+"_date_time"]); raw != "" {
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
}
return nil, raw
}
return nil, ""
}
func str(v any) string {
s, _ := v.(string)
return s
}