// 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. // // 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") } 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, 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 { 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, }, false) 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), }, true) if err != nil { return nil, err } return serviceEvents(resp, c.entity), nil } // ---------- transport ---------- // callService posts to /api/services//. 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+"?return_response", 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 } // 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) { 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 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 }