Files
compass_teams_nhs/internal/calendar/ha.go
Sam Rolfe 047af0f31d Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- Parse each child's own "Today" day widget for the timetable (FR-16); drop the
  school-wide week calendar's lesson grid and clean the leaked routine rows.
- Add the editable codes map (FR-17): subjects, rooms, teachers and period
  labels, seeded, an unknown code falls back to the raw code; `school codes` CLI.
- Brief: Finn's block first, then Harry's, four sections always present (FR-9);
  periods decoded into plain English; the undated tail is one counted line (FR-18).
- Calendar: write periods + work due + work overdue + major events, per child,
  on every pass. Identity is the stable per-item key, so a re-run never re-inserts
  (FR-8). morning-brief.sh now runs scan -> calendar push -> notify.
- Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods.

Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and
separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-16 10:25:55 +11:00

429 lines
13 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:"start,omitempty"`
End *time.Time `json:"end,omitempty"`
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/<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+"?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
}