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>
394 lines
12 KiB
Go
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
|
|
}
|