diff --git a/.changeset/retire-built-in-calendar.md b/.changeset/retire-built-in-calendar.md new file mode 100644 index 00000000..c3b92601 --- /dev/null +++ b/.changeset/retire-built-in-calendar.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Remove the built-in CalDAV server, calendar settings and calendar-driven charging and away events. Use loadpoint targets and ready-by schedules for future charging. Existing goals, calendar data, learned models and forecast archives remain in place. Older config files still load and warn when calendar support was enabled. diff --git a/README.md b/README.md index 6ec42f91..82da5373 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ rule. See [docs/architecture.md](docs/architecture.md). - EV charging, V2X and thermal planning; - local web UI, SQLite history and Parquet rolloff; - Home Assistant MQTT discovery; -- CalDAV planning intents and published schedules; - hot-reloadable, independently released Lua drivers; - a built-in OCPP 1.6J + 2.0.1 server, so OCPP chargers connect with no driver. @@ -227,7 +226,7 @@ metadata are the detailed reference. - [OCPP chargers (no driver needed)](docs/ocpp.md) - [Self-update and release channels](docs/self-update.md) - [Home Assistant](docs/ha-integration.md) -- [CalDAV](docs/caldav-integration.md) +- [Calendar removal and existing schedules](docs/caldav-integration.md) Other files under [`docs/`](docs/) are focused installation or external-integration guides. diff --git a/config.example.yaml b/config.example.yaml index 4f5ca556..550d3052 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -146,36 +146,6 @@ homeassistant: password: homeems publish_interval_s: 5 -# Calendar-based planner constraints (#498). FTW hosts its OWN in-process, -# pure-Go CalDAV server (emersion/go-webdav, MIT — no sidecar, works in a -# single container incl. a Home Assistant add-on; objects persist in state.db) -# and turns the events you add into planner intents: "Away"/"Vacation" → away -# load profile; "Charge car 80%" → EV target SoC by the event time. It also -# writes EVSE usage-history and forward-looking plan calendars you subscribe -# to. Recurring events are expanded server-side. Opt-in + fail-soft; the -# password is stored in state.db. See docs/caldav-integration.md. -caldav: - enabled: false - # listen: ":5232" # bind address for the in-process CalDAV server - url: http://localhost:5232 - username: ftw - # manage_credentials (default true): FTW generates the password and the - # in-process server authenticates against it, then shows it (with a QR) in - # Settings → Calendar. Set false to set `password` here by hand. - manage_credentials: true - password: "" - calendar_path: /ftw/energy/ - poll_interval_s: 300 - ev_default_target_soc: 0.80 - # ev_loadpoint_id: "" # defaults to the first configured loadpoint - # away_keywords: [away, vacation, holiday] - # ev_keywords: [ev, car, charge] - evse_history: true # write a calendar event per EV charge session - history_path: /ftw/history/ - publish_plan: true # publish upcoming charge/discharge windows (read-only) - plan_path: /ftw/plan/ - # plan_publish_interval_s: 900 # how often the plan calendar is reconciled - # Built-in OCPP Central System, speaking 1.6J and 2.0.1. EV chargers that speak # OCPP connect to FTW directly — there is no driver to write and nothing to add # under `drivers:`. diff --git a/deploy/pi-gen/build.sh b/deploy/pi-gen/build.sh index a6c931fb..432bda8a 100755 --- a/deploy/pi-gen/build.sh +++ b/deploy/pi-gen/build.sh @@ -45,8 +45,6 @@ FTW_COMPOSE="${FTW_COMPOSE:-${REPO_ROOT}/docker-compose.yml}" install -m 0644 "${FTW_COMPOSE}" "${FILES_DIR}/docker-compose.yml" install -m 0644 "${REPO_ROOT}/mosquitto/config/mosquitto.conf" "${FILES_DIR}/mosquitto.conf" -# Calendar (#498) needs nothing shipped here: FTW's CalDAV server is in-process -# (no sidecar) and persists its objects in state.db. if [ ! -d "${PI_GEN_DIR}" ]; then # `git clone --branch` only accepts branch/tag names, not arbitrary diff --git a/deploy/pi-gen/stage-ftw/01-ftw-setup/00-run.sh b/deploy/pi-gen/stage-ftw/01-ftw-setup/00-run.sh index bfa9f92d..599d9a01 100755 --- a/deploy/pi-gen/stage-ftw/01-ftw-setup/00-run.sh +++ b/deploy/pi-gen/stage-ftw/01-ftw-setup/00-run.sh @@ -80,8 +80,6 @@ install -d -m 0755 "${ROOTFS_DIR}/opt/ftw" install -d -m 0755 -o 100 -g 101 "${ROOTFS_DIR}/opt/ftw/data" install -d -m 0755 "${ROOTFS_DIR}/opt/ftw/mosquitto" install -d -m 0755 "${ROOTFS_DIR}/opt/ftw/mosquitto/config" -# Calendar (#498) needs no extra dirs/files: FTW's CalDAV server is in-process -# (no sidecar) and persists in state.db under ./data. install -m 0644 files/docker-compose.yml "${ROOTFS_DIR}/opt/ftw/docker-compose.yml" install -m 0644 files/mosquitto.conf "${ROOTFS_DIR}/opt/ftw/mosquitto/config/mosquitto.conf" diff --git a/docker-compose.macos.yml b/docker-compose.macos.yml index 73668f1a..a7baa6d0 100644 --- a/docker-compose.macos.yml +++ b/docker-compose.macos.yml @@ -71,11 +71,6 @@ services: # NOT used — see the header.) ports: - "8080:8080" - # Calendar (#498): publish FTW's in-process CalDAV server so phones and - # desktops on the LAN can subscribe at http://:5232/. (On the - # host-networking Linux compose this is implicit; macOS bridge networking - # needs it published.) Drop this line if caldav.enabled is false. - - "5232:5232" volumes: - ./data:/app/data @@ -135,19 +130,6 @@ services: - ./mosquitto/config:/mosquitto/config:ro - mosquitto-data:/mosquitto/data - # --------------------------------------------------------------------- - # Calendar (CalDAV) planner constraints (#498) - # - # No service here: FTW hosts its OWN in-process, pure-Go CalDAV server - # (emersion/go-webdav, MIT) inside the main container above — there is no - # sidecar. With `caldav.enabled: true`, the in-container server binds :5232, - # which the main service publishes (see its ports above), so phones/desktops - # on the LAN subscribe at http://:5232//energy/. Keep - # `caldav.url: http://localhost:5232` (the in-container loopback). Credentials - # are auto-managed and shown (with a QR) in Settings → Calendar. LAN-only by - # design. See docs/caldav-integration.md. - # --------------------------------------------------------------------- - volumes: update-ipc: mosquitto-data: diff --git a/docker-compose.yml b/docker-compose.yml index f0a9ce00..e65cde1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,23 +162,6 @@ services: - ./mosquitto/config:/mosquitto/config:ro - mosquitto-data:/mosquitto/data - # --------------------------------------------------------------------- - # Calendar (CalDAV) planner constraints (#498) - # - # No service here: FTW hosts its OWN in-process, pure-Go CalDAV server - # (emersion/go-webdav, MIT) inside the main container — there is no sidecar to - # run. With `caldav.enabled: true`, point a CalDAV account in your phone or - # desktop calendar app at http://:5232//energy/ and create - # events to drive the planner: - # - "Away" / "Vacation" → conserve battery while you're out. - # - "Charge car 80%" → have the EV ready by the event's start time. - # FTW also writes read-only "EVSE history" and "plan" calendars you subscribe - # to. Credentials are auto-managed and shown (with a QR) in Settings → - # Calendar. LAN-ONLY by design; nothing forwards port 5232 to the internet - # unless the operator does so separately. - # See docs/caldav-integration.md. - # --------------------------------------------------------------------- - volumes: update-ipc: mosquitto-data: diff --git a/docs/architecture.md b/docs/architecture.md index 579d320a..779cb6a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ never stop local measurement or make dispatch unsafe. Core can run without the optimizer. Hardware cannot be accessed without a driver, but one failed driver is isolated from the others. Optional -integrations such as Home Assistant, CalDAV, notifications and Nova attach at +integrations such as Home Assistant, notifications and Nova attach at core's API, state or telemetry boundaries; they do not own dispatch safety. A future module belongs outside core only when it has: diff --git a/docs/caldav-integration.md b/docs/caldav-integration.md index b89b4196..578faa90 100644 --- a/docs/caldav-integration.md +++ b/docs/caldav-integration.md @@ -1,143 +1,18 @@ -# Calendar integration (CalDAV) — planner constraints + EVSE history +# Calendar removal -Issue #498. Let the planner act on intent you express in your normal calendar -app, and surface energy history back into that calendar — all on your local -network. +FTW no longer runs a CalDAV server or reads calendar events. The Calendar +settings tab, subscription feeds and port 5232 have been removed. -## Mental model +Before upgrading, move any future calendar charging events to the usual +loadpoint targets and ready-by schedules. Existing saved loadpoint goals +remain in place. Away events no longer change load forecasts; new forecasts +use the existing home default. Saved forecasts keep their original inputs. -FTW **hosts its own CalDAV server**, in-process, and also runs a CalDAV -**client** against it: +Old configuration files still load. FTW ignores the `caldav` section and +logs a warning when it was enabled. Remove this section when you next edit +the file, and remove calendar accounts or subscriptions from your phone. -- The **server** ([`go/internal/caldavserver`](../go/internal/caldavserver)) is pure-Go, built on - [`emersion/go-webdav`](https://github.com/emersion/go-webdav) (MIT). It ships - inside the single FTW binary — no sidecar, no second container — and persists - calendar objects in `state.db`. It binds `:5232` on your LAN so your phone or - desktop calendar app can subscribe. Because it's in-process it runs everywhere - FTW does, **including a single-container Home Assistant add-on**. -- The **client** ([`go/internal/calendar`](../go/internal/calendar)) polls a collection on that server and - maps events onto planner machinery. - -``` - Calendar app ──CalDAV(LAN :5232)──▶ FTW - (phone / ├─ caldavserver (in-process, go-webdav) - Thunderbird) └─ calendar client ──▶ away → loadmodel.ProfileAway - (poll/write over "charge car 80%" → loadpoint target - localhost) EV session ended → write history event -``` - -Two directions: - -- **Inbound** — you create events in your app; FTW reads them as intents. -- **Outbound** — FTW writes read-only "EVSE history" and "plan" calendars you - subscribe to (one event per completed charge session; upcoming - charge/discharge windows). - -## Security / network posture - -- The CalDAV server listens on **`:5232`** (all interfaces), so it is - reachable from devices on the home network. FTW does not publish a relay or - create an internet port-forward. Off the LAN it does not sync unless the - operator separately provides private network access. -- Authentication is HTTP Basic over **plain HTTP** — credentials are - base64-encoded (not encrypted). This is standard for self-hosted CalDAV on a - **trusted** home network. If your LAN has guest WiFi or untrusted IoT - devices, treat this as a weaker boundary: use a strong password, leave the - feature off (it is opt-in), or put FTW behind a TLS reverse proxy. The server - fails closed — an empty configured password rejects every request. -- The FTW API and CalDAV endpoint are local-network services. Do not expose - either directly to the public internet. -- DoS hardening: FTW's client caps the CalDAV response it will read (25 MiB) and - the number of events it parses per poll (10k), and bounds each poll with a - timeout, so a hostile/MITM'd server can't exhaust the Pi or stall the calendar - loop. The control loop runs in separate goroutines and is never blocked. - -## Setup - -No sidecar, nothing to install. FTW **manages the credential for you** -(`caldav.manage_credentials: true`): on first enable it generates a random -password and shows the username + password (with a QR) in **Settings → -Calendar**. - -1. In the dashboard, **Settings → Calendar**: tick *Enabled*, save. -2. FTW starts its in-process CalDAV server on `:5232`. Open the **Calendar - account** panel that appears — copy the username + password, or scan the QR - to get the subscribe URL onto your phone — and add a CalDAV account in your - calendar app pointing at the shown URL, e.g. - `http://:5232/ftw/energy/` (the tab rewrites `localhost` - to the dashboard's host for you). - -That's the whole flow. - -> **Manual credentials.** Set `caldav.manage_credentials: false` and put your -> own `password` in the `caldav:` block (stored in `state.db`, not `config.yaml` -> — see below). The server authenticates against it. - -## Writing intents (title keywords) - -Events are classified by case-insensitive keyword in the **title**: - -| Title example | Meaning | -|---|---| -| `Away`, `Vacation 2 weeks`, `Holiday` | Away interval `[start, end)` → away load profile (~25% load); planner conserves battery. | -| `Charge car 80%` | EV must reach 80% by the event's **start** time. `lp:` selects a loadpoint; no `%` → `ev_default_target_soc` (0–1). | - -Keyword lists (`away_keywords`, `ev_keywords`) are configurable for other -languages. What FTW parsed is visible at `GET /api/caldav/status`. - -**Recurring events work fully.** A weekly *Away* or a daily *Charge car* expands -into its individual occurrences server-side (RFC 4791 `CALDAV:expand`, via -[`caldavserver/expand.go`](../go/internal/caldavserver/expand.go)), so the planner sees every occurrence inside its -horizon — not just the first. RRULE, RDATE and EXDATE are all honoured, and if -you edit or delete a single occurrence in your calendar app (a per-instance -`RECURRENCE-ID` override or cancellation) that one occurrence is updated/removed -while the rest of the series is unchanged. - -## EVSE history (outbound) - -When an EV charge session ends, FTW writes a VEVENT into a **separate** -collection (`history_path`, fresh-install default `/ftw/history/`) — e.g. -`EV charged 12.3 kWh`, spanning the charge window. The history collection is -deliberately distinct from the intent calendar so FTW never re-reads its own -events as intents. Subscribe to it read-only. Disable with `evse_history: -false`. - -## Plan publishing (outbound, forward-looking) - -FTW also publishes the planner's **upcoming** decisions as a read-only calendar -you can subscribe to (`plan_path`, fresh-install default `/ftw/plan/`). On each -publish it coalesces the MPC plan into charge/discharge windows — e.g. -`Charge battery ~3.2 kW` from 02:00–05:00 — marked `TENTATIVE` (it's a plan, -not a commitment). - -Because the plan re-plans every ~15 min, the publisher **reconciles** rather -than appends: each cycle it PUTs new/changed windows and DELETEs windows that -are no longer planned (or have fallen into the past), keyed by a stable UID, -so your calendar reflects the current plan without piling up stale events. -Only forward-looking windows are published; idle/"hold" slots are omitted. -Disable with `publish_plan: false`; tune cadence with -`plan_publish_interval_s` (default 900). The plan, history and intent -collections are kept distinct so FTW never re-reads its own output as input. - -## Config - -See the `caldav:` block in [`config.example.yaml`](../config.example.yaml). The password is stored in -`state.db` (key `caldav_password`), never written to `config.yaml`. URL, -credentials, keywords and intervals hot-reload; toggling `enabled` needs a -restart. `listen` (default `:5232`) sets the server's bind address. - -## Deploy modes & Home Assistant - -The CalDAV server is in-process and pure-Go (MIT), so it works in **every** -deploy mode with nothing extra to install: - -- **Raspberry Pi image / raw binary / docker-compose (host networking):** the - server binds `:5232` directly on the host. Subscribe at - `http://:5232/…`. -- **docker-compose on macOS (bridge networking):** the main service publishes - `5232:5232` (see [`docker-compose.macos.yml`](../docker-compose.macos.yml)) so phones reach it; keep - `caldav.url: http://localhost:5232` (the in-container loopback). -- **Home Assistant add-on (single container):** it just works — there is no - sidecar at all, so no deploy-mode is gated off. - -Objects persist in `state.db`, so events survive restarts and image upgrades. +The upgrade keeps existing calendar objects and credentials in `state.db`. +Back up the database and the old config before upgrading if you may need to +return to an older release. The database backup includes those old tables; +a fresh database no longer creates them. diff --git a/docs/roadmap.md b/docs/roadmap.md index a651b229..3155cda4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -58,7 +58,7 @@ core still validates and dispatches. | 3 | Freeze and hold | Core can command freeze-charge, freeze-export and hold as named intents. A driver that declares the capability executes the vendor hold. A driver that cannot freeze degrades to a quantified 0 W charge or discharge clamp, never a pretend hold. Idle 0 W is not freeze. | Driver capability tests for at least one hybrid that implements hold and one that degrades. Restart and stale-driver paths return to autonomous default, not a stuck freeze. | Not started, except the degrade half: idle mode holds every battery at a quantified 0 W and bounded manual holds auto-expire ([#817](https://github.com/srcfl/ftw/pull/817)). No freeze intents, no driver hold capability. | | 4 | Fuse tree and phase scaling | Site limits are a tree: a child circuit has a parent, optional meter, and max current and/or max power. Before pausing a charge, core scales 3-phase to 1-phase when the charger can switch and the child still has headroom. New and live sessions share the tree; leftover-headroom-only is not enough. A stale circuit meter is over-limit, not a sum of children. | Tests cover nested circuits, metered vs summed children, 1p/3p before pause, live rebalance, and stale-meter fail-closed. The UI shows the tree and the active clip. | Partial. The single-fuse guard is mature — per-phase clamps, latching hysteresis, joint EV allocation — and 3p→1p happens before a surplus pause. No circuit tree exists: no children, parents, per-circuit meters or power caps. | | 5 | Solar gate fallback | When the optimizer is unavailable, invalid or stale, core still allocates surplus. Below the house reserve, solar goes to the battery. The battery assists an EV only when live surplus clears a gate. This is the Go fallback, not a second planner. | Tests cover optimizer-down, optimizer-invalid, and surplus below/above the gate without emptying the house battery into the car. | Largely implemented, and overtaken by [#1030](https://github.com/srcfl/ftw/pull/1030): the Go DP is now the champion planner and the Python solver a measurement shadow. Solver failure falls back to the DP, a stale plan degrades to live self-consumption, and battery-assists-EV is gated — today on the per-loadpoint `surplus_unlock_bat_soc` threshold, because track 2's house reserve does not exist yet. | -| 6 | Charge as energy and deadline | A loadpoint goal is remaining energy or SoC, a ready-by time, optional weekday mask, and a strategy: cheapest slots or one continuous block. An optional late window moves the last minutes to just before leave. If the vehicle SoC is stale, the goal is kWh, never an invented percent. A session already drawing power pins the first planner slot to the measured watts so a replan does not cancel a human start. | Tests cover deadline, weekday mask, cheapest vs continuous, late window, stale SoC, and the t=0 pin. CalDAV and the local UI write the same intent object. | Partial. Ready-by deadline and the weekday mask shipped ([#869](https://github.com/srcfl/ftw/pull/869)); Charge now → target releases back to the plan ([#1007](https://github.com/srcfl/ftw/pull/1007)). The kWh goal, cheapest-vs-continuous strategy, late window, stale-SoC→kWh rule, t=0 pin and the shared CalDAV/UI intent object are absent. | +| 6 | Charge as energy and deadline | A loadpoint goal is remaining energy or SoC, a ready-by time, optional weekday mask, and a strategy: cheapest slots or one continuous block. An optional late window moves the last minutes to just before leave. If the vehicle SoC is stale, the goal is kWh, never an invented percent. A session already drawing power pins the first planner slot to the measured watts so a replan does not cancel a human start. | Tests cover deadline, weekday mask, cheapest vs continuous, late window, stale SoC, and the t=0 pin. The local UI writes the loadpoint goal. | Partial. Ready-by deadline and the weekday mask shipped ([#869](https://github.com/srcfl/ftw/pull/869)); Charge now → target releases back to the plan ([#1007](https://github.com/srcfl/ftw/pull/1007)). The kWh goal, cheapest-vs-continuous strategy, late window, stale-SoC→kWh rule, t=0 pin are absent. | Tracks 1–5 are dispatcher policy. They keep working if the optimizer is down. Track 6 is an intent the optimizer may fill; the Go fallback must still produce diff --git a/docs/safety.md b/docs/safety.md index 01371c4d..639f9a07 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -89,7 +89,7 @@ failure and recovery. ## External systems -Home Assistant, CalDAV, notifications, cloud drivers, price/weather services +Home Assistant, notifications, cloud drivers, price/weather services and Nova fail soft. Their network I/O is outside the control tick, and their failure cannot disable local measurement or safety. Self-update snapshots state before replacement and uses immutable version targets. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 667b14f7..3c5c4b39 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -39,8 +39,6 @@ import ( "github.com/srcfl/ftw/go/internal/appuplink" "github.com/srcfl/ftw/go/internal/arp" "github.com/srcfl/ftw/go/internal/battery" - "github.com/srcfl/ftw/go/internal/caldavserver" - "github.com/srcfl/ftw/go/internal/calendar" "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/configreload" @@ -450,6 +448,12 @@ func main() { os.Exit(1) } defer st.Close() + if cfg.RetiredCalendarEnabled { + if err := st.RetireCalendarProfile(); err != nil { + slog.Error("retire calendar profile", "err", err) + os.Exit(1) + } + } // The repository is entirely local on startup: existing active symlinks are // usable offline and remote refresh never blocks core boot. @@ -488,27 +492,6 @@ func main() { } } - // ---- Restore CalDAV password from state.db (not stored in YAML) ---- - if cfg.CalDAV != nil { - if pw, ok := st.LoadConfig("caldav_password"); ok { - cfg.CalDAV.Password = pw - } - } - // Managed credential (#498): mint a random password on first enable so the - // operator never sets one by hand. Persisted to state.db; the in-process - // CalDAV server authenticates against it and the Settings tab shows it (with - // a QR) to paste into a calendar app. - if cfg.CalDAV.ManageCredentialsEnabled() && cfg.CalDAV.Password == "" { - if tok, err := calendar.GenerateToken(18); err != nil { - slog.Warn("caldav: failed to generate managed credential", "err", err) - } else if err := st.SaveConfig("caldav_password", tok); err != nil { - slog.Warn("caldav: failed to persist managed credential", "err", err) - } else { - cfg.CalDAV.Password = tok - slog.Info("caldav: generated managed credential") - } - } - // ---- Telemetry store ---- tel := telemetry.NewStore() @@ -908,10 +891,6 @@ func main() { // with hot-reloaded fuse params. Assigned later (loadpoint.NewController). var lpController *loadpoint.Controller - // Forward-declared before the reload watcher so the callback can - // hot-reload the calendar client (#498). Assigned later (calendar.New). - var calSvc *calendar.Service - // ---- Config hot-reload watcher ---- // Named because two callers share it: the fsnotify watcher created // below and POST /api/config (Deps.ConfigApplier), so a config saved @@ -925,14 +904,6 @@ func main() { newCfg.EVCharger.Password = pw } } - // Restore CalDAV password from state.db (not in YAML). Any CalDAV - // change is restart-gated because the native server and client must - // switch credentials, paths, and listeners atomically. - if newCfg.CalDAV != nil { - if pw, ok := st.LoadConfig("caldav_password"); ok { - newCfg.CalDAV.Password = pw - } - } // Driver paths are already resolved by config.Load; no extra // work needed here. Re-apply the battery SoC-window → driver // config mapping so a hot-edited soc_max reaches the driver too. @@ -1280,37 +1251,6 @@ func main() { defer loadSvc.Stop() slog.Info("loadmodel started", "peak_w", loadPeakW, "quality", loadSvc.Model().Quality()) - // ---- Calendar (CalDAV) planner constraints (#498) ---- - // FTW hosts its own in-process, pure-Go CalDAV server (internal/caldavserver, - // emersion/go-webdav, MIT) and runs a CalDAV *client* against it: it maps - // "away" events onto the load model's away profile and EV - // "charged-by-departure" events onto loadpoint targets. Opt-in + fail-soft; - // enable/disable is restart-gated (config.RestartRequiredFor), so the runtime - // block only ever exists while enabled. Single-container friendly — runs in a - // Home Assistant add-on with no sidecar. - var caldavSrv *caldavserver.Server - if cfg.CalDAV != nil && cfg.CalDAV.Enabled { - // Host CalDAV in-process; the client below talks to it over localhost, so - // the inbound/outbound intent logic is the same regardless. Objects - // persist in state.db so they survive restarts. - principal, calPaths, feeds := nativeCalDAVLayout(cfg.CalDAV) - caldavSrv = caldavserver.New(cfg.CalDAV.ListenAddr(), caldavUsername(cfg.CalDAV), cfg.CalDAV.Password, principal, calPaths, st, caldavserver.WithFeeds(feeds)) - caldavSrv.Start() - defer caldavSrv.Stop() - - calSvc = calendar.New(*cfg.CalDAV, lpMgr, loadSvc, firstLoadpointID(cfg.Loadpoints)) - // Outbound EVSE history: feed live EV charge-point readings so the - // service can author a calendar event per completed session. - calSvc.SetEVSource(func() []calendar.EVSample { return evSamplesFromTelemetry(tel) }) - // Outbound plan publishing: feed the current MPC plan so the service - // can render forward-looking charge/discharge windows. mpcSvc is built - // just below; the closure reads it at call time (nil-safe until then). - calSvc.SetPlanSource(func() []calendar.PlanSlot { return planSlotsFromMPC(mpcSvc) }) - calSvc.Start(ctx) - defer calSvc.Stop() - slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) - } - // ---- Start OCPP 1.6J Central System (optional) ---- // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua // driver involved. A charge point whose identity (the last segment of the @@ -1488,9 +1428,7 @@ func main() { }, curtailed: func(time.Time) bool { return forecastCurtail.Active() }, } - if calSvc != nil { - forecastTrackerSvc.away = calSvc.IsAwayAt - } + // No live occupancy source: forecasts keep the established home default. if energyplanSupported(runtime.GOOS, runtime.GOARCH) { if candidate, err := newRustForecast(st, resolveEnergyplanBinary()); err != nil { slog.Warn("primary forecast worker unavailable; using legacy fallback", "err", err) @@ -1563,21 +1501,7 @@ func main() { if cfg.Planner != nil { mpcSvc.MinArbitrageSpreadOreKwh = cfg.Planner.MinArbitrageSpreadOreKwh } - // Away-aware load predictor (#498): for slots inside a calendar - // "away" interval, predict with the load model's away profile so the - // DP conserves battery over exactly those slots. Outside any away - // window (and whenever CalDAV is off), this is identical to - // loadSvc.Predict. - if calSvc != nil { - mpcSvc.Load = func(t time.Time) float64 { - if calSvc.IsAwayAt(t) { - return loadSvc.PredictWith(t, loadmodel.ProfileAway) - } - return loadSvc.PredictWith(t, loadmodel.ProfileHome) - } - } else { - mpcSvc.Load = loadSvc.Predict - } + mpcSvc.Load = loadSvc.Predict mpcSvc.Price = priceFc.Predict mpcSvc.SiteMeter = cfg.SiteMeterDriver() // The mathematical planner co-optimizes every scheduled loadpoint. @@ -2473,7 +2397,6 @@ func main() { LoadpointCtrl: lpController, OCPPChargers: ocppChargersFn, EVSend: evSend, - CalDAV: calSvc, HA: haBridge, Registry: reg, DriverRepository: driverRepository, @@ -3660,111 +3583,6 @@ func warnIfEVHasBatteryCapacity(drvList []config.Driver, loadpoints []config.Loa } } -// firstLoadpointID returns the ID of the first configured loadpoint, or "". -// Used as the fallback target for a calendar EV event whose title names no -// specific loadpoint and when caldav.ev_loadpoint_id is unset. -func firstLoadpointID(src []config.Loadpoint) string { - if len(src) > 0 { - return src[0].ID - } - return "" -} - -// caldavUsername resolves the configured CalDAV username. The runtime fallback -// remains the former default so an existing config that omitted the field does -// not silently move its principal; fresh UI/example configs write `ftw`. -func caldavUsername(cv *config.CalDAV) string { - if cv != nil && strings.TrimSpace(cv.Username) != "" { - return strings.TrimSpace(cv.Username) - } - return config.DefaultCalDAVUsername -} - -// nativeCalDAVLayout derives the principal path + the collections the -// in-process CalDAV server (#498) should expose, from config (with defaults). -func nativeCalDAVLayout(cv *config.CalDAV) (principal string, calendarPaths []string, feeds map[string]string) { - principal = "/" + caldavUsername(cv) + "/" - calPath := config.DefaultCalDAVCalendarPath - histPath := config.DefaultCalDAVHistoryPath - planPath := config.DefaultCalDAVPlanPath - if cv != nil { - if strings.TrimSpace(cv.CalendarPath) != "" { - calPath = cv.CalendarPath - } - if strings.TrimSpace(cv.HistoryPath) != "" { - histPath = cv.HistoryPath - } - if strings.TrimSpace(cv.PlanPath) != "" { - planPath = cv.PlanPath - } - } - // Only the read-only collections get a one-tap webcal:// feed; the - // read-write "energy" collection is where the user *writes* intents, so a - // read-only subscription would be the wrong tool for it. - feeds = map[string]string{"plan": planPath, "history": histPath} - return principal, []string{calPath, histPath, planPath}, feeds -} - -// evSamplesFromTelemetry projects current DerEV readings into the shape the -// calendar service's history writer consumes (#498). One sample per EV -// charge-point driver; the writer turns charge→idle transitions into events. -func evSamplesFromTelemetry(tel *telemetry.Store) []calendar.EVSample { - readings := tel.ReadingsByType(telemetry.DerEV) - out := make([]calendar.EVSample, 0, len(readings)) - for _, r := range readings { - var d struct { - Connected *bool `json:"connected"` - Charging *bool `json:"charging"` - SessionWh *float64 `json:"session_wh"` - } - if len(r.Data) > 0 { - _ = json.Unmarshal(r.Data, &d) - } - var sessionWh float64 - if d.SessionWh != nil { - sessionWh = *d.SessionWh - } - out = append(out, calendar.EVSample{ - ID: r.Driver, - Connected: d.Connected != nil && *d.Connected, - Charging: d.Charging != nil && *d.Charging, - SessionWh: sessionWh, - PowerW: r.SmoothedW, - }) - } - return out -} - -// planSlotsFromMPC projects the latest MPC plan into the shape the calendar -// service's plan publisher consumes. Nil-safe: returns nil -// when the planner is disabled or has no plan yet. -func planSlotsFromMPC(mpcSvc *mpc.Service) []calendar.PlanSlot { - if mpcSvc == nil { - return nil - } - plan := mpcSvc.Latest() - if plan == nil { - return nil - } - out := make([]calendar.PlanSlot, 0, len(plan.Actions)) - for _, a := range plan.Actions { - start := time.UnixMilli(a.SlotStartMs) - ln := a.SlotLenMin - if ln <= 0 { - ln = 15 - } - out = append(out, calendar.PlanSlot{ - Start: start, - End: start.Add(time.Duration(ln) * time.Minute), - BatteryW: a.BatteryW, - GridW: a.GridW, - SoC: a.SoC, - Confidence: a.Confidence, - }) - } - return out -} - // activeBatteryBoostTotals keeps the core dispatch tick safe when the optional // planner is disabled. The loadpoint controller currently shares the planner's // lifecycle, so no controller means there can be no active boost permission. diff --git a/go/go.mod b/go/go.mod index 6bccf1fd..d877a7f9 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,8 +4,6 @@ go 1.26.0 require ( github.com/eclipse/paho.mqtt.golang v1.5.1 - github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 - github.com/emersion/go-webdav v0.7.0 github.com/fsnotify/fsnotify v1.10.1 github.com/fxamacker/cbor/v2 v2.9.2 github.com/goburrow/serial v0.1.0 @@ -48,7 +46,6 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.4.0 // indirect - github.com/teambition/rrule-go v1.8.2 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/twpayne/go-geom v1.6.1 // indirect diff --git a/go/go.sum b/go/go.sum index 6d87997d..64ea7a81 100644 --- a/go/go.sum +++ b/go/go.sum @@ -19,12 +19,6 @@ github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7 github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= -github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= -github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY= -github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= -github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= -github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= -github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= @@ -110,8 +104,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= -github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 560223c7..2b26621c 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -31,7 +31,6 @@ import ( // this package through an interface and must never import it. "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/battery" - "github.com/srcfl/ftw/go/internal/calendar" "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/configreload" @@ -63,9 +62,6 @@ const ( // evPasswordKey is the state.db key for the EV charger password // (stored outside config.yaml for security). evPasswordKey = "ev_charger_password" - // caldavPasswordKey is the state.db key for the CalDAV password (#498), - // stored outside config.yaml for security — same pattern as the EV charger. - caldavPasswordKey = "caldav_password" // maskedPlaceholder is sent to the UI to indicate a password is set // without revealing the actual value. maskedPlaceholder = "••••••••" @@ -173,10 +169,6 @@ type Deps struct { // Pause / Resume / Force start would find no such driver and fail. EVSend func(ctx context.Context, name string, payload []byte) error - // Optional: CalDAV calendar-constraints client (#498). Nil when the - // feature is disabled; GET /api/caldav/status then reports disabled. - CalDAV *calendar.Service - // Optional: HA MQTT bridge (nil if disabled). HA *ha.Bridge @@ -464,8 +456,6 @@ func (s *Server) routes() { s.handle("GET /api/components", Read, s.handleComponents) s.handle("GET /api/components/history", Read, s.handleComponentHistory) s.handle("GET /api/ha/status", Read, s.handleHAStatus) - s.handle("GET /api/caldav/status", Read, s.handleCalDAVStatus) - s.handle("GET /api/caldav/credentials", Local, s.handleCalDAVCredentials) s.handle("GET /api/notifications/status", Read, s.handleNotificationsStatus) s.handle("GET /api/notifications/defaults", Read, s.handleNotificationsDefaults) s.handle("GET /api/notifications/history", Read, s.handleNotificationsHistory) @@ -1351,14 +1341,6 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { masked.EVCharger = &cp } } - // CalDAV password also lives in state.db — signal "set" with the placeholder. - if masked.CalDAV != nil { - if pw, ok := s.deps.State.LoadConfig(caldavPasswordKey); ok && pw != "" { - cp := *masked.CalDAV - cp.Password = maskedPlaceholder - masked.CalDAV = &cp - } - } // Mask driver-declared config_secrets (e.g. sonnen api_token) so // the UI never sees the plaintext token in /api/config. The // settings tab renders an empty input + "Saved" badge; on POST the @@ -1629,21 +1611,6 @@ func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) { } } - // CalDAV password follows the exact same state.db pattern (#498). Without - // this, a UI-set password would only live in memory and be blanked on the - // next config-reload (Password is yaml:"-", so it is never in config.yaml). - var caldavPasswordToPersist string - var persistCalDAVPassword bool - if newCfg.CalDAV != nil { - pw := newCfg.CalDAV.Password - if pw != "" && pw != maskedPlaceholder { - caldavPasswordToPersist = pw - persistCalDAVPassword = true - } else if stored, ok := s.deps.State.LoadConfig(caldavPasswordKey); ok { - newCfg.CalDAV.Password = stored - } - } - if err := newCfg.Validate(); err != nil { writeJSON(w, 400, map[string]string{"error": "validation: " + err.Error()}) return @@ -1667,11 +1634,6 @@ func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, 500, map[string]string{"error": "save failed: " + err.Error()}) return } - if persistCalDAVPassword { - if err := s.deps.State.SaveConfig(caldavPasswordKey, caldavPasswordToPersist); err != nil { - slog.Warn("failed to persist caldav_password", "err", err) - } - } if persistEVPassword { if err := s.deps.State.SaveConfig(evPasswordKey, evPasswordToPersist); err != nil { slog.Warn("failed to persist ev_charger_password", "err", err) diff --git a/go/internal/api/api_caldav.go b/go/internal/api/api_caldav.go deleted file mode 100644 index 42a7e466..00000000 --- a/go/internal/api/api_caldav.go +++ /dev/null @@ -1,43 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" -) - -// handleCalDAVStatus renders the calendar-constraints client's diagnostic -// snapshot (issue #498): whether it's enabled, whether the CalDAV server is -// reachable, the last sync time, the parsed-intent counts, the live away -// state, the next EV deadline, and the subscribe URL + username the UI shows -// the operator to paste into their calendar app. -// -// Mirrors handleHAStatus: pure read, no mutation, nil-safe. When the feature -// is disabled (Deps.CalDAV nil) it reports {enabled:false} rather than 503 so -// the Settings tab can render a clean "disabled in config" state. -func (s *Server) handleCalDAVStatus(w http.ResponseWriter, r *http.Request) { - if s.deps.CalDAV == nil { - writeJSON(w, 200, map[string]any{"enabled": false}) - return - } - writeJSON(w, 200, s.deps.CalDAV.Status()) -} - -// handleCalDAVCredentials reveals the managed CalDAV credential (username + -// password) plus subscribe URLs so the Settings → Calendar tab can show them -// (and render a QR) for the operator to add the account to a phone/desktop -// calendar app. It intentionally returns the password for the local operator -// and is kept separate from the frequently-polled /status so the secret is not -// read on every poll. -func (s *Server) handleCalDAVCredentials(w http.ResponseWriter, r *http.Request) { - // Unlike the ordinary read APIs, this response contains a reusable secret. - // Keep the explicit headers here so it remains non-cacheable and cannot be - // content-sniffed if the generic JSON response helper changes later. - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Content-Type-Options", "nosniff") - if s.deps.CalDAV == nil { - _ = json.NewEncoder(w).Encode(map[string]any{"managed": false}) - return - } - _ = json.NewEncoder(w).Encode(s.deps.CalDAV.Credentials()) -} diff --git a/go/internal/api/api_caldav_test.go b/go/internal/api/api_caldav_test.go deleted file mode 100644 index 47f1700b..00000000 --- a/go/internal/api/api_caldav_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/srcfl/ftw/go/internal/calendar" - "github.com/srcfl/ftw/go/internal/config" -) - -func caldavStatus(t *testing.T, deps *Deps) map[string]any { - t.Helper() - srv := New(deps) - req := httptest.NewRequest(http.MethodGet, "/api/caldav/status", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - if w.Code != 200 { - t.Fatalf("status code = %d, body=%s", w.Code, w.Body.String()) - } - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode: %v (%s)", err, w.Body.String()) - } - return body -} - -func TestCalDAVStatusDisabledWhenNil(t *testing.T) { - body := caldavStatus(t, &Deps{Version: "test"}) - if body["enabled"] != false { - t.Fatalf("nil CalDAV should report enabled=false, got %v", body["enabled"]) - } -} - -func TestCalDAVStatusReportsSubscribeURL(t *testing.T) { - svc := calendar.New(config.CalDAV{Enabled: true}, nil, nil, "garage") - body := caldavStatus(t, &Deps{Version: "test", CalDAV: svc}) - if body["enabled"] != true { - t.Fatalf("expected enabled=true, got %v", body["enabled"]) - } - if body["subscribe_url"] == "" || body["subscribe_url"] == nil { - t.Fatalf("expected a subscribe_url, got %v", body["subscribe_url"]) - } - // History writer defaults ON when enabled. - if body["history_enabled"] != true { - t.Fatalf("expected history_enabled=true by default, got %v", body["history_enabled"]) - } -} - -func TestCalDAVCredentialsArePrivateAndUncached(t *testing.T) { - svc := calendar.New(config.CalDAV{ - Enabled: true, - Username: "calendar-user", - Password: "calendar-secret", - }, nil, nil, "garage") - srv := New(&Deps{Version: "test", CalDAV: svc}) - req := httptest.NewRequest(http.MethodGet, "/api/caldav/credentials", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("status code = %d, body=%s", w.Code, w.Body.String()) - } - if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { - t.Fatalf("credential response must not allow cross-origin reads, got %q", got) - } - if got := w.Header().Get("Cache-Control"); got != "no-store" { - t.Fatalf("Cache-Control = %q, want no-store", got) - } - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("decode: %v", err) - } - if body["password"] != "calendar-secret" { - t.Fatalf("credential body password = %v", body["password"]) - } -} diff --git a/go/internal/api/api_tiers_test.go b/go/internal/api/api_tiers_test.go index b0233e6b..65b28990 100644 --- a/go/internal/api/api_tiers_test.go +++ b/go/internal/api/api_tiers_test.go @@ -20,21 +20,14 @@ import ( "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/appuplink" "github.com/srcfl/ftw/go/internal/battery" - "github.com/srcfl/ftw/go/internal/calendar" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/selftune" "github.com/srcfl/ftw/go/internal/telemetry" ) -// caldavPassword is the credential the reviewer walked away with. It is a -// literal on purpose: the assertion is that these bytes never cross the -// session, and matching on them is the only way to say that. -const caldavPassword = "S3CRET-CALDAV-PASSWORD" - -// tieredRig is a session onto a box that has the two subsystems these tests -// are about. The other passthrough tests use a bare box; a bare box has no -// credential to leak and no battery to drive, which is why they missed both. +// tieredRig includes a battery so refusal tests also check that no control +// action runs. type tieredRig struct { *appRig srv *Server @@ -50,11 +43,6 @@ func newTieredSession(t *testing.T, role string) *tieredRig { cfg := &config.Config{ Drivers: []config.Driver{{Name: "pixii-1", BatteryCapacityWh: 16000}}, - CalDAV: &config.CalDAV{ - Enabled: true, - Username: "ftw", - Password: caldavPassword, - }, } coordinator := selftune.NewCoordinator() @@ -62,7 +50,6 @@ func newTieredSession(t *testing.T, role string) *tieredRig { Ctrl: ctrl, CtrlMu: &sync.Mutex{}, Tel: tel, LogRing: telemetry.NewLogRing(), Version: "test", CfgMu: &sync.RWMutex{}, Cfg: cfg, - CalDAV: calendar.New(*cfg.CalDAV, nil, nil, "lp1"), SelfTune: coordinator, Models: map[string]*battery.Model{"pixii-1": battery.New("pixii-1")}, ModelsMu: &sync.Mutex{}, @@ -106,88 +93,14 @@ func newTieredSession(t *testing.T, role string) *tieredRig { } } -// carried is every byte the session sent back, head, chunks and all. What a -// leak test needs is the wire, not one message. -func (r *tieredRig) carried(t *testing.T) string { - t.Helper() - var out strings.Builder - for _, env := range r.frames.snapshot() { - if env.T == appproto.MsgAPIChunk { - out.Write(decode[appproto.APIChunk](t, env).Data) - } - } - return out.String() -} - // -------------------------------------------------------------------------- // A read that hands out a credential is not a read // -------------------------------------------------------------------------- -// The CalDAV credential is a write channel into dispatch: the calendar it -// unlocks is what tells this box when the house is away and when the car has -// to be full. A family member given read-only access to watch the house walked -// away able to drive it. -func TestAViewerCannotReadACredential(t *testing.T) { - rig := newTieredSession(t, apiauth.RoleViewer) - - rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{ - Method: appproto.APIGet, Path: "/api/caldav/credentials", - }) - refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError)) - - if refusal.Code != appproto.ErrLocalOnly { - t.Fatalf("refusal = %+v, want E_LOCAL_ONLY", refusal) - } - if rig.frames.has(appproto.MsgAPIHead) { - t.Fatal("the credential handler answered an app session") - } - if body := rig.carried(t); strings.Contains(body, caldavPassword) { - t.Fatalf("the CalDAV password crossed the session: %q", body) - } -} - -// An owner is refused too, and that is the point of the tier rather than a -// role check. The credential is the same credential whoever asks for it, and -// the box's own page — which needs somebody at home — is where it is shown. -func TestAnOwnerCannotReadACredentialEither(t *testing.T) { - rig := newTieredSession(t, apiauth.RoleOwner) - - rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{ - Method: appproto.APIGet, Path: "/api/caldav/credentials", StepUp: true, - }) - refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError)) - - if refusal.Code != appproto.ErrLocalOnly { - t.Fatalf("refusal = %+v, want E_LOCAL_ONLY", refusal) - } - if body := rig.carried(t); strings.Contains(body, caldavPassword) { - t.Fatalf("the CalDAV password crossed the session: %q", body) - } -} - -// The LAN still serves it. The claim being made is "only from your box's own -// page, from home" — if the box's page could not show it either, the sentence -// the app says would be a lie and the calendar feature would be unusable. -func TestTheBoxsOwnPageStillShowsTheCredential(t *testing.T) { - rig := newTieredSession(t, apiauth.RoleOwner) - - req := httptest.NewRequest(http.MethodGet, "/api/caldav/credentials", nil) - rec := httptest.NewRecorder() - rig.srv.Handler().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("the LAN got %d for the credential", rec.Code) - } - if !strings.Contains(rec.Body.String(), caldavPassword) { - t.Fatalf("the LAN no longer sees the credential: %s", rec.Body.String()) - } -} - // Every route whose answer carries a reusable secret, or a whole file this box // cannot vouch for, swept as a viewer. None of them reaches a handler. func TestNoSecretBearingReadCrossesTheSession(t *testing.T) { secretBearing := []string{ - "/api/caldav/credentials", "/api/config", "/api/backups/x", "/api/support/dump", @@ -271,8 +184,7 @@ func TestSelfTuneIsRefusedWithoutStepUpAsWell(t *testing.T) { } } -// The box's own page still starts one. Same reason as the credential: a tier -// that made the feature unreachable everywhere would be a different change. +// The box's own page can still start a self-tune run. func TestTheBoxsOwnPageStillStartsSelfTune(t *testing.T) { rig := newTieredSession(t, apiauth.RoleOwner) diff --git a/go/internal/api/retired_calendar_test.go b/go/internal/api/retired_calendar_test.go new file mode 100644 index 00000000..f122d5c2 --- /dev/null +++ b/go/internal/api/retired_calendar_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appproto" +) + +func TestRetiredCalendarEndpointsAreNotServed(t *testing.T) { + srv := New(&Deps{Version: "test", WebDir: t.TempDir()}) + for _, path := range []string{"/api/caldav/status", "/api/caldav/credentials"} { + for _, method := range []string{http.MethodGet, http.MethodHead} { + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("%s %s = %d, want 404", method, path, rec.Code) + } + } + } +} + +func TestRetiredCalendarIsUnknownToAppSessions(t *testing.T) { + for _, role := range []string{apiauth.RoleOwner, apiauth.RoleViewer} { + for _, path := range []string{"/api/caldav/status", "/api/caldav/credentials"} { + rig := newTieredSession(t, role) + rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{Method: appproto.APIGet, Path: path, StepUp: true}) + refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError)) + if refusal.Code != appproto.ErrUnknownOp || rig.frames.has(appproto.MsgAPIHead) { + t.Fatalf("%s %s: %+v", role, path, refusal) + } + } + } +} diff --git a/go/internal/caldavserver/backend.go b/go/internal/caldavserver/backend.go deleted file mode 100644 index 18873c52..00000000 --- a/go/internal/caldavserver/backend.go +++ /dev/null @@ -1,204 +0,0 @@ -package caldavserver - -import ( - "context" - "crypto/sha1" - "encoding/hex" - "fmt" - "net/http" - "strings" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" - - "github.com/srcfl/ftw/go/internal/state" -) - -// backend is a caldav.Backend persisting calendar objects through a Store -// (state.db when wired, in-memory otherwise). iCalendar (de)serialization + -// ETag computation live here; the Store only moves bytes. -type backend struct { - principal string - store Store - now func() time.Time -} - -func newBackend(principal string, calendarPaths []string, store Store) *backend { - if store == nil { - store = NewMemStore() - } - b := &backend{principal: principal, store: store, now: time.Now} - for _, p := range calendarPaths { - name := strings.Trim(strings.TrimPrefix(p, principal), "/") - _ = b.store.SaveCalDAVCalendar(state.CalDAVCalendar{Path: p, Name: name}) - } - return b -} - -func (b *backend) CurrentUserPrincipal(ctx context.Context) (string, error) { - return b.principal, nil -} - -func (b *backend) CalendarHomeSetPath(ctx context.Context) (string, error) { - return b.principal, nil -} - -func toCalendar(c state.CalDAVCalendar) caldav.Calendar { - return caldav.Calendar{ - Path: c.Path, - Name: c.Name, - Description: c.Description, - SupportedComponentSet: []string{ical.CompEvent}, - } -} - -func (b *backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { - cals, err := b.store.ListCalDAVCalendars() - if err != nil { - return nil, err - } - out := make([]caldav.Calendar, 0, len(cals)) - for _, c := range cals { - out = append(out, toCalendar(c)) - } - return out, nil -} - -func (b *backend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { - cals, err := b.store.ListCalDAVCalendars() - if err != nil { - return nil, err - } - for _, c := range cals { - if c.Path == path { - cal := toCalendar(c) - return &cal, nil - } - } - return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("calendar not found: %s", path)) -} - -func (b *backend) CreateCalendar(ctx context.Context, cal *caldav.Calendar) error { - return b.store.SaveCalDAVCalendar(state.CalDAVCalendar{ - Path: cal.Path, Name: cal.Name, Description: cal.Description, - }) -} - -// parseObject decodes a stored row into a caldav.CalendarObject (Data parsed). -func parseObject(o state.CalDAVObject) (caldav.CalendarObject, error) { - cal, err := ical.NewDecoder(strings.NewReader(o.Data)).Decode() - if err != nil { - return caldav.CalendarObject{}, err - } - return caldav.CalendarObject{ - Path: o.Path, - ETag: o.ETag, - ModTime: time.UnixMilli(o.ModifiedMs), - ContentLength: int64(len(o.Data)), - Data: cal, - }, nil -} - -func (b *backend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { - o, ok, err := b.store.GetCalDAVObject(path) - if err != nil { - return nil, err - } - if !ok { - return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("object not found: %s", path)) - } - co, err := parseObject(o) - if err != nil { - return nil, err - } - return &co, nil -} - -func (b *backend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { - rows, err := b.store.ListCalDAVObjects(path) - if err != nil { - return nil, err - } - out := make([]caldav.CalendarObject, 0, len(rows)) - for _, o := range rows { - co, err := parseObject(o) - if err != nil { - continue // skip an unparseable row rather than failing the listing - } - out = append(out, co) - } - return out, nil -} - -func (b *backend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { - objs, err := b.ListCalendarObjects(ctx, path, &query.CompRequest) - if err != nil { - return nil, err - } - // caldav.Filter applies the component/property/time-range filters (it also - // evaluates the recurrence set so a recurring master matches when any of its - // instances falls in range). - matched, err := caldav.Filter(query, objs) - if err != nil { - return nil, err - } - // RFC 4791 CALDAV:expand — turn each recurring master into the concrete - // instances inside the requested window. Without this a "weekly away" event - // would return only its first occurrence. go-webdav v0.7 drops the explicit - // element, so the window comes from the comp-filter time-range that - // the client sends alongside it. See expand.go. - if exp := findExpand(query.CompRequest); exp != nil { - matched = expandObjects(matched, exp.Start, exp.End) - } else if start, end, ok := filterTimeRange(query.CompFilter); ok { - matched = expandObjects(matched, start, end) - } - return matched, nil -} - -func (b *backend) PutCalendarObject(ctx context.Context, path string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { - var sb strings.Builder - if err := ical.NewEncoder(&sb).Encode(cal); err != nil { - return nil, err - } - data := sb.String() - sum := sha1.Sum([]byte(data)) - collection := path - if i := strings.LastIndex(path, "/"); i >= 0 { - collection = path[:i+1] - } - // Auto-create the parent collection if a client PUTs without MKCALENDAR. - _ = b.store.SaveCalDAVCalendar(state.CalDAVCalendar{ - Path: collection, - Name: strings.Trim(strings.TrimPrefix(collection, b.principal), "/"), - }) - row := state.CalDAVObject{ - Path: path, - Collection: collection, - ETag: hex.EncodeToString(sum[:]), - Data: data, - ModifiedMs: b.now().UnixMilli(), - } - if err := b.store.SaveCalDAVObject(row); err != nil { - return nil, err - } - return &caldav.CalendarObject{ - Path: path, - ETag: row.ETag, - ModTime: time.UnixMilli(row.ModifiedMs), - ContentLength: int64(len(data)), - Data: cal, - }, nil -} - -func (b *backend) DeleteCalendarObject(ctx context.Context, path string) error { - _, ok, err := b.store.GetCalDAVObject(path) - if err != nil { - return err - } - if !ok { - return webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("object not found: %s", path)) - } - return b.store.DeleteCalDAVObject(path) -} diff --git a/go/internal/caldavserver/expand.go b/go/internal/caldavserver/expand.go deleted file mode 100644 index 5f7256e1..00000000 --- a/go/internal/caldavserver/expand.go +++ /dev/null @@ -1,308 +0,0 @@ -package caldavserver - -import ( - "log/slog" - "time" - - "github.com/emersion/go-ical" - "github.com/emersion/go-webdav/caldav" -) - -const ( - maxExpansionWindow = 366 * 24 * time.Hour - maxExpandedOccurrences = 10000 -) - -// findExpand walks a CalendarCompRequest tree for a CALDAV:expand directive. -// Clients nest it under the VEVENT comp request, so a plain top-level check -// isn't enough. Returns nil when the client did not request expansion. -// -// NB: go-webdav v0.7's REPORT handler does not decode the element -// into the backend query (it only surfaces the comp-filter), so in practice the -// expansion window comes from filterTimeRange below. This is kept for forward -// compatibility should a future go-webdav start passing it through. -func findExpand(req caldav.CalendarCompRequest) *caldav.CalendarExpandRequest { - if req.Expand != nil { - return req.Expand - } - for i := range req.Comps { - if e := findExpand(req.Comps[i]); e != nil { - return e - } - } - return nil -} - -// filterTimeRange returns the [start, end] window carried by a calendar-query's -// (VEVENT) comp-filter time-range, if any. This is how the requested window -// actually reaches the backend in go-webdav v0.7 — the client sends the same -// window on both the comp-filter and the (dropped) expand element. Only returns -// ok when both bounds are present, so an open-ended query keeps its masters. -func filterTimeRange(cf caldav.CompFilter) (time.Time, time.Time, bool) { - for i := range cf.Comps { - if s, e, ok := filterTimeRange(cf.Comps[i]); ok { - return s, e, ok - } - } - if !cf.Start.IsZero() && !cf.End.IsZero() { - return cf.Start, cf.End, true - } - return time.Time{}, time.Time{}, false -} - -// expandObjects implements RFC 4791 CALDAV:expand. Every recurring VEVENT in a -// resource is replaced by the concrete instances whose start falls inside -// [start, end], each carrying its own RECURRENCE-ID and stripped of -// RRULE/RDATE/EXDATE. Non-recurring components pass through unchanged. A -// resource left with no in-range component after expansion is dropped. -// -// caldav.Filter has already kept only resources with at least one instance in -// range (it evaluates the recurrence set for the time-range match), so this -// only ever expands events that genuinely have occurrences in the window. -func expandObjects(objs []caldav.CalendarObject, start, end time.Time) []caldav.CalendarObject { - out := make([]caldav.CalendarObject, 0, len(objs)) - for _, co := range objs { - if co.Data == nil { - out = append(out, co) - continue - } - expanded := expandCalendar(co.Data, start, end) - if expanded == nil { - continue - } - co.Data = expanded - out = append(out, co) - } - return out -} - -// eventGroup is the set of VEVENT components sharing one UID: the recurrence -// master (no RECURRENCE-ID) plus zero or more per-instance override components -// (each with a RECURRENCE-ID). Per RFC 5545 a recurrence set lives in a single -// calendar object resource, so grouping within one calendar is sufficient. -type eventGroup struct { - master *ical.Component - overrides []*ical.Component -} - -// expandCalendar returns a copy of cal with every recurring VEVENT expanded -// into its per-occurrence instances within [start, end]. RRULE, RDATE and -// EXDATE are resolved via go-ical's RecurrenceSet; per-instance RECURRENCE-ID -// override components replace (or, when STATUS:CANCELLED, delete) the matching -// generated instance. Non-event components (e.g. VTIMEZONE) and non-recurring -// events are preserved verbatim. Returns nil when no component remains. -func expandCalendar(cal *ical.Calendar, start, end time.Time) *ical.Calendar { - // CalDAV clients control the REPORT window. Bound it before recurrence - // math so an authenticated but buggy/hostile client cannot request decades - // of expansion and exhaust a Pi. The planner's configured horizon is at - // most one year, so this does not truncate an in-scope query. - if end.After(start.Add(maxExpansionWindow)) { - end = start.Add(maxExpansionWindow) - } - loc := start.Location() - if loc == nil { - loc = time.UTC - } - out := ical.NewCalendar() - for name, props := range cal.Props { - out.Props[name] = append([]ical.Prop(nil), props...) - } - - // Partition VEVENTs into UID groups; everything else passes through. Events - // with no UID can't be grouped, so each becomes its own singleton group. - var groups []*eventGroup - byUID := map[string]*eventGroup{} - for _, child := range cal.Children { - if child.Name != ical.CompEvent { - out.Children = append(out.Children, child) - continue - } - uid, _ := child.Props.Text(ical.PropUID) - var g *eventGroup - if uid != "" { - g = byUID[uid] - } - if g == nil { - g = &eventGroup{} - groups = append(groups, g) - if uid != "" { - byUID[uid] = g - } - } - if child.Props.Get(ical.PropRecurrenceID) != nil { - g.overrides = append(g.overrides, child) - } else { - g.master = child // a later non-RECURRENCE-ID component wins - } - } - - for _, g := range groups { - expandGroup(out, g, start, end, loc) - } - - if len(out.Children) == 0 { - return nil - } - return out -} - -// expandGroup emits the in-window instances for one UID group into out. -func expandGroup(out *ical.Calendar, g *eventGroup, start, end time.Time, loc *time.Location) { - // Index overrides by the instant their RECURRENCE-ID identifies. - overrides := make(map[int64]*ical.Component, len(g.overrides)) - for _, ov := range g.overrides { - if k, ok := recurrenceKey(ov, loc); ok { - overrides[k] = ov - } - } - consumed := make(map[int64]bool, len(overrides)) - - if g.master != nil { - if rset, err := g.master.RecurrenceSet(loc); err == nil && rset != nil { - if rule := rset.GetRRule(); rule != nil { - freq := rule.OrigOptions.Freq.String() - if freq == "SECONDLY" || freq == "MINUTELY" { - // Sub-hourly recurrence has no useful planner meaning and can - // generate hundreds of thousands of instances in the normal - // horizon. Preserve the master unexpanded for calendar clients, - // while keeping the planner from materialising the flood. - slog.Warn("caldav: refusing sub-hourly recurrence expansion", "frequency", freq) - out.Children = append(out.Children, g.master) - return - } - } - ev := ical.Event{Component: g.master} - st0, errS := ev.DateTimeStart(loc) - en0, errE := ev.DateTimeEnd(loc) - var dur time.Duration - if errS == nil && errE == nil && en0.After(st0) { - dur = en0.Sub(st0) - } - allDay := isAllDay(g.master) - occurrences := rset.Between(start, end, true) - if len(occurrences) > maxExpandedOccurrences { - slog.Warn("caldav: recurrence expansion capped", "count", len(occurrences), "cap", maxExpandedOccurrences) - occurrences = occurrences[:maxExpandedOccurrences] - } - for _, occ := range occurrences { - occ = occ.In(loc) - k := occ.UTC().Unix() - if ov, ok := overrides[k]; ok { - consumed[k] = true - // A cancelled occurrence is dropped; a moved one is emitted - // only if its new time still falls in the window. - if !isCancelled(ov) && overrideInWindow(ov, start, end, loc) { - out.Children = append(out.Children, ov) - } - continue - } - out.Children = append(out.Children, makeInstance(g.master, occ, dur, allDay)) - } - // Overrides whose original occurrence is outside the window but whose - // new time was moved into it (or RDATE-style additions) — emit once. - for _, ov := range g.overrides { - k, ok := recurrenceKey(ov, loc) - if !ok || consumed[k] || isCancelled(ov) { - continue - } - if overrideInWindow(ov, start, end, loc) { - out.Children = append(out.Children, ov) - consumed[k] = true - } - } - return - } - // Master without a recurrence set: a plain non-recurring event. Pass it - // through unchanged. - out.Children = append(out.Children, g.master) - } - - // Orphan overrides (no master in this resource): emit those intersecting the - // window so a stray override is never silently dropped. - for _, ov := range g.overrides { - if isCancelled(ov) { - continue - } - if overrideInWindow(ov, start, end, loc) { - out.Children = append(out.Children, ov) - } - } -} - -// makeInstance clones the master into a single concrete occurrence at occ: the -// RRULE/RDATE/EXDATE are stripped and DTSTART/DTEND/RECURRENCE-ID set. -func makeInstance(master *ical.Component, occ time.Time, dur time.Duration, allDay bool) *ical.Component { - inst := cloneComponent(master) - inst.Props.Del(ical.PropRecurrenceRule) - inst.Props.Del(ical.PropRecurrenceDates) - inst.Props.Del(ical.PropExceptionDates) - if allDay { - inst.Props.SetDate(ical.PropDateTimeStart, occ) - inst.Props.SetDate(ical.PropRecurrenceID, occ) - if dur > 0 { - inst.Props.SetDate(ical.PropDateTimeEnd, occ.Add(dur)) - } - } else { - inst.Props.SetDateTime(ical.PropDateTimeStart, occ) - inst.Props.SetDateTime(ical.PropRecurrenceID, occ) - if dur > 0 { - inst.Props.SetDateTime(ical.PropDateTimeEnd, occ.Add(dur)) - } - } - return inst -} - -// recurrenceKey is the instant (unix seconds, UTC) a component's RECURRENCE-ID -// identifies, used to match an override to a generated occurrence. -func recurrenceKey(c *ical.Component, loc *time.Location) (int64, bool) { - rid := c.Props.Get(ical.PropRecurrenceID) - if rid == nil { - return 0, false - } - t, err := rid.DateTime(loc) - if err != nil { - return 0, false - } - return t.UTC().Unix(), true -} - -// isAllDay reports whether the component's DTSTART is a DATE (no time-of-day). -func isAllDay(c *ical.Component) bool { - p := c.Props.Get(ical.PropDateTimeStart) - return p != nil && p.ValueType() == ical.ValueDate -} - -// isCancelled reports whether the component is STATUS:CANCELLED — i.e. this -// occurrence has been removed from the recurrence set. -func isCancelled(c *ical.Component) bool { - s, err := (&ical.Event{Component: c}).Status() - return err == nil && s == ical.EventCancelled -} - -// overrideInWindow reports whether an override component's own [DTSTART, DTEND] -// intersects [start, end]. Fails open (includes) when the times can't be read, -// mirroring how non-recurring events pass through. -func overrideInWindow(c *ical.Component, start, end time.Time, loc *time.Location) bool { - ev := ical.Event{Component: c} - s, err := ev.DateTimeStart(loc) - if err != nil { - return true - } - e, err := ev.DateTimeEnd(loc) - if err != nil || !e.After(s) { - e = s - } - return !s.After(end) && !e.Before(start) -} - -// cloneComponent deep-copies a component's property slices (so per-instance -// edits never touch the stored master) and shallow-copies its children, which -// the expander only reads. -func cloneComponent(c *ical.Component) *ical.Component { - nc := ical.NewComponent(c.Name) - for name, props := range c.Props { - nc.Props[name] = append([]ical.Prop(nil), props...) - } - nc.Children = append(nc.Children, c.Children...) - return nc -} diff --git a/go/internal/caldavserver/expand_test.go b/go/internal/caldavserver/expand_test.go deleted file mode 100644 index 35ccb9a9..00000000 --- a/go/internal/caldavserver/expand_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package caldavserver - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" -) - -// expandQuery is a calendar-query REPORT carrying a VEVENT time-range — the -// shape FTW's calendar client sends — which the backend uses as the recurrence -// expansion window. -func expandQuery(start, end time.Time) *caldav.CalendarQuery { - return &caldav.CalendarQuery{ - CompRequest: caldav.CalendarCompRequest{ - Name: "VCALENDAR", - Comps: []caldav.CalendarCompRequest{{ - Name: "VEVENT", - AllProps: true, - Expand: &caldav.CalendarExpandRequest{Start: start, End: end}, - }}, - }, - CompFilter: caldav.CompFilter{ - Name: "VCALENDAR", - Comps: []caldav.CompFilter{{Name: "VEVENT", Start: start, End: end}}, - }, - } -} - -// TestNativeServerExpandsRecurrence proves the gap that used to require an -// external CalDAV server is closed: a daily-recurring event is returned as one -// concrete instance per occurrence in the queried window — each with a -// RECURRENCE-ID and no RRULE — rather than just its master VEVENT. -func TestNativeServerExpandsRecurrence(t *testing.T) { - srv := httptest.NewServer(testHandler("u", "p", "/u/", []string{"/u/energy/"})) - defer srv.Close() - hc := webdav.HTTPClientWithBasicAuth(http.DefaultClient, "u", "p") - c, err := caldav.NewClient(hc, srv.URL) - if err != nil { - t.Fatal(err) - } - - // A daily-recurring 1 h "Away" event anchored at a fixed instant so the test - // is independent of the wall clock (the window below is explicit). - anchor := time.Date(2026, time.June, 1, 9, 0, 0, 0, time.UTC) - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//ftw-test//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, "away-daily") - ev.Props.SetDateTime(ical.PropDateTimeStamp, anchor) - ev.Props.SetDateTime(ical.PropDateTimeStart, anchor) - ev.Props.SetDateTime(ical.PropDateTimeEnd, anchor.Add(time.Hour)) - ev.Props.SetText(ical.PropSummary, "Away — daily") - // RRULE must keep its default RECUR value type — SetText would tag it - // VALUE=TEXT and break parsing, which real calendar apps never do. - ev.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=DAILY;COUNT=10"}) - cal.Children = append(cal.Children, ev.Component) - if _, err := c.PutCalendarObject(context.Background(), "/u/energy/away.ics", cal); err != nil { - t.Fatalf("PUT: %v", err) - } - - // A window covering Jun 1, 2, 3 (ending just before the Jun 4 occurrence). - start := anchor.Add(-time.Hour) - end := anchor.Add(3*24*time.Hour - time.Minute) - objs, err := c.QueryCalendar(context.Background(), "/u/energy/", expandQuery(start, end)) - if err != nil { - t.Fatalf("REPORT: %v", err) - } - - instances := 0 - for _, o := range objs { - if o.Data == nil { - continue - } - for _, e := range o.Data.Events() { - instances++ - if rr, _ := e.Props.Text(ical.PropRecurrenceRule); rr != "" { - t.Fatalf("expanded instance must not carry an RRULE, got %q", rr) - } - if rid := e.Props.Get(ical.PropRecurrenceID); rid == nil { - t.Fatalf("expanded instance must carry a RECURRENCE-ID") - } - } - } - if instances != 3 { - t.Fatalf("expected 3 expanded instances in the 3-day window, got %d", instances) - } -} - -// TestExpandCalendarUnit exercises the pure expander without the HTTP layer: -// a non-recurring event passes through untouched; a recurring one fans out. -func TestExpandCalendarUnit(t *testing.T) { - anchor := time.Date(2026, time.June, 1, 9, 0, 0, 0, time.UTC) - mk := func(rrule string) *ical.Calendar { - cal := ical.NewCalendar() - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, "x") - ev.Props.SetDateTime(ical.PropDateTimeStart, anchor) - ev.Props.SetDateTime(ical.PropDateTimeEnd, anchor.Add(time.Hour)) - ev.Props.SetText(ical.PropSummary, "x") - if rrule != "" { - ev.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: rrule}) - } - cal.Children = append(cal.Children, ev.Component) - return cal - } - start, end := anchor.Add(-time.Hour), anchor.Add(3*24*time.Hour-time.Minute) - - // Non-recurring: returned unchanged (still has exactly one event). - if got := expandCalendar(mk(""), start, end); got == nil || len(got.Events()) != 1 { - t.Fatalf("non-recurring event should pass through as 1 event, got %v", got) - } - // Recurring daily: 3 instances in the window. - if got := expandCalendar(mk("FREQ=DAILY;COUNT=10"), start, end); got == nil || len(got.Events()) != 3 { - n := 0 - if got != nil { - n = len(got.Events()) - } - t.Fatalf("daily recurrence should expand to 3 instances, got %d", n) - } -} - -// mkEvent builds a timed VEVENT for the expansion edge-case tests. -func mkEvent(uid, summary string, start time.Time, dur time.Duration) *ical.Event { - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, uid) - ev.Props.SetDateTime(ical.PropDateTimeStamp, start) - ev.Props.SetDateTime(ical.PropDateTimeStart, start) - ev.Props.SetDateTime(ical.PropDateTimeEnd, start.Add(dur)) - ev.Props.SetText(ical.PropSummary, summary) - return ev -} - -// June 1 2026, 09:00 UTC and a window covering Jun 1, 2, 3 (Jun 4 excluded). -var ( - expAnchor = time.Date(2026, time.June, 1, 9, 0, 0, 0, time.UTC) - expStart = expAnchor.Add(-time.Hour) - expEnd = expAnchor.Add(3*24*time.Hour - time.Minute) -) - -func summaries(cal *ical.Calendar) []string { - if cal == nil { - return nil - } - out := []string{} - for _, e := range cal.Events() { - s, _ := e.Props.Text(ical.PropSummary) - out = append(out, s) - } - return out -} - -// TestExpandRecurrenceIDOverride: a per-instance override replaces exactly that -// occurrence (no duplicate), and the other occurrences are still generated. -func TestExpandRecurrenceIDOverride(t *testing.T) { - cal := ical.NewCalendar() - master := mkEvent("e", "Away", expAnchor, time.Hour) - master.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=DAILY;COUNT=10"}) - cal.Children = append(cal.Children, master.Component) - - ov := mkEvent("e", "Away (changed)", expAnchor.AddDate(0, 0, 1), time.Hour) // Jun 2 - ov.Props.SetDateTime(ical.PropRecurrenceID, expAnchor.AddDate(0, 0, 1)) - cal.Children = append(cal.Children, ov.Component) - - got := summaries(expandCalendar(cal, expStart, expEnd)) - if len(got) != 3 { - t.Fatalf("want 3 instances, got %d (%v)", len(got), got) - } - changed := 0 - for _, s := range got { - if s == "Away (changed)" { - changed++ - } - } - if changed != 1 { - t.Fatalf("want exactly one overridden instance, got %d (%v)", changed, got) - } -} - -// TestExpandRecurrenceIDCancellation: a STATUS:CANCELLED override deletes that -// occurrence from the set. -func TestExpandRecurrenceIDCancellation(t *testing.T) { - cal := ical.NewCalendar() - master := mkEvent("e", "Away", expAnchor, time.Hour) - master.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=DAILY;COUNT=10"}) - cal.Children = append(cal.Children, master.Component) - - cancel := mkEvent("e", "Away", expAnchor.AddDate(0, 0, 1), time.Hour) // Jun 2 - cancel.Props.SetDateTime(ical.PropRecurrenceID, expAnchor.AddDate(0, 0, 1)) - cancel.SetStatus(ical.EventCancelled) - cal.Children = append(cal.Children, cancel.Component) - - got := expandCalendar(cal, expStart, expEnd) - if n := len(got.Events()); n != 2 { - t.Fatalf("cancelled occurrence should leave 2 instances, got %d", n) - } - for _, e := range got.Events() { - if st, _ := e.DateTimeStart(time.UTC); st.Equal(expAnchor.AddDate(0, 0, 1)) { - t.Fatalf("the cancelled Jun 2 occurrence must not appear") - } - } -} - -// TestExpandEXDATE: an EXDATE removes a generated occurrence. -func TestExpandEXDATE(t *testing.T) { - cal := ical.NewCalendar() - master := mkEvent("e", "Away", expAnchor, time.Hour) - master.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=DAILY;COUNT=10"}) - master.Props.SetDateTime(ical.PropExceptionDates, expAnchor.AddDate(0, 0, 1)) // drop Jun 2 - cal.Children = append(cal.Children, master.Component) - - got := expandCalendar(cal, expStart, expEnd) - if n := len(got.Events()); n != 2 { - t.Fatalf("EXDATE should leave 2 instances (Jun 1, 3), got %d", n) - } -} - -// TestExpandRDATE: an RDATE adds an occurrence beyond the RRULE. -func TestExpandRDATE(t *testing.T) { - cal := ical.NewCalendar() - master := mkEvent("e", "Away", expAnchor, time.Hour) - master.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=DAILY;COUNT=2"}) // Jun 1, 2 - master.Props.SetDateTime(ical.PropRecurrenceDates, expAnchor.AddDate(0, 0, 2)) // add Jun 3 - cal.Children = append(cal.Children, master.Component) - - got := expandCalendar(cal, expStart, expEnd) - if n := len(got.Events()); n != 3 { - t.Fatalf("RDATE should add a third instance (Jun 1, 2, 3), got %d", n) - } -} - -func TestExpandRejectsSubHourlyRecurrence(t *testing.T) { - cal := ical.NewCalendar() - master := mkEvent("fast", "Away", expAnchor, time.Minute) - master.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=SECONDLY;COUNT=1000000"}) - cal.Children = append(cal.Children, master.Component) - - got := expandCalendar(cal, expStart, expEnd) - if got == nil || len(got.Events()) != 1 { - t.Fatalf("sub-hourly recurrence must remain one unexpanded master") - } - if got.Events()[0].Props.Get(ical.PropRecurrenceRule) == nil { - t.Fatal("unexpanded master lost its RRULE") - } -} - -func TestExpansionWindowIsBounded(t *testing.T) { - cal := ical.NewCalendar() - master := mkEvent("long", "Away", expAnchor, time.Hour) - master.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=DAILY;COUNT=100000"}) - cal.Children = append(cal.Children, master.Component) - - got := expandCalendar(cal, expAnchor, expAnchor.AddDate(10, 0, 0)) - if got == nil { - t.Fatal("bounded expansion unexpectedly returned nil") - } - if n := len(got.Events()); n > 367 { - t.Fatalf("ten-year query expanded to %d events, want at most one bounded year", n) - } -} diff --git a/go/internal/caldavserver/feed.go b/go/internal/caldavserver/feed.go deleted file mode 100644 index bf63f233..00000000 --- a/go/internal/caldavserver/feed.go +++ /dev/null @@ -1,100 +0,0 @@ -package caldavserver - -import ( - "io" - "net/http" - "strings" - - "github.com/emersion/go-ical" - - "github.com/srcfl/ftw/go/internal/state" -) - -// feedHandler serves read-only, aggregated iCalendar feeds for selected -// collections (the plan + EVSE-history calendars), so a phone can subscribe to -// them in one tap via a webcal:// link. The CalDAV protocol (go-webdav) only -// answers GET for an individual object; a calendar *subscription* needs the -// whole collection as a single text/calendar document, which this provides. -// -// feeds maps a short feed name (the URL is /feed/.ics) to the collection -// path whose objects are merged. Only read-only collections are exposed here — -// never the read-write "energy" collection FTW reads inbound intents from. -// The handler is mounted behind the same Basic auth as the rest of the server, -// so the webcal:// link carries the managed credential. -type feedHandler struct { - feeds map[string]string // name -> collection path - store Store -} - -func newFeedHandler(feeds map[string]string, store Store) http.Handler { - return &feedHandler{feeds: feeds, store: store} -} - -func (h *feedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - w.Header().Set("Allow", "GET, HEAD") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/feed/"), ".ics") - collection, ok := h.feeds[name] - if !ok { - http.NotFound(w, r) - return - } - objs, err := h.store.ListCalDAVObjects(collection) - if err != nil { - http.Error(w, "feed unavailable", http.StatusInternalServerError) - return - } - body, err := encodeFeed(objs) - if err != nil { - http.Error(w, "feed encode failed", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/calendar; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache") - if r.Method == http.MethodHead { - return - } - _, _ = io.WriteString(w, body) -} - -// emptyCalendar is a valid, empty VCALENDAR. go-ical refuses to encode a -// calendar with no components, but an empty subscription feed (no plan/history -// events yet) is a legitimate state a client must still be able to fetch. -const emptyCalendar = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//FTW//CalDAV feed//EN\r\nEND:VCALENDAR\r\n" - -// encodeFeed serializes the collection's events into one text/calendar body. -func encodeFeed(objs []state.CalDAVObject) (string, error) { - feed := mergeFeed(objs) - if len(feed.Children) == 0 { - return emptyCalendar, nil - } - var sb strings.Builder - if err := ical.NewEncoder(&sb).Encode(feed); err != nil { - return "", err - } - return sb.String(), nil -} - -// mergeFeed flattens the VEVENTs from every stored object in a collection into -// one VCALENDAR. An object that fails to parse is skipped — one bad object must -// not take down the whole feed. -func mergeFeed(objs []state.CalDAVObject) *ical.Calendar { - out := ical.NewCalendar() - out.Props.SetText(ical.PropVersion, "2.0") - out.Props.SetText(ical.PropProductID, "-//FTW//CalDAV feed//EN") - for _, o := range objs { - cal, err := ical.NewDecoder(strings.NewReader(o.Data)).Decode() - if err != nil { - continue - } - for _, child := range cal.Children { - if child.Name == ical.CompEvent { - out.Children = append(out.Children, child) - } - } - } - return out -} diff --git a/go/internal/caldavserver/feed_test.go b/go/internal/caldavserver/feed_test.go deleted file mode 100644 index 1d9f6f82..00000000 --- a/go/internal/caldavserver/feed_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package caldavserver - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/emersion/go-ical" - - "github.com/srcfl/ftw/go/internal/state" -) - -const feedObj = `BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//ftw-test//EN -BEGIN:VEVENT -UID:%s -DTSTAMP:20260101T000000Z -DTSTART:20260101T120000Z -DTEND:20260101T130000Z -SUMMARY:%s -END:VEVENT -END:VCALENDAR -` - -// feedFixture returns a server handler whose /u/plan/ collection holds two -// objects, exposed as the read-only feed /feed/plan.ics. -func feedFixture(t *testing.T) http.Handler { - t.Helper() - store := NewMemStore() - for _, e := range []struct{ uid, sum string }{{"plan-1", "Charge window"}, {"plan-2", "Discharge window"}} { - if err := store.SaveCalDAVObject(state.CalDAVObject{ - Path: "/u/plan/" + e.uid + ".ics", - Collection: "/u/plan/", - Data: fmt.Sprintf(feedObj, e.uid, e.sum), - }); err != nil { - t.Fatalf("save: %v", err) - } - } - return NewHandler("u", "p", "/u/", []string{"/u/plan/"}, store, WithFeeds(map[string]string{"plan": "/u/plan/"})) -} - -func TestFeedAggregatesCollection(t *testing.T) { - srv := httptest.NewServer(feedFixture(t)) - defer srv.Close() - - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/feed/plan.ics", nil) - req.SetBasicAuth("u", "p") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("get: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") { - t.Fatalf("content-type = %q, want text/calendar", ct) - } - body, _ := io.ReadAll(resp.Body) - cal, err := ical.NewDecoder(strings.NewReader(string(body))).Decode() - if err != nil { - t.Fatalf("decode feed: %v", err) - } - var events int - for _, c := range cal.Children { - if c.Name == ical.CompEvent { - events++ - } - } - if events != 2 { - t.Fatalf("feed has %d VEVENTs, want 2", events) - } -} - -func TestFeedRequiresAuth(t *testing.T) { - srv := httptest.NewServer(feedFixture(t)) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/feed/plan.ics") - if err != nil { - t.Fatalf("get: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("status = %d, want 401 without auth", resp.StatusCode) - } -} - -func TestFeedUnknownNameIs404(t *testing.T) { - srv := httptest.NewServer(feedFixture(t)) - defer srv.Close() - - req, _ := http.NewRequest(http.MethodGet, srv.URL+"/feed/energy.ics", nil) - req.SetBasicAuth("u", "p") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("get: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNotFound { - t.Fatalf("status = %d, want 404 for an unmapped feed", resp.StatusCode) - } -} diff --git a/go/internal/caldavserver/server.go b/go/internal/caldavserver/server.go deleted file mode 100644 index 969caba1..00000000 --- a/go/internal/caldavserver/server.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package caldavserver is FTW's native, in-process CalDAV server built on -// github.com/emersion/go-webdav (MIT). Being pure-Go and in-process, it ships -// in the single FTW binary and needs no second container — so the calendar -// feature (#498) runs everywhere FTW does, including a single-container Home -// Assistant add-on. -// -// FTW's calendar client (internal/calendar) talks CalDAV to it over localhost, -// so the inbound/outbound intent logic is independent of transport. -// -// Objects persist via a Store (state.db in production; in-memory for tests). -// Recurring events ARE expanded server-side per RFC 4791 CALDAV:expand (see -// expand.go). Known limits: a single principal and minimal MKCALENDAR/sync -// semantics; interop is verified against FTW's own go-webdav client rather than -// the full matrix of iOS / Google / Thunderbird. -package caldavserver - -import ( - "context" - "crypto/subtle" - "log/slog" - "net/http" - "time" - - "github.com/emersion/go-webdav/caldav" -) - -// Server is the native CalDAV HTTP server: a go-webdav caldav.Handler behind -// HTTP Basic auth, on its own listener (default :5232). -type Server struct { - addr string - httpSrv *http.Server -} - -const maxRequestBytes = 4 << 20 // calendar objects / REPORT bodies; bound Pi memory use - -// Option configures optional server behaviour without breaking the core -// New / NewHandler signatures. -type Option func(*options) - -type options struct { - feeds map[string]string // feed name -> collection path (read-only .ics feeds) -} - -// WithFeeds exposes read-only aggregated .ics feeds at /feed/.ics for the -// given collections (e.g. {"plan": "/u/plan/", "history": "/u/history/"}), so a -// phone can subscribe via a one-tap webcal:// link. Served behind the same -// Basic auth as the rest of the server. -func WithFeeds(feeds map[string]string) Option { - return func(o *options) { o.feeds = feeds } -} - -func buildOptions(opts []Option) options { - var o options - for _, fn := range opts { - fn(&o) - } - return o -} - -// New builds the server. principal is the CalDAV principal path (e.g. -// "/fortytwowatts/"); calendarPaths are collections to pre-create; store is the -// persistence (pass *state.Store for durability, or nil for in-memory). -func New(addr, username, password, principal string, calendarPaths []string, store Store, opts ...Option) *Server { - return &Server{ - addr: addr, - httpSrv: &http.Server{ - Addr: addr, - Handler: NewHandler(username, password, principal, calendarPaths, store, opts...), - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 60 * time.Second, - }, - } -} - -// NewHandler builds the auth-wrapped CalDAV http.Handler. Exposed so callers -// (and tests) can mount the native server on an existing mux / httptest server. -func NewHandler(username, password, principal string, calendarPaths []string, store Store, opts ...Option) http.Handler { - o := buildOptions(opts) - mux := http.NewServeMux() - if len(o.feeds) > 0 { - mux.Handle("/feed/", basicAuth(username, password, newFeedHandler(o.feeds, store))) - } - mux.Handle("/", basicAuth(username, password, &caldav.Handler{Backend: newBackend(principal, calendarPaths, store)})) - return http.MaxBytesHandler(mux, maxRequestBytes) -} - -// basicAuth gates the handler with a constant-time Basic-auth check. An empty -// configured password rejects everything (fail-closed) so a missing managed -// credential never opens the calendar. -func basicAuth(username, password string, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - u, p, ok := r.BasicAuth() - authed := ok && password != "" && - subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 && - subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 - if !authed { - w.Header().Set("WWW-Authenticate", `Basic realm="FTW"`) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -// Start begins serving in a background goroutine. -func (s *Server) Start() { - go func() { - slog.Info("caldav: native CalDAV server listening", "addr", s.addr) - if err := s.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("caldav: native server stopped", "err", err) - } - }() -} - -// Stop shuts the server down. -func (s *Server) Stop() { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - _ = s.httpSrv.Shutdown(ctx) -} diff --git a/go/internal/caldavserver/server_test.go b/go/internal/caldavserver/server_test.go deleted file mode 100644 index e080419d..00000000 --- a/go/internal/caldavserver/server_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package caldavserver - -import ( - "context" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" - - "github.com/srcfl/ftw/go/internal/state" -) - -func testHandler(user, pass, principal string, cals []string) http.Handler { - return NewHandler(user, pass, principal, cals, NewMemStore()) -} - -func query() *caldav.CalendarQuery { - return &caldav.CalendarQuery{ - CompRequest: caldav.CalendarCompRequest{Name: "VCALENDAR", Comps: []caldav.CalendarCompRequest{{Name: "VEVENT", AllProps: true}}}, - CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{Name: "VEVENT"}}}, - } -} - -func putEvent(t *testing.T, c *caldav.Client, path, uid, summary string, start, end time.Time) { - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//ftw-test//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, uid) - ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) - ev.Props.SetDateTime(ical.PropDateTimeStart, start) - ev.Props.SetDateTime(ical.PropDateTimeEnd, end) - ev.Props.SetText(ical.PropSummary, summary) - cal.Children = append(cal.Children, ev.Component) - if _, err := c.PutCalendarObject(context.Background(), path, cal); err != nil { - t.Fatalf("PUT %s: %v", path, err) - } -} - -// TestNativeServerRoundTrip drives the in-process CalDAV server with FTW's own -// go-webdav client: PUT an event, read it back via a calendar-query REPORT, -// then DELETE it. This is exactly the inbound/outbound path the calendar -// service uses. -func TestNativeServerRoundTrip(t *testing.T) { - srv := httptest.NewServer(testHandler("u", "p", "/u/", []string{"/u/energy/"})) - defer srv.Close() - hc := webdav.HTTPClientWithBasicAuth(http.DefaultClient, "u", "p") - c, err := caldav.NewClient(hc, srv.URL) - if err != nil { - t.Fatal(err) - } - now := time.Now() - - putEvent(t, c, "/u/energy/away.ics", "away", "Away — test", now.Add(time.Hour), now.Add(25*time.Hour)) - - objs, err := c.QueryCalendar(context.Background(), "/u/energy/", query()) - if err != nil { - t.Fatalf("REPORT: %v", err) - } - if len(objs) != 1 || objs[0].Data == nil || len(objs[0].Data.Events()) != 1 { - t.Fatalf("expected 1 event, got %d objects", len(objs)) - } - if sum, _ := objs[0].Data.Events()[0].Props.Text(ical.PropSummary); sum != "Away — test" { - t.Fatalf("summary round-trip wrong: %q", sum) - } - - // DELETE via plain WebDAV (the caldav client has no delete) — same path the - // plan reconciler uses. - wc, _ := webdav.NewClient(hc, srv.URL) - if err := wc.RemoveAll(context.Background(), "/u/energy/away.ics"); err != nil { - t.Fatalf("DELETE: %v", err) - } - objs2, err := c.QueryCalendar(context.Background(), "/u/energy/", query()) - if err != nil { - t.Fatalf("REPORT after delete: %v", err) - } - if len(objs2) != 0 { - t.Fatalf("expected 0 events after delete, got %d", len(objs2)) - } -} - -// TestNativeServerPersistsAcrossRestart proves durability with the state.db -// backend: write an event, close the DB, reopen it (a "restart"), and confirm -// the event is still served. -func TestNativeServerPersistsAcrossRestart(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "state.db") - st, err := state.Open(dbPath) - if err != nil { - t.Fatal(err) - } - srv := httptest.NewServer(NewHandler("u", "p", "/u/", []string{"/u/energy/"}, st)) - hc := webdav.HTTPClientWithBasicAuth(http.DefaultClient, "u", "p") - c, _ := caldav.NewClient(hc, srv.URL) - now := time.Now() - putEvent(t, c, "/u/energy/away.ics", "away", "Away — persisted", now.Add(time.Hour), now.Add(2*time.Hour)) - srv.Close() - st.Close() - - // "Restart": reopen the same DB and stand the server back up. - st2, err := state.Open(dbPath) - if err != nil { - t.Fatal(err) - } - defer st2.Close() - srv2 := httptest.NewServer(NewHandler("u", "p", "/u/", []string{"/u/energy/"}, st2)) - defer srv2.Close() - c2, _ := caldav.NewClient(hc, srv2.URL) - - objs, err := c2.QueryCalendar(context.Background(), "/u/energy/", query()) - if err != nil { - t.Fatalf("REPORT after restart: %v", err) - } - if len(objs) != 1 { - t.Fatalf("event did not survive restart: got %d objects", len(objs)) - } - if sum, _ := objs[0].Data.Events()[0].Props.Text(ical.PropSummary); sum != "Away — persisted" { - t.Fatalf("persisted summary wrong: %q", sum) - } -} - -func TestNativeServerAuthRejectsBadCreds(t *testing.T) { - srv := httptest.NewServer(testHandler("u", "p", "/u/", []string{"/u/energy/"})) - defer srv.Close() - req, _ := http.NewRequest("PROPFIND", srv.URL+"/u/energy/", nil) - req.SetBasicAuth("u", "wrong") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("bad creds: want 401, got %d", resp.StatusCode) - } -} - -func TestNativeServerEmptyPasswordFailsClosed(t *testing.T) { - srv := httptest.NewServer(testHandler("u", "", "/u/", []string{"/u/energy/"})) - defer srv.Close() - req, _ := http.NewRequest("PROPFIND", srv.URL+"/u/energy/", nil) - req.SetBasicAuth("u", "") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("empty configured password must reject all (fail-closed), got %d", resp.StatusCode) - } -} diff --git a/go/internal/caldavserver/store.go b/go/internal/caldavserver/store.go deleted file mode 100644 index 98272a1f..00000000 --- a/go/internal/caldavserver/store.go +++ /dev/null @@ -1,85 +0,0 @@ -package caldavserver - -import ( - "sync" - - "github.com/srcfl/ftw/go/internal/state" -) - -// Store is the persistence the native CalDAV server needs. *state.Store -// satisfies it (durable, in state.db); NewMemStore is an in-memory impl for -// tests and the no-database fallback. -type Store interface { - SaveCalDAVObject(o state.CalDAVObject) error - GetCalDAVObject(path string) (state.CalDAVObject, bool, error) - ListCalDAVObjects(collection string) ([]state.CalDAVObject, error) - DeleteCalDAVObject(path string) error - SaveCalDAVCalendar(c state.CalDAVCalendar) error - ListCalDAVCalendars() ([]state.CalDAVCalendar, error) -} - -// memStore is an in-memory Store (non-durable). Used by tests and when no -// state DB is wired. -type memStore struct { - mu sync.RWMutex - objs map[string]state.CalDAVObject - cals map[string]state.CalDAVCalendar -} - -// NewMemStore returns a non-persistent in-memory Store. -func NewMemStore() Store { - return &memStore{ - objs: map[string]state.CalDAVObject{}, - cals: map[string]state.CalDAVCalendar{}, - } -} - -func (m *memStore) SaveCalDAVObject(o state.CalDAVObject) error { - m.mu.Lock() - defer m.mu.Unlock() - m.objs[o.Path] = o - return nil -} - -func (m *memStore) GetCalDAVObject(path string) (state.CalDAVObject, bool, error) { - m.mu.RLock() - defer m.mu.RUnlock() - o, ok := m.objs[path] - return o, ok, nil -} - -func (m *memStore) ListCalDAVObjects(collection string) ([]state.CalDAVObject, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var out []state.CalDAVObject - for _, o := range m.objs { - if o.Collection == collection { - out = append(out, o) - } - } - return out, nil -} - -func (m *memStore) DeleteCalDAVObject(path string) error { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.objs, path) - return nil -} - -func (m *memStore) SaveCalDAVCalendar(c state.CalDAVCalendar) error { - m.mu.Lock() - defer m.mu.Unlock() - m.cals[c.Path] = c - return nil -} - -func (m *memStore) ListCalDAVCalendars() ([]state.CalDAVCalendar, error) { - m.mu.RLock() - defer m.mu.RUnlock() - out := make([]state.CalDAVCalendar, 0, len(m.cals)) - for _, c := range m.cals { - out = append(out, c) - } - return out, nil -} diff --git a/go/internal/calendar/doc.go b/go/internal/calendar/doc.go deleted file mode 100644 index e8195fcc..00000000 --- a/go/internal/calendar/doc.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package calendar consumes calendar events as planner constraints (issue -// #498). FTW runs a CalDAV *client* against its own in-process -// native CalDAV server (internal/caldavserver) — it polls a calendar -// collection and maps events into intents the planner already knows how to -// honour: -// -// - an "away"/vacation event switches the load model to its away profile for -// the interval (loadmodel.ProfileAway, ~25% load) so the MPC conserves -// battery while the house is empty; IsAwayAt lets the load predictor apply -// the away profile per slot across the planning horizon; -// - an EV "charged-by-departure" event sets the matching loadpoint's target -// SoC + deadline (loadpoint.Manager.SetTarget), which the MPC loadpoint -// probe already reads and enforces. -// -// Events are classified by case-insensitive keyword match on the event title -// (SUMMARY), e.g. "Away" / "Vacation" or "Charge car 80%". Keyword lists are -// configurable (config.CalDAV) so non-English calendars work; an explicit -// "lp:" token and an "%" target are honoured when present. -// -// The whole feature is opt-in (config.CalDAV.Enabled) and fail-soft: an -// unreachable server logs a warning and leaves control untouched. Recurrences -// are expanded server-side via the CalDAV calendar-query Expand element, so no -// RRULE math lives here. -package calendar diff --git a/go/internal/calendar/e2e_test.go b/go/internal/calendar/e2e_test.go deleted file mode 100644 index 91e0a492..00000000 --- a/go/internal/calendar/e2e_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package calendar - -import ( - "path/filepath" - "testing" - "time" - - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/loadmodel" - "github.com/srcfl/ftw/go/internal/loadpoint" - "github.com/srcfl/ftw/go/internal/state" -) - -// TestEndToEndRealCollaborators drives the calendar service's intent -// application against the REAL loadmodel.Service and loadpoint.Manager (no -// fakes), so a regression in the wiring — wrong profile constant, mis-plumbed -// loadpoint id — is caught. The CalDAV wire itself (REPORT/Expand/PUT/DELETE) -// is covered by the caldav_it-tagged integration test against a real CalDAV -// server; this is the CI-safe complement that proves the planner-side effects. -func TestEndToEndRealCollaborators(t *testing.T) { - st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) - if err != nil { - t.Fatalf("open state: %v", err) - } - defer st.Close() - - loadSvc := loadmodel.NewService(st, nil, "", 5000, 17250) - lpMgr := loadpoint.NewManager() - lpMgr.Load([]loadpoint.Config{{ID: "garage", VehicleCapacityWh: 60000}}) - - s := New(config.CalDAV{Enabled: true}, lpMgr, loadSvc, "garage") - now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - - // --- Away: the real load model switches to its away profile, and the - // away-profile prediction is lower than home (planner conserves battery). --- - homeBefore := loadSvc.PredictWith(now, loadmodel.ProfileHome) - s.apply(Intents{Away: []Interval{{Start: now.Add(-time.Hour), End: now.Add(time.Hour)}}}, now) - if loadSvc.Profile() != loadmodel.ProfileAway { - t.Fatalf("expected real load model to switch to away, got %q", loadSvc.Profile()) - } - if awayPred := loadSvc.PredictWith(now, loadmodel.ProfileAway); !(awayPred < homeBefore) { - t.Fatalf("away prediction (%.0f W) should be below home (%.0f W)", awayPred, homeBefore) - } - - // Leaving the away window restores home on the real model. - s.apply(Intents{}, now) - if loadSvc.Profile() != loadmodel.ProfileHome { - t.Fatalf("expected home profile after the away window, got %q", loadSvc.Profile()) - } - - // --- EV: the real loadpoint manager receives the target SoC + deadline, - // which is exactly what the MPC loadpoint probe reads. --- - dep := now.Add(3 * time.Hour) - s.apply(Intents{EV: []EVDeadline{{LoadpointID: "garage", TargetSoC: 0.8, Departure: dep}}}, now) - lpState, ok := lpMgr.State("garage") - if !ok { - t.Fatal("garage loadpoint not found") - } - if lpState.TargetSoC != 0.8 { - t.Fatalf("loadpoint target SoC: want 80, got %v", lpState.TargetSoC) - } - if !lpState.TargetTime.Equal(dep) { - t.Fatalf("loadpoint target time: want %v, got %v", dep, lpState.TargetTime) - } -} diff --git a/go/internal/calendar/history.go b/go/internal/calendar/history.go deleted file mode 100644 index 40efff08..00000000 --- a/go/internal/calendar/history.go +++ /dev/null @@ -1,209 +0,0 @@ -package calendar - -import ( - "context" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/emersion/go-ical" -) - -// EVSample is a point-in-time observation of one EV charge point, supplied by -// the host (main.go reads it off telemetry). A charging→idle (or unplug) -// transition becomes a history VEVENT. -type EVSample struct { - ID string // stable per charge point (driver / loadpoint id) - Connected bool // plug present - Charging bool // actively delivering power - SessionWh float64 // driver-reported session energy, 0 if unknown - PowerW float64 // current charge power (integration fallback) -} - -// EVSource returns the current EV samples, one per charge point. -type EVSource func() []EVSample - -// CompletedSession is a finished charge used to author a history event. -type CompletedSession struct { - ID string - Start time.Time - End time.Time - EnergyWh float64 -} - -// evTrack is the per-charge-point session state machine. -type evTrack struct { - active bool - start time.Time - lastSeen time.Time - baseWh float64 // SessionWh at session start - lastWh float64 // most recent SessionWh seen while charging - integratedWh float64 // ∫ power·dt fallback for drivers without SessionWh -} - -// Noise filter — ignore contact bounce / trivial top-ups. -const ( - minSessionDur = 60 * time.Second - minSessionEnergy = 100.0 // Wh -) - -// observeEV folds a batch of samples into the per-charge-point trackers and -// returns the sessions that just completed. It is pure with respect to the -// network — the caller writes the returned sessions. Not safe for concurrent -// use; the history loop is the single caller. -func (s *Service) observeEV(samples []EVSample, now time.Time) []CompletedSession { - var done []CompletedSession - seen := make(map[string]bool, len(samples)) - - for _, smp := range samples { - if smp.ID == "" { - continue - } - seen[smp.ID] = true - t := s.ev[smp.ID] - if t == nil { - t = &evTrack{} - s.ev[smp.ID] = t - } - charging := smp.Charging && smp.Connected - - if charging && !t.active { - // Session start. - t.active = true - t.start = now - t.baseWh = smp.SessionWh - t.lastWh = smp.SessionWh - t.integratedWh = 0 - t.lastSeen = now - continue - } - - if t.active { - if !t.lastSeen.IsZero() { - // Integrate, ignoring long gaps (sleep / restart) that would - // otherwise inflate the estimate. - if dtH := now.Sub(t.lastSeen).Hours(); dtH > 0 && dtH < 1 { - t.integratedWh += smp.PowerW * dtH - } - } - t.lastSeen = now - if smp.SessionWh > 0 { - t.lastWh = smp.SessionWh - } - if !charging { // stopped charging or unplugged - if cs, ok := finishSession(smp.ID, t, now); ok { - done = append(done, cs) - } - } - } - } - - // A tracked charge point that vanished entirely (driver offline) ends its - // session at the last time we saw it. - for id, t := range s.ev { - if t.active && !seen[id] { - if cs, ok := finishSession(id, t, t.lastSeen); ok { - done = append(done, cs) - } - } - } - return done -} - -// finishSession closes a track and decides whether it is worth recording. -func finishSession(id string, t *evTrack, end time.Time) (CompletedSession, bool) { - t.active = false - energy := t.lastWh - t.baseWh - if energy <= 0 { - energy = t.integratedWh - } - if end.Sub(t.start) < minSessionDur || energy < minSessionEnergy { - return CompletedSession{}, false - } - return CompletedSession{ID: id, Start: t.start, End: end, EnergyWh: energy}, true -} - -// evHistoryLoop samples EV state on a fast ticker and writes a history VEVENT -// for each completed session. Fail-soft: a write error is logged, the session -// is dropped, control is never blocked. -func (s *Service) evHistoryLoop(ctx context.Context) { - t := time.NewTicker(s.evSampleInterval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-s.stop: - return - case <-t.C: - s.mu.RLock() - src := s.evSource - s.mu.RUnlock() - if src == nil { - continue - } - for _, cs := range s.observeEV(src(), time.Now()) { - if err := s.writeSession(ctx, cs); err != nil { - slog.Warn("caldav: failed to write EV history event", "id", cs.ID, "err", err) - continue - } - s.mu.Lock() - s.historyWritten++ - s.lastHistoryMs = time.Now().UnixMilli() - s.mu.Unlock() - slog.Info("caldav: wrote EV history event", - "id", cs.ID, "energy_wh", cs.EnergyWh, - "start", cs.Start, "end", cs.End) - } - } - } -} - -// writeSession PUTs a single VEVENT describing a completed charge into the -// history collection. The UID is stable per (charge point, session start) so a -// retried write is idempotent rather than duplicating the event. -func (s *Service) writeSession(ctx context.Context, cs CompletedSession) error { - s.mu.RLock() - url, histPath, user, pass := s.url, s.historyPath, s.username, s.password - s.mu.RUnlock() - - client, err := s.newClient(url, user, pass) - if err != nil { - return err - } - - uid := fmt.Sprintf("ftw-ev-%s-%d@fortytwowatts", sanitizeUID(cs.ID), cs.Start.Unix()) - - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//FTW//EV history//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, uid) - ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) - ev.Props.SetDateTime(ical.PropDateTimeStart, cs.Start) - ev.Props.SetDateTime(ical.PropDateTimeEnd, cs.End) - ev.Props.SetText(ical.PropSummary, fmt.Sprintf("EV charged %.1f kWh", cs.EnergyWh/1000)) - ev.Props.SetText(ical.PropDescription, fmt.Sprintf( - "FTW: %s delivered %.0f Wh over %s.", - cs.ID, cs.EnergyWh, cs.End.Sub(cs.Start).Round(time.Minute))+ - lanNote("adds a new event here after each completed charge session")) - cal.Children = append(cal.Children, ev.Component) - - objPath := strings.TrimRight(histPath, "/") + "/" + uid + ".ics" - _, err = client.PutCalendarObject(ctx, objPath, cal) - return err -} - -// sanitizeUID keeps a charge-point id safe for a CalDAV object path / UID. -func sanitizeUID(id string) string { - return strings.Map(func(r rune) rune { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': - return r - default: - return '-' - } - }, id) -} diff --git a/go/internal/calendar/history_test.go b/go/internal/calendar/history_test.go deleted file mode 100644 index 5ad7fd23..00000000 --- a/go/internal/calendar/history_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package calendar - -import ( - "testing" - "time" -) - -func TestEVSessionRecordedFromSessionWh(t *testing.T) { - s := newTestService(&fakeLP{}, &fakeLM{}) - t0 := time.Date(2026, 7, 1, 18, 0, 0, 0, time.UTC) - - if d := s.observeEV([]EVSample{{ID: "easee", Connected: true, Charging: true, SessionWh: 0, PowerW: 11000}}, t0); len(d) != 0 { - t.Fatalf("no completion expected at session start, got %d", len(d)) - } - s.observeEV([]EVSample{{ID: "easee", Connected: true, Charging: true, SessionWh: 5500, PowerW: 11000}}, t0.Add(30*time.Minute)) - done := s.observeEV([]EVSample{{ID: "easee", Connected: true, Charging: false, SessionWh: 5500, PowerW: 0}}, t0.Add(31*time.Minute)) - - if len(done) != 1 { - t.Fatalf("expected 1 completed session, got %d", len(done)) - } - if done[0].EnergyWh != 5500 { - t.Fatalf("energy: want 5500, got %v", done[0].EnergyWh) - } - if !done[0].Start.Equal(t0) || !done[0].End.Equal(t0.Add(31*time.Minute)) { - t.Fatalf("session bounds wrong: %+v", done[0]) - } - if done[0].ID != "easee" { - t.Fatalf("id not carried: %q", done[0].ID) - } -} - -func TestEVShortSessionFiltered(t *testing.T) { - s := newTestService(&fakeLP{}, &fakeLM{}) - t0 := time.Now() - s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: true, PowerW: 11000}}, t0) - done := s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: false, SessionWh: 5000}}, t0.Add(10*time.Second)) - if len(done) != 0 { - t.Fatalf("sub-minute session should be filtered, got %d", len(done)) - } -} - -func TestEVLowEnergyFiltered(t *testing.T) { - s := newTestService(&fakeLP{}, &fakeLM{}) - t0 := time.Now() - s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: true, PowerW: 10}}, t0) - done := s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: false, SessionWh: 50}}, t0.Add(5*time.Minute)) - if len(done) != 0 { - t.Fatalf("sub-100Wh session should be filtered, got %d", len(done)) - } -} - -func TestEVUnplugEndsSession(t *testing.T) { - s := newTestService(&fakeLP{}, &fakeLM{}) - t0 := time.Now() - s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: true, PowerW: 11000}}, t0) - done := s.observeEV([]EVSample{{ID: "x", Connected: false, Charging: false, SessionWh: 7000}}, t0.Add(20*time.Minute)) - if len(done) != 1 || done[0].EnergyWh != 7000 { - t.Fatalf("unplug should finish the session: %+v", done) - } -} - -func TestEVDriverDisappearEndsSession(t *testing.T) { - s := newTestService(&fakeLP{}, &fakeLM{}) - t0 := time.Now() - s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: true, PowerW: 11000}}, t0) - s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: true, SessionWh: 6000, PowerW: 11000}}, t0.Add(30*time.Minute)) - done := s.observeEV(nil, t0.Add(31*time.Minute)) // driver gone entirely - if len(done) != 1 || done[0].EnergyWh != 6000 { - t.Fatalf("driver disappearance should finish the session: %+v", done) - } -} - -func TestEVIntegrationFallback(t *testing.T) { - // Driver reports no SessionWh → energy comes from ∫ power·dt. Steps stay - // under the 1h gap guard so the integration accumulates. - s := newTestService(&fakeLP{}, &fakeLM{}) - t0 := time.Now() - s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: true, PowerW: 6000}}, t0) - s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: true, PowerW: 6000}}, t0.Add(30*time.Minute)) // +3000 Wh - done := s.observeEV([]EVSample{{ID: "x", Connected: true, Charging: false, PowerW: 0}}, t0.Add(45*time.Minute)) - if len(done) != 1 { - t.Fatalf("expected 1 session, got %d", len(done)) - } - if done[0].EnergyWh < 2500 || done[0].EnergyWh > 3500 { - t.Fatalf("integration fallback energy out of expected range: %v", done[0].EnergyWh) - } -} - -func TestWriteSessionUIDStable(t *testing.T) { - // sanitizeUID keeps the path safe and the UID deterministic per session. - if got := sanitizeUID("garage/1 left"); got != "garage-1-left" { - t.Fatalf("sanitizeUID: got %q", got) - } -} diff --git a/go/internal/calendar/integration_test.go b/go/internal/calendar/integration_test.go deleted file mode 100644 index fcdf44c7..00000000 --- a/go/internal/calendar/integration_test.go +++ /dev/null @@ -1,197 +0,0 @@ -//go:build caldav_it - -// Integration test against an external CalDAV server. Excluded from the -// normal build; run with a server available (point it at any CalDAV URL): -// -// FTW_CALDAV_IT_URL=http://localhost:5232 \ -// FTW_CALDAV_IT_USER=ituser FTW_CALDAV_IT_PASS=itpass \ -// go test -tags caldav_it ./internal/calendar/ -run TestCalDAVIntegration -v -package calendar - -import ( - "context" - "net/http" - "os" - "strings" - "testing" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" - - "github.com/srcfl/ftw/go/internal/config" -) - -func itEnv(t *testing.T) (url, user, pass string) { - url = os.Getenv("FTW_CALDAV_IT_URL") - user = os.Getenv("FTW_CALDAV_IT_USER") - pass = os.Getenv("FTW_CALDAV_IT_PASS") - if url == "" { - t.Skip("set FTW_CALDAV_IT_URL to run the CalDAV integration test") - } - return -} - -func itClient(url, user, pass string) *caldav.Client { - hc := webdav.HTTPClientWithBasicAuth(&http.Client{Timeout: 15 * time.Second}, user, pass) - c, _ := caldav.NewClient(hc, url) - return c -} - -// mkcalendar ensures a calendar collection exists (idempotent). -func mkcalendar(t *testing.T, url, user, pass, path string) { - req, _ := http.NewRequest("MKCALENDAR", strings.TrimRight(url, "/")+path, nil) - req.SetBasicAuth(user, pass) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("MKCALENDAR %s: %v", path, err) - } - resp.Body.Close() - // 201 created, or 405/409 already exists — all fine. -} - -func putEvent(t *testing.T, c *caldav.Client, path, uid, summary string, start, end time.Time) { - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//ftw-it//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, uid) - ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) - ev.Props.SetDateTime(ical.PropDateTimeStart, start) - ev.Props.SetDateTime(ical.PropDateTimeEnd, end) - ev.Props.SetText(ical.PropSummary, summary) - cal.Children = append(cal.Children, ev.Component) - if _, err := c.PutCalendarObject(context.Background(), strings.TrimRight(path, "/")+"/"+uid+".ics", cal); err != nil { - t.Fatalf("PUT %s: %v", uid, err) - } -} - -func TestCalDAVIntegration(t *testing.T) { - url, user, pass := itEnv(t) - energyPath := "/" + user + "/energy/" - historyPath := "/" + user + "/history/" - c := itClient(url, user, pass) - - mkcalendar(t, url, user, pass, energyPath) - mkcalendar(t, url, user, pass, historyPath) - - now := time.Now() - putEvent(t, c, energyPath, "it-away", "Away — IT", now.Add(1*time.Hour), now.Add(25*time.Hour)) - putEvent(t, c, energyPath, "it-ev", "Charge car 80%", now.Add(2*time.Hour), now.Add(3*time.Hour)) - - // ---- Inbound: fetch + classify ---- - s := New(config.CalDAV{ - Enabled: true, URL: url, Username: user, Password: pass, - CalendarPath: energyPath, HistoryPath: historyPath, - }, &fakeLP{}, &fakeLM{}, "garage") - - intents, err := s.fetch(context.Background()) - if err != nil { - t.Fatalf("fetch: %v", err) - } - if len(intents.Away) != 1 { - t.Fatalf("expected 1 away interval, got %d (%+v)", len(intents.Away), intents.Away) - } - if len(intents.EV) != 1 { - t.Fatalf("expected 1 EV deadline, got %d (%+v)", len(intents.EV), intents.EV) - } - if intents.EV[0].TargetSoC != 0.8 { - t.Fatalf("EV target: want 80, got %v", intents.EV[0].TargetSoC) - } - t.Logf("inbound OK: away=%+v ev=%+v", intents.Away[0], intents.EV[0]) - - // ---- Outbound: write a session, then read it back raw ---- - sess := CompletedSession{ID: "easee", Start: now.Add(-2 * time.Hour), End: now.Add(-30 * time.Minute), EnergyWh: 12300} - if err := s.writeSession(context.Background(), sess); err != nil { - t.Fatalf("writeSession: %v", err) - } - - objs, err := c.QueryCalendar(context.Background(), historyPath, &caldav.CalendarQuery{ - CompRequest: caldav.CalendarCompRequest{Name: "VCALENDAR", Comps: []caldav.CalendarCompRequest{{Name: "VEVENT", AllProps: true}}}, - CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{Name: "VEVENT"}}}, - }) - if err != nil { - t.Fatalf("read back history: %v", err) - } - found := false - for _, o := range objs { - if o.Data == nil { - continue - } - for _, ev := range o.Data.Events() { - sum, _ := ev.Props.Text(ical.PropSummary) - if strings.Contains(sum, "EV charged") { - found = true - t.Logf("outbound OK: history event %q", sum) - } - } - } - if !found { - t.Fatalf("history event not found in %s", historyPath) - } -} - -func countEvents(t *testing.T, c *caldav.Client, path, substr string) int { - objs, err := c.QueryCalendar(context.Background(), path, &caldav.CalendarQuery{ - CompRequest: caldav.CalendarCompRequest{Name: "VCALENDAR", Comps: []caldav.CalendarCompRequest{{Name: "VEVENT", AllProps: true}}}, - CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{Name: "VEVENT"}}}, - }) - if err != nil { - t.Fatalf("query %s: %v", path, err) - } - n := 0 - for _, o := range objs { - if o.Data == nil { - continue - } - for _, ev := range o.Data.Events() { - if sum, _ := ev.Props.Text(ical.PropSummary); strings.Contains(sum, substr) { - n++ - } - } - } - return n -} - -func TestCalDAVPlanPublish(t *testing.T) { - url, user, pass := itEnv(t) - planPath := "/" + user + "/plan/" - mkcalendar(t, url, user, pass, planPath) - c := itClient(url, user, pass) - now := time.Now() - - s := New(config.CalDAV{ - Enabled: true, URL: url, Username: user, Password: pass, - CalendarPath: "/" + user + "/energy/", PlanPath: planPath, - }, &fakeLP{}, &fakeLM{}, "garage") - - // Plan v1: a charge window in the near future (two consecutive slots). - s.SetPlanSource(func() []PlanSlot { - return []PlanSlot{ - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), BatteryW: 4000, SoC: 0.6}, - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), BatteryW: 4000, SoC: 0.8}, - } - }) - s.publishPlan(context.Background()) - if n := countEvents(t, c, planPath, "Charge battery"); n != 1 { - t.Fatalf("after v1: want 1 charge event, got %d", n) - } - t.Logf("plan publish OK: 1 charge window written") - - // Plan v2: the charge window is gone, replaced by a discharge window. The - // reconcile must DELETE the stale charge event and PUT the discharge one. - s.SetPlanSource(func() []PlanSlot { - return []PlanSlot{ - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), BatteryW: -3000, SoC: 0.4}, - } - }) - s.publishPlan(context.Background()) - if n := countEvents(t, c, planPath, "Charge battery"); n != 0 { - t.Fatalf("after v2: stale charge event not deleted, %d remain", n) - } - if n := countEvents(t, c, planPath, "Discharge battery"); n != 1 { - t.Fatalf("after v2: want 1 discharge event, got %d", n) - } - t.Logf("plan reconcile OK: stale charge deleted, discharge written") -} diff --git a/go/internal/calendar/native_e2e_test.go b/go/internal/calendar/native_e2e_test.go deleted file mode 100644 index 4856b71e..00000000 --- a/go/internal/calendar/native_e2e_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package calendar - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" - - "github.com/srcfl/ftw/go/internal/caldavserver" - "github.com/srcfl/ftw/go/internal/config" -) - -// TestCalendarServiceAgainstNativeServer is the end-to-end proof that the -// native in-process CalDAV server (#498) carries the whole feature: a real -// calendar.Service fetches and parses intents straight from it. CI-safe -// (everything in-process). -func TestCalendarServiceAgainstNativeServer(t *testing.T) { - srv := httptest.NewServer(caldavserver.NewHandler("u", "p", "/u/", []string{"/u/energy/"}, caldavserver.NewMemStore())) - defer srv.Close() - - // A calendar app would PUT this; we do it with the same client FTW uses. - hc := webdav.HTTPClientWithBasicAuth(http.DefaultClient, "u", "p") - c, err := caldav.NewClient(hc, srv.URL) - if err != nil { - t.Fatal(err) - } - now := time.Now() - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//ftw-test//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, "away1") - ev.Props.SetDateTime(ical.PropDateTimeStamp, now.UTC()) - ev.Props.SetDateTime(ical.PropDateTimeStart, now.Add(time.Hour)) - ev.Props.SetDateTime(ical.PropDateTimeEnd, now.Add(25*time.Hour)) - ev.Props.SetText(ical.PropSummary, "Vacation") - cal.Children = append(cal.Children, ev.Component) - if _, err := c.PutCalendarObject(context.Background(), "/u/energy/away1.ics", cal); err != nil { - t.Fatalf("seed event: %v", err) - } - - // The real calendar service, pointed at the native server, parses it. - s := New(config.CalDAV{ - Enabled: true, URL: srv.URL, Username: "u", Password: "p", - CalendarPath: "/u/energy/", - }, &fakeLP{}, &fakeLM{}, "garage") - - intents, err := s.fetch(context.Background()) - if err != nil { - t.Fatalf("fetch from native server: %v", err) - } - if len(intents.Away) != 1 { - t.Fatalf("expected 1 away interval from native server, got %d", len(intents.Away)) - } - if intents.Away[0].Title != "Vacation" { - t.Fatalf("title round-trip wrong: %q", intents.Away[0].Title) - } -} - -// TestCalendarServiceExpandsRecurringAway proves recurrence expansion end to -// end: a single daily-recurring "Away" event yields one away interval per -// occurrence inside the fetch horizon (not just the first). This is the gap -// that previously required an external CalDAV server. -func TestCalendarServiceExpandsRecurringAway(t *testing.T) { - srv := httptest.NewServer(caldavserver.NewHandler("u", "p", "/u/", []string{"/u/energy/"}, caldavserver.NewMemStore())) - defer srv.Close() - - hc := webdav.HTTPClientWithBasicAuth(http.DefaultClient, "u", "p") - c, err := caldav.NewClient(hc, srv.URL) - if err != nil { - t.Fatal(err) - } - now := time.Now() - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//ftw-test//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, "away-recurring") - ev.Props.SetDateTime(ical.PropDateTimeStamp, now.UTC()) - ev.Props.SetDateTime(ical.PropDateTimeStart, now.Add(time.Hour)) - ev.Props.SetDateTime(ical.PropDateTimeEnd, now.Add(2*time.Hour)) - ev.Props.SetText(ical.PropSummary, "Away") - // RRULE kept at its default RECUR value type (SetText would mis-tag it). - ev.Props.Set(&ical.Prop{Name: ical.PropRecurrenceRule, Value: "FREQ=DAILY;COUNT=5"}) - cal.Children = append(cal.Children, ev.Component) - if _, err := c.PutCalendarObject(context.Background(), "/u/energy/recurring.ics", cal); err != nil { - t.Fatalf("seed recurring event: %v", err) - } - - s := New(config.CalDAV{ - Enabled: true, URL: srv.URL, Username: "u", Password: "p", - CalendarPath: "/u/energy/", - }, &fakeLP{}, &fakeLM{}, "garage") - - intents, err := s.fetch(context.Background()) - if err != nil { - t.Fatalf("fetch from native server: %v", err) - } - // Default horizon is 7 days, so all 5 daily occurrences fall inside it. - if len(intents.Away) < 3 { - t.Fatalf("recurring away should expand to several intervals, got %d", len(intents.Away)) - } -} diff --git a/go/internal/calendar/parse.go b/go/internal/calendar/parse.go deleted file mode 100644 index 6e249f0b..00000000 --- a/go/internal/calendar/parse.go +++ /dev/null @@ -1,143 +0,0 @@ -package calendar - -import ( - "regexp" - "strconv" - "strings" - "time" -) - -// Interval is a half-open [Start, End) "away"/vacation window derived from a -// calendar event. -type Interval struct { - Start time.Time `json:"start"` - End time.Time `json:"end"` - UID string `json:"uid,omitempty"` - Title string `json:"title,omitempty"` -} - -// Contains reports whether t falls inside [Start, End). -func (iv Interval) Contains(t time.Time) bool { - return !t.Before(iv.Start) && t.Before(iv.End) -} - -// EVDeadline is "loadpoint LoadpointID must reach TargetSoC (0–1) by Departure", -// derived from a calendar event whose start time is the departure. Titles -// still write "80%"; that percent is converted here. -type EVDeadline struct { - LoadpointID string `json:"loadpoint_id,omitempty"` - TargetSoC float64 `json:"target_soc"` - Departure time.Time `json:"departure"` - UID string `json:"uid,omitempty"` - Title string `json:"title,omitempty"` -} - -// Intents is the parsed result of one calendar fetch. -type Intents struct { - Away []Interval `json:"away"` - EV []EVDeadline `json:"ev"` -} - -// pctRe extracts a target percentage like "80%" or "80 %" from an event title. -var pctRe = regexp.MustCompile(`(\d{1,3})\s*%`) - -// lpRe extracts an explicit loadpoint selector like "lp:garage" or "lp=garage". -var lpRe = regexp.MustCompile(`(?i)\blp[:=]\s*([A-Za-z0-9_-]+)`) - -// parser classifies event titles into intents. It is deliberately free of any -// config / network dependency so the title→intent rules are unit-testable in -// isolation. Keywords are stored already lower-cased. -type parser struct { - awayKeywords []string - evKeywords []string - defaultLoadpointID string - defaultTargetSoC float64 -} - -func newParser(awayKeywords, evKeywords []string, defaultLoadpointID string, defaultTargetSoC float64) *parser { - return &parser{ - awayKeywords: lowerAll(awayKeywords), - evKeywords: lowerAll(evKeywords), - defaultLoadpointID: defaultLoadpointID, - defaultTargetSoC: defaultTargetSoC, - } -} - -// classify maps a single event to at most one intent. EV is checked before -// away because EV titles are the more specific case (they carry a target %). -// A non-matching title yields (nil, nil) and is ignored. -// maxTitleLen bounds how much of an event title we inspect — a hostile server -// could otherwise ship a multi-megabyte SUMMARY to burn CPU on a Pi. -const maxTitleLen = 4096 - -func (p *parser) classify(title string, start, end time.Time, uid string) (*Interval, *EVDeadline) { - if len(title) > maxTitleLen { - title = title[:maxTitleLen] - } - lt := strings.ToLower(strings.TrimSpace(title)) - if lt == "" || start.IsZero() { - return nil, nil - } - - if matchesAny(lt, p.evKeywords) { - soc := p.defaultTargetSoC - if m := pctRe.FindStringSubmatch(title); m != nil { - if v, err := strconv.ParseFloat(m[1], 64); err == nil { - soc = titlePercentToFraction(v) - } - } - lp := p.defaultLoadpointID - if m := lpRe.FindStringSubmatch(title); m != nil { - lp = m[1] - } - return nil, &EVDeadline{ - LoadpointID: lp, - TargetSoC: soc, - Departure: start, - UID: uid, - Title: title, - } - } - - if matchesAny(lt, p.awayKeywords) { - e := end - // All-day or DTEND-less events: assume a one-day window so a bare - // "Away" still suppresses load for a sensible span rather than zero. - if !e.After(start) { - e = start.Add(24 * time.Hour) - } - return &Interval{Start: start, End: e, UID: uid, Title: title}, nil - } - - return nil, nil -} - -func matchesAny(lowerTitle string, keywords []string) bool { - for _, k := range keywords { - if k != "" && strings.Contains(lowerTitle, k) { - return true - } - } - return false -} - -func lowerAll(in []string) []string { - out := make([]string, 0, len(in)) - for _, s := range in { - s = strings.ToLower(strings.TrimSpace(s)) - if s != "" { - out = append(out, s) - } - } - return out -} - -func titlePercentToFraction(v float64) float64 { - if v < 0 { - return 0 - } - if v > 100 { - v = 100 - } - return v / 100.0 -} diff --git a/go/internal/calendar/parse_test.go b/go/internal/calendar/parse_test.go deleted file mode 100644 index 70ee4bf3..00000000 --- a/go/internal/calendar/parse_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package calendar - -import ( - "testing" - "time" -) - -func testParser() *parser { - return newParser( - []string{"away", "vacation", "holiday"}, - []string{"ev", "car", "charge"}, - "garage", - 0.80, - ) -} - -func TestClassifyAway(t *testing.T) { - p := testParser() - start := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC) - end := time.Date(2026, 7, 5, 18, 0, 0, 0, time.UTC) - - iv, ev := p.classify("Away — visiting family", start, end, "uid-1") - if ev != nil { - t.Fatalf("away event misclassified as EV: %+v", ev) - } - if iv == nil { - t.Fatal("expected an away interval") - } - if !iv.Start.Equal(start) || !iv.End.Equal(end) { - t.Fatalf("interval bounds wrong: got [%v,%v)", iv.Start, iv.End) - } - if iv.UID != "uid-1" { - t.Fatalf("uid not carried: %q", iv.UID) - } -} - -func TestClassifyAwayNoEndDefaultsToOneDay(t *testing.T) { - p := testParser() - start := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC) - - iv, _ := p.classify("Vacation", start, time.Time{}, "") - if iv == nil { - t.Fatal("expected an away interval") - } - if want := start.Add(24 * time.Hour); !iv.End.Equal(want) { - t.Fatalf("expected 24h default end %v, got %v", want, iv.End) - } -} - -func TestClassifyEVWithPercent(t *testing.T) { - p := testParser() - start := time.Date(2026, 7, 2, 7, 30, 0, 0, time.UTC) - - iv, ev := p.classify("Charge car 65 % before work", start, start.Add(time.Hour), "uid-ev") - if iv != nil { - t.Fatalf("EV event misclassified as away: %+v", iv) - } - if ev == nil { - t.Fatal("expected an EV deadline") - } - if ev.TargetSoC != 0.65 { - t.Fatalf("target soc: want 65, got %v", ev.TargetSoC) - } - if !ev.Departure.Equal(start) { - t.Fatalf("departure should be the event start: got %v", ev.Departure) - } - if ev.LoadpointID != "garage" { - t.Fatalf("loadpoint should fall back to default: got %q", ev.LoadpointID) - } -} - -func TestClassifyEVDefaultTargetWhenNoPercent(t *testing.T) { - p := testParser() - start := time.Date(2026, 7, 2, 7, 30, 0, 0, time.UTC) - - _, ev := p.classify("EV ready", start, start.Add(time.Hour), "") - if ev == nil { - t.Fatal("expected an EV deadline") - } - if ev.TargetSoC != 0.8 { - t.Fatalf("want default 80, got %v", ev.TargetSoC) - } -} - -func TestClassifyEVExplicitLoadpoint(t *testing.T) { - p := testParser() - start := time.Date(2026, 7, 2, 7, 30, 0, 0, time.UTC) - - _, ev := p.classify("Charge to 90% lp:carport", start, start.Add(time.Hour), "") - if ev == nil { - t.Fatal("expected an EV deadline") - } - if ev.LoadpointID != "carport" { - t.Fatalf("explicit loadpoint not honoured: got %q", ev.LoadpointID) - } - if ev.TargetSoC != 0.9 { - t.Fatalf("want 90, got %v", ev.TargetSoC) - } -} - -func TestClassifyPercentClamped(t *testing.T) { - p := testParser() - start := time.Now() - _, ev := p.classify("charge 150%", start, start.Add(time.Hour), "") - if ev == nil || ev.TargetSoC != 1 { - t.Fatalf("percent should clamp to 100: %+v", ev) - } -} - -func TestClassifyCaseInsensitive(t *testing.T) { - p := testParser() - start := time.Now() - iv, _ := p.classify("AWAY", start, start.Add(time.Hour), "") - if iv == nil { - t.Fatal("uppercase keyword should still match") - } -} - -func TestClassifyNonMatchingIgnored(t *testing.T) { - p := testParser() - start := time.Now() - iv, ev := p.classify("Dentist appointment", start, start.Add(time.Hour), "") - if iv != nil || ev != nil { - t.Fatalf("unrelated event should be ignored: iv=%+v ev=%+v", iv, ev) - } -} - -func TestClassifyEmptyTitleIgnored(t *testing.T) { - p := testParser() - start := time.Now() - if iv, ev := p.classify(" ", start, start.Add(time.Hour), ""); iv != nil || ev != nil { - t.Fatal("blank title should be ignored") - } -} - -func TestClassifyZeroStartIgnored(t *testing.T) { - p := testParser() - if iv, ev := p.classify("Away", time.Time{}, time.Time{}, ""); iv != nil || ev != nil { - t.Fatal("event without a start time should be ignored") - } -} diff --git a/go/internal/calendar/plan.go b/go/internal/calendar/plan.go deleted file mode 100644 index 188049d8..00000000 --- a/go/internal/calendar/plan.go +++ /dev/null @@ -1,373 +0,0 @@ -package calendar - -import ( - "context" - "fmt" - "log/slog" - "math" - "net/http" - "path" - "strings" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" -) - -// PlanSlot is one MPC plan slot, supplied by the host (main.go reads it off -// mpc.Service.Latest()). The plan publisher coalesces consecutive slots with -// the same battery action into human-readable "decision blocks". -type PlanSlot struct { - Start, End time.Time - BatteryW float64 // site sign: + charging, - discharging - GridW float64 // resulting grid power (+ import, - export) - SoC float64 // SoC at END of slot (0–1) - Confidence float64 -} - -// PlanSource returns the current plan slots (ordered by time). -type PlanSource func() []PlanSlot - -// planChargeThreshW is the |battery power| above which a slot counts as an -// active charge/discharge window worth publishing. Below it the slot is -// "hold" and is omitted to keep the calendar to actual charging windows. -const planChargeThreshW = 150.0 - -// planBlock is a coalesced, ready-to-write plan event. -type planBlock struct { - uid string - start, end time.Time - summary string - description string -} - -// hash is a cheap content fingerprint for reconcile (re-PUT only on change). -func (b planBlock) hash() string { - return b.summary + "|" + b.start.UTC().Format(time.RFC3339) + "|" + b.end.UTC().Format(time.RFC3339) -} - -// buildPlanBlocks coalesces plan slots into forward-looking charge/discharge -// blocks. Pure + deterministic for unit testing. Only blocks that extend past -// `now` are returned (the plan is forward-looking; past windows belong to the -// history calendar). "Hold" slots break a run but are not published. -func buildPlanBlocks(slots []PlanSlot, now time.Time) []planBlock { - var blocks []planBlock - - flush := func(run []PlanSlot, cat int) { - if len(run) == 0 || cat == 0 { - return - } - start := run[0].Start - end := run[len(run)-1].End - if !end.After(now) { - return // entirely in the past - } - var sum float64 - for _, s := range run { - sum += s.BatteryW - } - avgKW := math.Abs(sum/float64(len(run))) / 1000 - endSoC := run[len(run)-1].SoC * 100 // calendar title door: percent - var verb, short string - if cat > 0 { - verb, short = "Charge battery", "chg" - } else { - verb, short = "Discharge battery", "dis" - } - blocks = append(blocks, planBlock{ - uid: fmt.Sprintf("ftw-plan-%s-%d@fortytwowatts", short, start.Unix()), - start: start, - end: end, - summary: fmt.Sprintf("%s ~%.1f kW", verb, avgKW), - description: fmt.Sprintf( - "FTW plan: %s at about %.1f kW, SoC ≈ %.0f%% by end of window.", - strings.ToLower(verb), avgKW, endSoC), - }) - } - - var run []PlanSlot - runCat := 0 - for _, s := range slots { - cat := 0 - if s.BatteryW > planChargeThreshW { - cat = 1 - } else if s.BatteryW < -planChargeThreshW { - cat = -1 - } - if cat != runCat { - flush(run, runCat) - run = run[:0] - runCat = cat - } - if cat != 0 { - run = append(run, s) - } - } - flush(run, runCat) - return blocks -} - -// SetPlanSource installs the MPC plan source for the forward-looking plan -// publisher. Call before Start. nil disables publishing. -func (s *Service) SetPlanSource(src PlanSource) { - if s == nil { - return - } - s.mu.Lock() - s.planSource = src - s.mu.Unlock() -} - -// planPublishLoop renders the plan into the plan collection on a ticker, -// reconciling against what it wrote last cycle. Fail-soft. -func (s *Service) planPublishLoop(ctx context.Context) { - s.publishPlan(ctx) // prime - s.mu.RLock() - interval := s.planPublishInterval - s.mu.RUnlock() - t := time.NewTicker(interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-s.stop: - return - case <-t.C: - s.publishPlan(ctx) - } - } -} - -// publishPlan builds the current plan blocks and reconciles the plan -// collection: PUT new/changed blocks, DELETE blocks that are no longer in the -// plan (or have fallen into the past). Churn is bounded to real plan changes. -func (s *Service) publishPlan(ctx context.Context) { - s.mu.RLock() - src := s.planSource - url, planPath, user, pass := s.url, s.planPath, s.username, s.password - interval := s.planPublishInterval - s.mu.RUnlock() - if src == nil { - return - } - - // If the planner has produced nothing yet (e.g. just after start, before the - // MPC restores or computes a plan), leave the calendar untouched. Reconciling - // against an empty want-set here would delete every published window — and - // with the seed below that would wipe the whole plan calendar on each restart. - slots := src() - if len(slots) == 0 { - return - } - - blocks := buildPlanBlocks(slots, time.Now()) - // Append the reachability / refresh-cadence footer to every block. Done here - // (not in the pure buildPlanBlocks) because it needs the live interval, and - // after hashing-relevant fields are set — hash() ignores the description, so - // the note never causes reconcile churn. The wording is deliberately precise: - // FTW re-checks on a timer but only rewrites an event when the plan changes, - // so the description must not imply the calendar churns every interval. - note := lanNote("re-checks the plan about every " + friendlyInterval(interval) + - ", and updates an event only when the plan actually changes (so windows you have already seen stay put)") - for i := range blocks { - blocks[i].description += note - } - want := make(map[string]planBlock, len(blocks)) - for _, b := range blocks { - want[b.uid] = b - } - - s.mu.Lock() - prev := s.planWritten - if prev == nil { - prev = map[string]string{} - } - seeded := s.planSeeded - s.mu.Unlock() - - // planWritten is in-memory and resets on restart, so on the first real - // reconcile after (re)start we seed it from what is actually in the plan - // collection. Without this, objects written before the restart whose windows - // have since passed are invisible to the DELETE loop and linger forever, - // accumulating stale past events across restarts. Seeding lets this cycle's - // DELETE loop reclaim them. Seeded uids not in `want` are deleted; seeded - // uids still wanted carry an unknown hash and are re-PUT once (harmless — - // a restart re-PUTs the current plan regardless). - if !seeded { - if uids, err := s.listPlanObjectUIDs(ctx); err != nil { - slog.Warn("caldav: could not enumerate plan collection to seed reconcile; deferring orphan cleanup", "err", err) - } else { - merged := make(map[string]string, len(prev)+len(uids)) - for k, v := range prev { - merged[k] = v - } - for _, uid := range uids { - if _, ok := merged[uid]; !ok { - merged[uid] = "" // unknown hash: forces one re-PUT if still wanted, else a DELETE - } - } - prev = merged - s.mu.Lock() - s.planSeeded = true - s.mu.Unlock() - } - } - - next := make(map[string]string, len(want)) - var puts, dels int - - // PUT new or changed blocks. - for uid, b := range want { - h := b.hash() - next[uid] = h - if prev[uid] == h { - continue // unchanged - } - if err := s.putPlanBlock(ctx, url, planPath, user, pass, b); err != nil { - slog.Warn("caldav: failed to publish plan event", "uid", uid, "err", err) - // keep last-known state for this uid so we retry next cycle - if old, ok := prev[uid]; ok { - next[uid] = old - } else { - delete(next, uid) - } - continue - } - puts++ - } - // DELETE blocks we wrote before but no longer want. - for uid := range prev { - if _, keep := want[uid]; keep { - continue - } - if err := s.deleteObject(ctx, url, planPath, user, pass, uid); err != nil { - slog.Warn("caldav: failed to delete stale plan event", "uid", uid, "err", err) - next[uid] = prev[uid] // retain so we retry the delete next cycle - continue - } - dels++ - } - - s.mu.Lock() - s.planWritten = next - s.planEventCount = len(want) - s.lastPlanMs = time.Now().UnixMilli() - s.mu.Unlock() - - if puts > 0 || dels > 0 { - slog.Info("caldav: plan calendar reconciled", "events", len(want), "put", puts, "deleted", dels) - } -} - -func (s *Service) putPlanBlock(ctx context.Context, url, planPath, user, pass string, b planBlock) error { - client, err := s.newClient(url, user, pass) - if err != nil { - return err - } - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//FTW//plan//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, b.uid) - ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) - ev.Props.SetDateTime(ical.PropDateTimeStart, b.start) - ev.Props.SetDateTime(ical.PropDateTimeEnd, b.end) - ev.Props.SetText(ical.PropSummary, b.summary) - ev.Props.SetText(ical.PropDescription, b.description) - // Mark as tentative — a plan, not a commitment. - ev.Props.SetText(ical.PropStatus, "TENTATIVE") - cal.Children = append(cal.Children, ev.Component) - _, err = client.PutCalendarObject(ctx, planObjectPath(planPath, b.uid), cal) - return err -} - -// deleteObject removes a plan event by uid via a WebDAV DELETE. The caldav -// client has no delete, so we use a plain webdav client. -func (s *Service) deleteObject(ctx context.Context, url, planPath, user, pass, uid string) error { - hc := webdav.HTTPClientWithBasicAuth(&http.Client{Timeout: 15 * time.Second}, user, pass) - wc, err := webdav.NewClient(hc, url) - if err != nil { - return err - } - return wc.RemoveAll(ctx, planObjectPath(planPath, uid)) -} - -func planObjectPath(planPath, uid string) string { - return strings.TrimRight(planPath, "/") + "/" + uid + ".ics" -} - -// listPlanObjectUIDs enumerates the plan collection and returns the uid of every -// object currently in it. Used once after (re)start to seed the reconcile map so -// orphaned objects from a previous process can be reclaimed. The uid is derived -// from the object path (planObjectPath writes "/.ics"), so it -// round-trips through deleteObject without parsing the calendar body. -func (s *Service) listPlanObjectUIDs(ctx context.Context) ([]string, error) { - s.mu.RLock() - url, planPath, user, pass := s.url, s.planPath, s.username, s.password - s.mu.RUnlock() - - client, err := s.newClient(url, user, pass) - if err != nil { - return nil, err - } - // Wide time-range filter: plan objects are near-term, but a stale one may sit - // in the past, so cover a year either side to catch every orphan. - now := time.Now() - query := &caldav.CalendarQuery{ - CompRequest: caldav.CalendarCompRequest{ - Name: "VCALENDAR", - Comps: []caldav.CalendarCompRequest{{Name: "VEVENT"}}, - }, - CompFilter: caldav.CompFilter{ - Name: "VCALENDAR", - Comps: []caldav.CompFilter{{ - Name: "VEVENT", - Start: now.Add(-365 * 24 * time.Hour), - End: now.Add(365 * 24 * time.Hour), - }}, - }, - } - objs, err := client.QueryCalendar(ctx, planPath, query) - if err != nil { - return nil, err - } - uids := make([]string, 0, len(objs)) - for _, obj := range objs { - if uid := strings.TrimSuffix(path.Base(obj.Path), ".ics"); uid != "" && uid != "." && uid != "/" { - uids = append(uids, uid) - } - } - return uids, nil -} - -// lanNote is the footer appended to every published event's description so a -// subscriber understands the refresh behaviour: FTW's CalDAV server -// is never published to the internet, so a calendar app can only pull updates -// while it can reach FTW — on the home network or over a VPN into -// it — plus when FTW actually changes the feed. `refresh` is the -// trailing clause, e.g. "re-checks the plan about every 15 min, and updates an -// event only when the plan actually changes". -func lanNote(refresh string) string { - return "\n\nThis calendar lives on your home network and is never " + - "published to the internet. Your calendar app can refresh it only " + - "while it can reach FTW — on your home network or over a " + - "VPN into it — otherwise events stay as last synced. " + - "FTW " + refresh + "." -} - -// friendlyInterval renders a poll/publish interval in a human-readable unit. -func friendlyInterval(d time.Duration) string { - switch { - case d >= time.Hour: - if h := d.Hours(); h == math.Trunc(h) { - return fmt.Sprintf("%.0f h", h) - } - return fmt.Sprintf("%.1f h", d.Hours()) - case d >= time.Minute: - return fmt.Sprintf("%.0f min", d.Minutes()) - default: - return fmt.Sprintf("%.0f s", d.Seconds()) - } -} diff --git a/go/internal/calendar/plan_reconcile_test.go b/go/internal/calendar/plan_reconcile_test.go deleted file mode 100644 index 361012ae..00000000 --- a/go/internal/calendar/plan_reconcile_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package calendar - -import ( - "context" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" - - "github.com/srcfl/ftw/go/internal/caldavserver" - "github.com/srcfl/ftw/go/internal/config" -) - -func planServer(t *testing.T) (*httptest.Server, *caldav.Client) { - t.Helper() - srv := httptest.NewServer(caldavserver.NewHandler("u", "p", "/u/", []string{"/u/energy/", "/u/plan/"}, caldavserver.NewMemStore())) - t.Cleanup(srv.Close) - hc := webdav.HTTPClientWithBasicAuth(http.DefaultClient, "u", "p") - c, err := caldav.NewClient(hc, srv.URL) - if err != nil { - t.Fatal(err) - } - return srv, c -} - -// putRawPlan writes a plan object the way a previous FTW process would have, so -// tests can stage pre-existing / orphaned objects in the plan collection. -func putRawPlan(t *testing.T, c *caldav.Client, uid, summary string, start, end time.Time) { - t.Helper() - cal := ical.NewCalendar() - cal.Props.SetText(ical.PropProductID, "-//ftw-test//EN") - cal.Props.SetText(ical.PropVersion, "2.0") - ev := ical.NewEvent() - ev.Props.SetText(ical.PropUID, uid) - ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) - ev.Props.SetDateTime(ical.PropDateTimeStart, start) - ev.Props.SetDateTime(ical.PropDateTimeEnd, end) - ev.Props.SetText(ical.PropSummary, summary) - cal.Children = append(cal.Children, ev.Component) - if _, err := c.PutCalendarObject(context.Background(), "/u/plan/"+uid+".ics", cal); err != nil { - t.Fatalf("seed plan object %s: %v", uid, err) - } -} - -// planSummaries returns the SUMMARY of every object currently in the plan -// collection (wide time range so past objects are included). -func planSummaries(t *testing.T, c *caldav.Client) []string { - t.Helper() - now := time.Now() - objs, err := c.QueryCalendar(context.Background(), "/u/plan/", &caldav.CalendarQuery{ - CompRequest: caldav.CalendarCompRequest{Name: "VCALENDAR", Comps: []caldav.CalendarCompRequest{{Name: "VEVENT", AllProps: true}}}, - CompFilter: caldav.CompFilter{Name: "VCALENDAR", Comps: []caldav.CompFilter{{ - Name: "VEVENT", Start: now.Add(-365 * 24 * time.Hour), End: now.Add(365 * 24 * time.Hour), - }}}, - }) - if err != nil { - t.Fatalf("query plan collection: %v", err) - } - var out []string - for _, o := range objs { - if o.Data == nil { - continue - } - for _, ev := range o.Data.Events() { - sum, _ := ev.Props.Text(ical.PropSummary) - out = append(out, sum) - } - } - return out -} - -func planService(t *testing.T, url string) *Service { - t.Helper() - return New(config.CalDAV{ - Enabled: true, URL: url, Username: "u", Password: "p", - CalendarPath: "/u/energy/", PlanPath: "/u/plan/", - }, &fakeLP{}, &fakeLM{}, "garage") -} - -// TestPlanReconcileReclaimsOrphansOnRestart proves the cross-restart orphan fix: -// a plan object left by a previous process (whose window the current plan no -// longer regenerates) is deleted on the first publish after a restart, because -// the reconcile seeds its state from the live collection rather than an empty -// in-memory map. -func TestPlanReconcileReclaimsOrphansOnRestart(t *testing.T) { - srv, c := planServer(t) - now := time.Now() - - // Stale object from a "previous process": a past discharge window. - pastStart := now.Add(-48 * time.Hour) - orphanUID := "ftw-plan-dis-" + strconv.FormatInt(pastStart.Unix(), 10) + "@fortytwowatts" - putRawPlan(t, c, orphanUID, "Discharge battery ~2.0 kW", pastStart, pastStart.Add(time.Hour)) - - // Fresh service (empty planWritten, planSeeded=false) models the restart. - s := planService(t, srv.URL) - s.SetPlanSource(func() []PlanSlot { - return []PlanSlot{{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), BatteryW: 4000, SoC: 0.7}} - }) - s.publishPlan(context.Background()) - - sums := planSummaries(t, c) - charge := 0 - for _, sum := range sums { - if strings.Contains(sum, "Discharge") { - t.Fatalf("stale orphan not reclaimed after restart; summaries=%v", sums) - } - if strings.Contains(sum, "Charge battery") { - charge++ - } - } - if charge != 1 { - t.Fatalf("want exactly 1 charge window, got %d; summaries=%v", charge, sums) - } -} - -// TestPlanPublishSkipsEmptyPlan proves the guard: when the planner has produced -// nothing yet (empty slots), publishPlan must leave the existing calendar alone -// rather than deleting every window (which — combined with the restart seed — -// would otherwise wipe the plan calendar on each restart). -func TestPlanPublishSkipsEmptyPlan(t *testing.T) { - srv, c := planServer(t) - now := time.Now() - - putRawPlan(t, c, "ftw-plan-chg-existing@fortytwowatts", "Charge battery ~3.0 kW", now.Add(time.Hour), now.Add(2*time.Hour)) - - s := planService(t, srv.URL) - s.SetPlanSource(func() []PlanSlot { return nil }) // planner not ready - s.publishPlan(context.Background()) - - if sums := planSummaries(t, c); len(sums) != 1 { - t.Fatalf("empty plan must leave existing events untouched, got %d: %v", len(sums), sums) - } -} diff --git a/go/internal/calendar/plan_test.go b/go/internal/calendar/plan_test.go deleted file mode 100644 index e1d7b954..00000000 --- a/go/internal/calendar/plan_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package calendar - -import ( - "strings" - "testing" - "time" -) - -func planSlot(now time.Time, offMin, lenMin int, batW, soc float64) PlanSlot { - s := now.Add(time.Duration(offMin) * time.Minute) - return PlanSlot{Start: s, End: s.Add(time.Duration(lenMin) * time.Minute), BatteryW: batW, SoC: soc} -} - -func TestBuildPlanBlocksCoalesces(t *testing.T) { - now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - slots := []PlanSlot{ - planSlot(now, 0, 15, 3000, 0.55), // charge - planSlot(now, 15, 15, 3200, 0.60), // charge (same run) - planSlot(now, 30, 15, 50, 0.60), // hold → breaks the run, not published - planSlot(now, 45, 15, -2000, 0.50), // discharge - planSlot(now, 60, 15, -2100, 0.45), // discharge (same run) - } - blocks := buildPlanBlocks(slots, now) - if len(blocks) != 2 { - t.Fatalf("want 2 blocks (charge, discharge), got %d: %+v", len(blocks), blocks) - } - if !strings.Contains(blocks[0].summary, "Charge battery") { - t.Fatalf("block 0 summary = %q", blocks[0].summary) - } - if !blocks[0].start.Equal(now) || !blocks[0].end.Equal(now.Add(30*time.Minute)) { - t.Fatalf("charge block bounds wrong: [%v,%v)", blocks[0].start, blocks[0].end) - } - if !strings.Contains(blocks[1].summary, "Discharge battery") { - t.Fatalf("block 1 summary = %q", blocks[1].summary) - } - if !blocks[1].start.Equal(now.Add(45*time.Minute)) || !blocks[1].end.Equal(now.Add(75*time.Minute)) { - t.Fatalf("discharge block bounds wrong: [%v,%v)", blocks[1].start, blocks[1].end) - } -} - -func TestBuildPlanBlocksSkipsPast(t *testing.T) { - now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - slots := []PlanSlot{ - planSlot(now, -120, 60, 3000, 0.50), // ended an hour ago → skipped - planSlot(now, -60, 60, 3000, 0.60), // ended exactly at now → skipped (End not after now) - } - if blocks := buildPlanBlocks(slots, now); len(blocks) != 0 { - t.Fatalf("past blocks should be skipped, got %d: %+v", len(blocks), blocks) - } -} - -func TestBuildPlanBlocksSpanningNowIncluded(t *testing.T) { - now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - slots := []PlanSlot{planSlot(now, -30, 120, 4000, 0.70)} // started 30m ago, ends in 90m - blocks := buildPlanBlocks(slots, now) - if len(blocks) != 1 { - t.Fatalf("block spanning now should be included, got %d", len(blocks)) - } - if !strings.Contains(blocks[0].summary, "4.0 kW") { - t.Fatalf("expected avg power in summary, got %q", blocks[0].summary) - } -} - -func TestPlanBlockHashStableAndUID(t *testing.T) { - now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - slots := []PlanSlot{planSlot(now, 60, 60, 5000, 0.80)} - a := buildPlanBlocks(slots, now) - b := buildPlanBlocks(slots, now) - if a[0].uid != b[0].uid { - t.Fatalf("uid not deterministic: %q vs %q", a[0].uid, b[0].uid) - } - if a[0].hash() != b[0].hash() { - t.Fatalf("hash not deterministic") - } - if !strings.HasPrefix(a[0].uid, "ftw-plan-chg-") { - t.Fatalf("unexpected uid: %q", a[0].uid) - } -} diff --git a/go/internal/calendar/provision.go b/go/internal/calendar/provision.go deleted file mode 100644 index 4372998f..00000000 --- a/go/internal/calendar/provision.go +++ /dev/null @@ -1,17 +0,0 @@ -package calendar - -import ( - "crypto/rand" - "encoding/base64" -) - -// GenerateToken returns a URL-safe random secret carrying nBytes of entropy. -// Used by main.go to mint the managed CalDAV password on first enable; the -// in-process native server (internal/caldavserver) authenticates against it. -func GenerateToken(nBytes int) (string, error) { - b := make([]byte, nBytes) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} diff --git a/go/internal/calendar/provision_test.go b/go/internal/calendar/provision_test.go deleted file mode 100644 index 34f4675a..00000000 --- a/go/internal/calendar/provision_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package calendar - -import ( - "testing" - - "github.com/srcfl/ftw/go/internal/config" -) - -func TestGenerateTokenNonEmptyAndDistinct(t *testing.T) { - a, err := GenerateToken(18) - if err != nil { - t.Fatal(err) - } - b, _ := GenerateToken(18) - if a == "" || a == b { - t.Fatalf("tokens should be non-empty and distinct: %q vs %q", a, b) - } - if len(a) < 20 { - t.Fatalf("token unexpectedly short: %q", a) - } -} - -func TestManagedUsernameDefault(t *testing.T) { - mc := true - s := New(config.CalDAV{Enabled: true, ManageCredentials: &mc}, &fakeLP{}, &fakeLM{}, "garage") - if got := s.Credentials().Username; got != config.DefaultCalDAVUsername { - t.Fatalf("managed username default: want %q, got %q", config.DefaultCalDAVUsername, got) - } -} diff --git a/go/internal/calendar/service.go b/go/internal/calendar/service.go deleted file mode 100644 index c0a57885..00000000 --- a/go/internal/calendar/service.go +++ /dev/null @@ -1,634 +0,0 @@ -package calendar - -import ( - "context" - "io" - "log/slog" - "net/http" - "strings" - "sync" - "time" - - "github.com/emersion/go-ical" - webdav "github.com/emersion/go-webdav" - "github.com/emersion/go-webdav/caldav" - - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/loadmodel" -) - -// LoadProfiler is the slice of *loadmodel.Service the calendar service needs: -// flip the active load profile to "away" while the house is empty and back to -// "home" when it isn't. Declared as an interface so tests can inject a fake. -type LoadProfiler interface { - SetProfile(loadmodel.Profile) error -} - -// LoadpointTargeter is the slice of *loadpoint.Manager the calendar service -// needs: push an EV "charged-by-departure" deadline onto a loadpoint. The MPC -// loadpoint probe already reads the resulting target/time off the manager. -type LoadpointTargeter interface { - SetTarget(id string, socPct float64, targetTime time.Time) bool -} - -// Service is a CalDAV *client*. It periodically polls a calendar collection -// (served by FTW's own in-process native CalDAV server — see -// internal/caldavserver), classifies events by title keyword, and applies the -// resulting intents: -// -// - "away" intervals → loadmodel away/home profile switch (live + training), -// plus an IsAwayAt hook the MPC load predictor consults per slot; -// - EV deadlines → loadpoint SetTarget(socPct, departure). -// -// Everything is opt-in and fail-soft: an unreachable server logs a warning and -// keeps evaluating the last-good intents so known time-based transitions still -// happen while the server is unavailable. -type Service struct { - lp LoadpointTargeter - lm LoadProfiler - - // lifecycleMu serialises Start / Stop. - lifecycleMu sync.Mutex - stop chan struct{} - wg sync.WaitGroup - running bool - - // evSource yields current EV charge-point samples (set by main.go from - // telemetry before Start). Drives the outbound history writer. - evSource EVSource - evSampleInterval time.Duration - ev map[string]*evTrack - - // planSource yields the current MPC plan slots (set by main.go before - // Start). Drives the forward-looking plan publisher. - planSource PlanSource - - // manageCreds = FTW owns the credential (generated on first enable and shown - // in the UI); the in-process native server authenticates against it. - manageCreds bool - - // mu guards the resolved config + live diagnostic state below. - mu sync.RWMutex - enabled bool - url string - calendarPath string - username string - password string - pollInterval time.Duration - horizon time.Duration - prs *parser - historyEnabled bool - historyPath string - intents Intents - hasIntents bool - lastSyncMs int64 - lastErr string - reachable bool - awayActive bool - profileApplied bool - lastEV *EVDeadline - historyWritten int - lastHistoryMs int64 - - planEnabled bool - planPath string - planPublishInterval time.Duration - planWritten map[string]string // uid -> content hash - planSeeded bool // reconcile map seeded from the live collection since (re)start - planEventCount int - lastPlanMs int64 -} - -// Status is the read-only snapshot rendered by GET /api/caldav/status. -type Status struct { - Enabled bool `json:"enabled"` - Reachable bool `json:"reachable"` - LastSyncMs int64 `json:"last_sync_ms"` - LastError string `json:"last_error,omitempty"` - EventCount int `json:"event_count"` - AwayActive bool `json:"away_active"` - NextEVDeadline *EVDeadline `json:"next_ev_deadline,omitempty"` - SubscribeURL string `json:"subscribe_url,omitempty"` - Username string `json:"username,omitempty"` - - // Outbound EVSE-history writer. - HistoryEnabled bool `json:"history_enabled"` - HistoryURL string `json:"history_url,omitempty"` - HistoryWritten int `json:"history_written"` - LastHistoryMs int64 `json:"last_history_ms,omitempty"` - - // Outbound forward-looking plan publisher. - PlanEnabled bool `json:"plan_enabled"` - PlanURL string `json:"plan_url,omitempty"` - PlanEvents int `json:"plan_events"` - LastPlanMs int64 `json:"last_plan_ms,omitempty"` -} - -// New builds a calendar service from config. firstLoadpointID is the fallback -// loadpoint an EV event targets when neither the event title nor -// cfg.EVLoadpointID names one (main.go passes cfg.Loadpoints[0].ID). -func New(cfg config.CalDAV, lp LoadpointTargeter, lm LoadProfiler, firstLoadpointID string) *Service { - s := &Service{ - lp: lp, - lm: lm, - ev: make(map[string]*evTrack), - evSampleInterval: 30 * time.Second, - planWritten: make(map[string]string), - } - s.applyConfig(cfg, firstLoadpointID) - return s -} - -// SetPlanSource is defined in plan.go. - -// SetEVSource installs the EV telemetry source for the outbound history -// writer. Call before Start. nil disables the writer. -func (s *Service) SetEVSource(src EVSource) { - if s == nil { - return - } - s.mu.Lock() - s.evSource = src - s.mu.Unlock() -} - -// applyConfig resolves config (filling defaults) into the live fields. Caller -// must not hold mu. -func (s *Service) applyConfig(cfg config.CalDAV, firstLoadpointID string) { - url := strings.TrimSpace(cfg.URL) - if url == "" { - url = config.DefaultCalDAVURL - } - calPath := strings.TrimSpace(cfg.CalendarPath) - if calPath == "" { - calPath = config.DefaultCalDAVCalendarPath - } - poll := cfg.PollIntervalS - if poll <= 0 { - poll = config.DefaultCalDAVPollS - } - horizonDays := cfg.HorizonDays - if horizonDays <= 0 { - horizonDays = config.DefaultCalDAVHorizonDays - } - targetSoC := cfg.EVDefaultTargetSoC - if targetSoC <= 0 { - targetSoC = config.DefaultCalDAVEVTargetSoC - } - away := cfg.AwayKeywords - if len(away) == 0 { - away = config.DefaultAwayKeywords - } - evk := cfg.EVKeywords - if len(evk) == 0 { - evk = config.DefaultEVKeywords - } - defaultLP := strings.TrimSpace(cfg.EVLoadpointID) - if defaultLP == "" { - defaultLP = firstLoadpointID - } - username := strings.TrimSpace(cfg.Username) - if username == "" && cfg.ManageCredentialsEnabled() { - username = config.DefaultCalDAVUsername - } - histPath := strings.TrimSpace(cfg.HistoryPath) - if histPath == "" { - histPath = config.DefaultCalDAVHistoryPath - } - // Refuse to write history into the same collection we read intents from — - // FTW would re-ingest its own "EV charged …" events as EV deadlines. - histEnabled := cfg.EVSEHistoryEnabled() && histPath != "" && histPath != calPath - if cfg.EVSEHistoryEnabled() && histPath == calPath { - slog.Warn("caldav: history_path equals calendar_path; disabling EVSE history to avoid a feedback loop", - "path", histPath) - } - planPath := strings.TrimSpace(cfg.PlanPath) - if planPath == "" { - planPath = config.DefaultCalDAVPlanPath - } - planPub := cfg.PlanPublishIntervalS - if planPub <= 0 { - planPub = config.DefaultCalDAVPlanPublishS - } - // Plan collection must be distinct from the inbound calendar (else the - // publisher's events would be re-read as intents) and from history. - planEnabled := cfg.PublishPlanEnabled() && planPath != "" && planPath != calPath && planPath != histPath - if cfg.PublishPlanEnabled() && (planPath == calPath || planPath == histPath) { - slog.Warn("caldav: plan_path collides with calendar_path/history_path; disabling plan publishing", - "path", planPath) - } - - s.mu.Lock() - s.enabled = cfg.Enabled - s.url = url - s.calendarPath = calPath - s.username = username - s.password = cfg.Password - s.manageCreds = cfg.ManageCredentialsEnabled() - s.pollInterval = time.Duration(poll) * time.Second - s.horizon = time.Duration(horizonDays) * 24 * time.Hour - s.prs = newParser(away, evk, defaultLP, targetSoC) - s.historyPath = histPath - s.historyEnabled = histEnabled - s.planPath = planPath - s.planEnabled = planEnabled - s.planPublishInterval = time.Duration(planPub) * time.Second - s.mu.Unlock() -} - -// Start launches the inbound poll loop, plus the outbound EVSE-history loop -// when a source + history collection are configured. No-op if disabled or -// already running. -func (s *Service) Start(ctx context.Context) { - if s == nil { - return - } - s.lifecycleMu.Lock() - defer s.lifecycleMu.Unlock() - if s.running { - return - } - s.mu.RLock() - enabled := s.enabled - historyEnabled := s.historyEnabled && s.evSource != nil - planEnabled := s.planEnabled && s.planSource != nil - s.mu.RUnlock() - if !enabled { - return - } - s.stop = make(chan struct{}) - s.running = true - s.wg.Add(1) - go func() { defer s.wg.Done(); s.loop(ctx) }() - if historyEnabled { - s.wg.Add(1) - go func() { defer s.wg.Done(); s.evHistoryLoop(ctx) }() - } - if planEnabled { - s.wg.Add(1) - go func() { defer s.wg.Done(); s.planPublishLoop(ctx) }() - } -} - -// Stop terminates both loops. -func (s *Service) Stop() { - if s == nil { - return - } - s.lifecycleMu.Lock() - defer s.lifecycleMu.Unlock() - if !s.running { - return - } - close(s.stop) - s.wg.Wait() - s.running = false -} - -func (s *Service) loop(ctx context.Context) { - // Prime once promptly so the dashboard + planner see intents without - // waiting a full interval. - s.pollOnce(ctx) - - s.mu.RLock() - interval := s.pollInterval - s.mu.RUnlock() - t := time.NewTicker(interval) - defer t.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-s.stop: - return - case <-t.C: - s.pollOnce(ctx) - // Pick up an interval change from Reload. - s.mu.RLock() - next := s.pollInterval - s.mu.RUnlock() - if next != interval { - interval = next - t.Reset(interval) - } - } - } -} - -// pollOnce fetches + applies once. Errors are recorded but never propagated — -// control must not stall on a flaky calendar server. -func (s *Service) pollOnce(ctx context.Context) { - // Bound the whole poll (network + XML unmarshal of a possibly-hostile - // response), since the HTTP client timeout only covers the round-trip. - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - intents, err := s.fetch(ctx) - now := time.Now() - if err != nil { - s.mu.Lock() - s.reachable = false - s.lastErr = err.Error() - s.lastSyncMs = now.UnixMilli() - cached, hasCached := s.intents, s.hasIntents - s.mu.Unlock() - slog.Warn("caldav poll failed", "err", err) - // A failed refresh must not freeze a previously active Away profile - // beyond the known event end. Do not apply an empty cache before the - // first successful poll: that would overwrite a manual profile choice. - if hasCached { - s.apply(cached, now) - } - return - } - - s.mu.Lock() - s.reachable = true - s.lastErr = "" - s.lastSyncMs = now.UnixMilli() - s.intents = intents - s.hasIntents = true - s.mu.Unlock() - - s.apply(intents, now) -} - -// DoS guards on the inbound CalDAV response (a hostile or MITM'd server could -// otherwise exhaust the Pi's memory/CPU): cap the raw response body and the -// number of events we parse from one fetch. -const ( - maxResponseBytes = 25 << 20 // 25 MiB - maxCalendarEvents = 10000 -) - -// limitedTransport caps the response body so an oversized CalDAV reply is -// truncated (and the XML decode then fails) instead of being read fully into -// memory. Wraps the default transport. -type limitedTransport struct { - base http.RoundTripper - max int64 -} - -func (t *limitedTransport) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := t.base.RoundTrip(req) - if err != nil || resp == nil || resp.Body == nil { - return resp, err - } - resp.Body = &limitedReadCloser{r: io.LimitReader(resp.Body, t.max), c: resp.Body} - return resp, nil -} - -type limitedReadCloser struct { - r io.Reader - c io.Closer -} - -func (l *limitedReadCloser) Read(p []byte) (int, error) { return l.r.Read(p) } -func (l *limitedReadCloser) Close() error { return l.c.Close() } - -// newClient builds a CalDAV client with Basic auth against the configured -// server. Shared by the inbound poll (fetch) and the outbound writers. -func (s *Service) newClient(url, user, pass string) (*caldav.Client, error) { - hc := &http.Client{ - Timeout: 15 * time.Second, - Transport: &limitedTransport{base: http.DefaultTransport, max: maxResponseBytes}, - } - httpClient := webdav.HTTPClientWithBasicAuth(hc, user, pass) - return caldav.NewClient(httpClient, url) -} - -// fetch runs a time-ranged calendar-query REPORT and parses the result. The -// server expands recurrences within the horizon (Expand), so we never compute -// RRULE instances ourselves. -func (s *Service) fetch(ctx context.Context) (Intents, error) { - s.mu.RLock() - url, calPath, user, pass := s.url, s.calendarPath, s.username, s.password - horizon, prs := s.horizon, s.prs - s.mu.RUnlock() - - client, err := s.newClient(url, user, pass) - if err != nil { - return Intents{}, err - } - - start := time.Now() - end := start.Add(horizon) - query := &caldav.CalendarQuery{ - CompRequest: caldav.CalendarCompRequest{ - Name: "VCALENDAR", - Comps: []caldav.CalendarCompRequest{{ - Name: "VEVENT", - AllProps: true, - Expand: &caldav.CalendarExpandRequest{Start: start, End: end}, - }}, - }, - CompFilter: caldav.CompFilter{ - Name: "VCALENDAR", - Comps: []caldav.CompFilter{{ - Name: "VEVENT", - Start: start, - End: end, - }}, - }, - } - - objs, err := client.QueryCalendar(ctx, calPath, query) - if err != nil { - return Intents{}, err - } - - var out Intents - parsed := 0 - for _, obj := range objs { - if obj.Data == nil { - continue - } - events := obj.Data.Events() - for i := range events { - if parsed >= maxCalendarEvents { - slog.Warn("caldav: event cap reached; ignoring the rest (hostile or misconfigured server?)", "cap", maxCalendarEvents) - return out, nil - } - parsed++ - ev := events[i] - title, _ := ev.Props.Text(ical.PropSummary) - uid, _ := ev.Props.Text(ical.PropUID) - st, err := ev.DateTimeStart(time.Local) - if err != nil { - continue - } - en, _ := ev.DateTimeEnd(time.Local) - away, evd := prs.classify(title, st, en, uid) - if away != nil { - out.Away = append(out.Away, *away) - } - if evd != nil { - out.EV = append(out.EV, *evd) - } - } - } - return out, nil -} - -// apply pushes intents into the load model + loadpoint manager. -func (s *Service) apply(intents Intents, now time.Time) { - // Away → profile switch on transition only (idempotent). On leaving an - // away window we restore the home profile; this intentionally overrides a - // manual UI profile choice while/after an away event — documented. - awayNow := false - for _, iv := range intents.Away { - if iv.Contains(now) { - awayNow = true - break - } - } - s.mu.Lock() - changed := !s.profileApplied || awayNow != s.awayActive - s.awayActive = awayNow - s.profileApplied = true - s.mu.Unlock() - if changed && s.lm != nil { - profile := loadmodel.ProfileHome - if awayNow { - profile = loadmodel.ProfileAway - } - if err := s.lm.SetProfile(profile); err != nil { - slog.Warn("caldav: failed to set load profile", "profile", profile, "err", err) - } else { - slog.Info("caldav: load profile switched", "profile", profile, "away_active", awayNow) - } - } - - // EV → set the next upcoming deadline's target on its loadpoint. We never - // clear an existing manual/UI target when no event is upcoming. - next := nextEV(intents.EV, now) - if next != nil && s.lp != nil { - if next.LoadpointID == "" { - slog.Warn("caldav: EV event has no loadpoint to target; ignoring", "title", next.Title) - } else if s.lp.SetTarget(next.LoadpointID, next.TargetSoC, next.Departure) { - s.mu.Lock() - prev := s.lastEV - s.lastEV = next - s.mu.Unlock() - if prev == nil || *prev != *next { - slog.Info("caldav: EV target set from calendar", - "loadpoint", next.LoadpointID, - "target_soc", next.TargetSoC, - "departure", next.Departure) - } - } - } else { - s.mu.Lock() - s.lastEV = nil - s.mu.Unlock() - } -} - -// nextEV returns the earliest EV deadline strictly after now, or nil. -func nextEV(evs []EVDeadline, now time.Time) *EVDeadline { - var best *EVDeadline - for i := range evs { - if !evs[i].Departure.After(now) { - continue - } - if best == nil || evs[i].Departure.Before(best.Departure) { - best = &evs[i] - } - } - return best -} - -// IsAwayAt reports whether time t falls inside any parsed away interval. The -// MPC load predictor consults this per slot so a horizon crossing an away -// window predicts reduced load over exactly the away slots. -func (s *Service) IsAwayAt(t time.Time) bool { - if s == nil { - return false - } - s.mu.RLock() - defer s.mu.RUnlock() - for _, iv := range s.intents.Away { - if iv.Contains(t) { - return true - } - } - return false -} - -// Status returns a read-only snapshot for the diagnostics endpoint. -func (s *Service) Status() Status { - if s == nil { - return Status{} - } - s.mu.RLock() - defer s.mu.RUnlock() - st := Status{ - Enabled: s.enabled, - Reachable: s.reachable, - LastSyncMs: s.lastSyncMs, - LastError: s.lastErr, - EventCount: len(s.intents.Away) + len(s.intents.EV), - AwayActive: s.awayActive, - SubscribeURL: joinURL(s.url, s.calendarPath), - Username: s.username, - HistoryEnabled: s.historyEnabled, - HistoryWritten: s.historyWritten, - LastHistoryMs: s.lastHistoryMs, - PlanEnabled: s.planEnabled, - PlanEvents: s.planEventCount, - LastPlanMs: s.lastPlanMs, - } - if s.historyEnabled { - st.HistoryURL = joinURL(s.url, s.historyPath) - } - if s.planEnabled { - st.PlanURL = joinURL(s.url, s.planPath) - } - if n := nextEV(s.intents.EV, time.Now()); n != nil { - cp := *n - st.NextEVDeadline = &cp - } - return st -} - -func joinURL(base, path string) string { - if base == "" { - return "" - } - return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(path, "/") -} - -// CredentialView is the managed-credential reveal rendered by -// GET /api/caldav/credentials for the Settings → Calendar tab. It returns the -// password so the local operator can add the account to another device. Keep -// the API on a trusted LAN or operator-managed private network. -type CredentialView struct { - Managed bool `json:"managed"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - SubscribeURL string `json:"subscribe_url,omitempty"` - HistoryURL string `json:"history_url,omitempty"` - PlanURL string `json:"plan_url,omitempty"` -} - -// Credentials returns the managed CalDAV credential + subscribe URLs. -func (s *Service) Credentials() CredentialView { - if s == nil { - return CredentialView{} - } - s.mu.RLock() - defer s.mu.RUnlock() - cv := CredentialView{ - Managed: s.manageCreds, - Username: s.username, - Password: s.password, - SubscribeURL: joinURL(s.url, s.calendarPath), - } - if s.historyEnabled { - cv.HistoryURL = joinURL(s.url, s.historyPath) - } - if s.planEnabled { - cv.PlanURL = joinURL(s.url, s.planPath) - } - return cv -} diff --git a/go/internal/calendar/service_test.go b/go/internal/calendar/service_test.go deleted file mode 100644 index 57064ede..00000000 --- a/go/internal/calendar/service_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package calendar - -import ( - "context" - "testing" - "time" - - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/loadmodel" -) - -type fakeLP struct { - id string - soc float64 - when time.Time - called int -} - -func (f *fakeLP) SetTarget(id string, soc float64, t time.Time) bool { - f.id, f.soc, f.when = id, soc, t - f.called++ - return true -} - -type fakeLM struct { - profile loadmodel.Profile - calls int -} - -func (f *fakeLM) SetProfile(p loadmodel.Profile) error { - f.profile = p - f.calls++ - return nil -} - -func newTestService(lp LoadpointTargeter, lm LoadProfiler) *Service { - return New(config.CalDAV{Enabled: true}, lp, lm, "garage") -} - -func TestApplyAwayProfileTransitions(t *testing.T) { - lm := &fakeLM{} - s := newTestService(&fakeLP{}, lm) - now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - - away := Intents{Away: []Interval{{Start: now.Add(-time.Hour), End: now.Add(time.Hour)}}} - - s.apply(away, now) - if lm.profile != loadmodel.ProfileAway || lm.calls != 1 { - t.Fatalf("expected away profile after entering window: profile=%v calls=%d", lm.profile, lm.calls) - } - - // Same state again → idempotent, no extra SetProfile call. - s.apply(away, now) - if lm.calls != 1 { - t.Fatalf("idempotent away should not re-call SetProfile: calls=%d", lm.calls) - } - - // Leaving the window restores home. - s.apply(Intents{}, now) - if lm.profile != loadmodel.ProfileHome || lm.calls != 2 { - t.Fatalf("expected home profile after leaving window: profile=%v calls=%d", lm.profile, lm.calls) - } -} - -func TestPollFailureExpiresCachedAwayIntent(t *testing.T) { - lm := &fakeLM{} - s := newTestService(&fakeLP{}, lm) - now := time.Now() - cached := Intents{Away: []Interval{{Start: now.Add(-2 * time.Hour), End: now.Add(-time.Hour)}}} - - // Simulate the earlier successful application while the event was active. - s.apply(cached, now.Add(-90*time.Minute)) - s.mu.Lock() - s.intents = cached - s.hasIntents = true - s.mu.Unlock() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // force the refresh to fail immediately - s.pollOnce(ctx) - - if lm.profile != loadmodel.ProfileHome || lm.calls != 2 { - t.Fatalf("failed refresh must expire known away window: profile=%v calls=%d", lm.profile, lm.calls) - } -} - -func TestFirstPollFailureDoesNotOverrideManualProfile(t *testing.T) { - lm := &fakeLM{profile: loadmodel.ProfileAway} - s := newTestService(&fakeLP{}, lm) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - s.pollOnce(ctx) - - if lm.calls != 0 || lm.profile != loadmodel.ProfileAway { - t.Fatalf("first failed poll must leave manual profile untouched: profile=%v calls=%d", lm.profile, lm.calls) - } -} - -func TestApplyEVTargetSet(t *testing.T) { - lp := &fakeLP{} - s := newTestService(lp, &fakeLM{}) - now := time.Date(2026, 7, 1, 6, 0, 0, 0, time.UTC) - dep := now.Add(2 * time.Hour) - - s.apply(Intents{EV: []EVDeadline{{LoadpointID: "garage", TargetSoC: 0.8, Departure: dep}}}, now) - - if lp.called != 1 { - t.Fatalf("expected SetTarget once, got %d", lp.called) - } - if lp.id != "garage" || lp.soc != 0.8 || !lp.when.Equal(dep) { - t.Fatalf("SetTarget args wrong: id=%q soc=%v when=%v", lp.id, lp.soc, lp.when) - } -} - -func TestApplyEVPicksEarliestUpcoming(t *testing.T) { - lp := &fakeLP{} - s := newTestService(lp, &fakeLM{}) - now := time.Date(2026, 7, 1, 6, 0, 0, 0, time.UTC) - - s.apply(Intents{EV: []EVDeadline{ - {LoadpointID: "garage", TargetSoC: 0.7, Departure: now.Add(-time.Hour)}, // past — ignored - {LoadpointID: "garage", TargetSoC: 0.9, Departure: now.Add(5 * time.Hour)}, - {LoadpointID: "garage", TargetSoC: 0.6, Departure: now.Add(1 * time.Hour)}, // earliest upcoming - }}, now) - - if lp.soc != 0.6 { - t.Fatalf("expected earliest upcoming deadline (soc 60), got %v", lp.soc) - } -} - -func TestApplyEVMissingLoadpointIgnored(t *testing.T) { - lp := &fakeLP{} - // firstLoadpointID empty → no default → EV event with no id is unactionable. - s := New(config.CalDAV{Enabled: true}, lp, &fakeLM{}, "") - now := time.Now() - s.apply(Intents{EV: []EVDeadline{{TargetSoC: 0.8, Departure: now.Add(time.Hour)}}}, now) - if lp.called != 0 { - t.Fatalf("EV event without a loadpoint must not call SetTarget") - } -} - -func TestIsAwayAt(t *testing.T) { - s := newTestService(&fakeLP{}, &fakeLM{}) - now := time.Now() - s.intents = Intents{Away: []Interval{{Start: now.Add(-time.Hour), End: now.Add(time.Hour)}}} - - if !s.IsAwayAt(now) { - t.Fatal("now should be inside the away window") - } - if s.IsAwayAt(now.Add(2 * time.Hour)) { - t.Fatal("time outside the window should not be away") - } -} - -func TestNewAppliesDefaults(t *testing.T) { - s := New(config.CalDAV{Enabled: true}, &fakeLP{}, &fakeLM{}, "garage") - st := s.Status() - if !st.Enabled { - t.Fatal("should be enabled") - } - // SubscribeURL should join the default URL + default calendar path. - want := joinURL(config.DefaultCalDAVURL, config.DefaultCalDAVCalendarPath) - if st.SubscribeURL != want { - t.Fatalf("subscribe URL: want %q, got %q", want, st.SubscribeURL) - } -} - -func TestStatusNilSafe(t *testing.T) { - var s *Service - if st := s.Status(); st.Enabled { - t.Fatal("nil service status should be zero value") - } - if s.IsAwayAt(time.Now()) { - t.Fatal("nil service is never away") - } -} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 96e2c23c..779e4124 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -34,7 +34,6 @@ type Config struct { Planner *Planner `yaml:"planner,omitempty" json:"planner,omitempty"` Batteries map[string]Battery `yaml:"batteries,omitempty" json:"batteries,omitempty"` EVCharger *EVCharger `yaml:"ev_charger,omitempty" json:"ev_charger,omitempty"` - CalDAV *CalDAV `yaml:"caldav,omitempty" json:"caldav,omitempty"` Loadpoints []Loadpoint `yaml:"loadpoints,omitempty" json:"loadpoints,omitempty"` Vehicles []Vehicle `yaml:"vehicles,omitempty" json:"vehicles,omitempty"` V2X *V2XPolicy `yaml:"v2x,omitempty" json:"v2x,omitempty"` @@ -52,6 +51,8 @@ type Config struct { // write path (Settings save, bootstrap) never populates this — it calls // Validate directly and stays strict. Never serialized. LoadWarnings []string `yaml:"-" json:"-"` + // Used once at startup to end an old calendar's persisted away selection. + RetiredCalendarEnabled bool `yaml:"-" json:"-"` } // OCPP configures the built-in OCPP 1.6J and 2.0.1 Central System. Chargers connect to @@ -676,148 +677,6 @@ type EVChargerModbus struct { UnitID int `yaml:"unit_id,omitempty" json:"unit_id,omitempty"` } -// CalDAV configures the calendar-constraints feature (issue #498). FTW hosts -// its own in-process, pure-Go CalDAV server (emersion/go-webdav, MIT — see -// internal/caldavserver) and runs a CalDAV *client* against it that polls the -// calendar collection and maps events into planner intents: -// -// - an "away"/vacation event switches the load model to its away profile -// for the interval, so the planner conserves battery while the house is -// empty; -// - an EV "charged-by-departure" event sets the matching loadpoint's -// target SoC + deadline, which the MPC already honours. -// -// Events are classified by case-insensitive keyword match on the event -// title (SUMMARY). Keyword lists are configurable so non-English calendars -// work. The whole feature is opt-in (Enabled) and fail-soft: an unreachable -// server never blocks control. -// -// Password is stored in state.db (key "caldav_password"), NOT in config.yaml, -// mirroring EVCharger.Password. -type CalDAV struct { - Enabled bool `yaml:"enabled" json:"enabled"` - - // URL is the base URL of the CalDAV server. Defaults to the in-process - // native server at http://localhost:5232. - URL string `yaml:"url,omitempty" json:"url,omitempty"` - - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Password string `yaml:"-" json:"password,omitempty"` // persisted in state.db, not YAML - - // CalendarPath is the collection path polled for events, relative to URL - // (e.g. "/ftw/energy/" for new configs). The runtime fallback below keeps - // the former path for configs that omitted this field before the rebrand. - CalendarPath string `yaml:"calendar_path,omitempty" json:"calendar_path,omitempty"` - - // PollIntervalS is how often the collection is re-fetched. Default 300s. - PollIntervalS int `yaml:"poll_interval_s,omitempty" json:"poll_interval_s,omitempty"` - - // HorizonDays bounds the calendar-query time range (recurrences are - // expanded server-side within it). Default 7. - HorizonDays int `yaml:"horizon_days,omitempty" json:"horizon_days,omitempty"` - - // EVLoadpointID is the loadpoint an EV event targets when the title - // names no specific one. Empty = the first/only configured loadpoint. - EVLoadpointID string `yaml:"ev_loadpoint_id,omitempty" json:"ev_loadpoint_id,omitempty"` - - EVDefaultTargetSoC float64 `yaml:"ev_default_target_soc,omitempty" json:"ev_default_target_soc,omitempty"` - EVDefaultTargetSoCPct float64 `yaml:"ev_default_target_soc_pct,omitempty" json:"ev_default_target_soc_pct,omitempty"` - - // AwayKeywords / EVKeywords classify an event by its title. Matching is - // case-insensitive substring. Empty lists fall back to the built-in - // defaults (see DefaultAwayKeywords / DefaultEVKeywords). - AwayKeywords []string `yaml:"away_keywords,omitempty" json:"away_keywords,omitempty"` - EVKeywords []string `yaml:"ev_keywords,omitempty" json:"ev_keywords,omitempty"` - - // EVSEHistory (default ON when enabled) makes FTW *write* a calendar - // event for each completed EV charging session into HistoryPath. This is - // an outbound capability — the user subscribes to HistoryPath to see when - // the charger was used. HistoryPath MUST differ from CalendarPath so FTW - // never re-reads its own history events as inbound intents. - EVSEHistory *bool `yaml:"evse_history,omitempty" json:"evse_history,omitempty"` - HistoryPath string `yaml:"history_path,omitempty" json:"history_path,omitempty"` - - // PublishPlan (default ON when enabled) makes FTW write its forward-looking - // plan — upcoming battery charge/discharge windows from the MPC — as - // read-only events into PlanPath (a SEPARATE collection), so you can see - // what FTW intends to do. Reconciled each publish so stale events are - // removed rather than piling up. - PublishPlan *bool `yaml:"publish_plan,omitempty" json:"publish_plan,omitempty"` - PlanPath string `yaml:"plan_path,omitempty" json:"plan_path,omitempty"` - PlanPublishIntervalS int `yaml:"plan_publish_interval_s,omitempty" json:"plan_publish_interval_s,omitempty"` - - // ManageCredentials (default ON when enabled) makes FTW generate a random - // password on first enable, which the in-process CalDAV server then - // authenticates against. The credential is shown in the Settings → Calendar - // tab (with a QR) to paste into a calendar app, so the operator never has to - // set one by hand. - ManageCredentials *bool `yaml:"manage_credentials,omitempty" json:"manage_credentials,omitempty"` - - // Listen is the bind address for the in-process CalDAV server. Default - // ":5232". FTW binds it on the LAN. - Listen string `yaml:"listen,omitempty" json:"listen,omitempty"` -} - -// ListenAddr returns the native CalDAV server bind address (default ":5232"). -func (cv *CalDAV) ListenAddr() string { - if cv != nil && strings.TrimSpace(cv.Listen) != "" { - return strings.TrimSpace(cv.Listen) - } - return ":5232" -} - -// ManageCredentialsEnabled reports whether FTW should auto-generate the managed -// CalDAV credential. Nil-safe; defaults ON when the feature is on. -func (cv *CalDAV) ManageCredentialsEnabled() bool { - return cv != nil && cv.Enabled && (cv.ManageCredentials == nil || *cv.ManageCredentials) -} - -// EVSEHistoryEnabled reports whether FTW should write EV-session history -// events. Nil-safe; defaults ON when the feature is enabled. -func (cv *CalDAV) EVSEHistoryEnabled() bool { - return cv != nil && cv.Enabled && (cv.EVSEHistory == nil || *cv.EVSEHistory) -} - -// PublishPlanEnabled reports whether FTW should publish its forward-looking -// plan calendar. Nil-safe; defaults ON when the feature is enabled. -func (cv *CalDAV) PublishPlanEnabled() bool { - return cv != nil && cv.Enabled && (cv.PublishPlan == nil || *cv.PublishPlan) -} - -// CalDAV defaults. Keyword identifiers are English; operators may override -// with localised terms via config (the values are user-facing). -var ( - DefaultCalDAVURL = "http://localhost:5232" - DefaultCalDAVCalendarPath = "/fortytwowatts/energy/" - DefaultCalDAVHistoryPath = "/fortytwowatts/history/" - DefaultCalDAVPlanPath = "/fortytwowatts/plan/" - DefaultCalDAVPlanPublishS = 900 - DefaultCalDAVUsername = "fortytwowatts" - DefaultCalDAVPollS = 300 - DefaultCalDAVHorizonDays = 7 - DefaultCalDAVEVTargetSoC = 0.8 - DefaultAwayKeywords = []string{"away", "vacation", "holiday"} - DefaultEVKeywords = []string{"ev", "car", "charge"} -) - -// Validate enforces range rules. Defaults are applied by the calendar -// service at construction time, so unset fields are legal here. -func (cv *CalDAV) Validate() error { - if cv == nil || !cv.Enabled { - return nil - } - if cv.PollIntervalS < 0 { - return errors.New("caldav.poll_interval_s must be >= 0") - } - if cv.HorizonDays < 0 { - return errors.New("caldav.horizon_days must be >= 0") - } - if cv.EVDefaultTargetSoC < 0 || cv.EVDefaultTargetSoC > 1 { - return errors.New("caldav.ev_default_target_soc must be in [0, 1]") - } - return nil -} - // Normalize folds the legacy `email:` YAML key into Username and clears // it so subsequent writes use the canonical key. Idempotent. func (e *EVCharger) Normalize() { @@ -1464,11 +1323,6 @@ func (c Config) MaskSecrets() Config { cp.Password = "" out.EVCharger = &cp } - if out.CalDAV != nil { - cp := *out.CalDAV - cp.Password = "" - out.CalDAV = &cp - } if out.HomeAssistant != nil { cp := *out.HomeAssistant cp.Password = "" @@ -1567,9 +1421,6 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { if incoming.EVCharger != nil && existing.EVCharger != nil && incoming.EVCharger.Password == "" { incoming.EVCharger.Password = existing.EVCharger.Password } - if incoming.CalDAV != nil && existing.CalDAV != nil && incoming.CalDAV.Password == "" { - incoming.CalDAV.Password = existing.CalDAV.Password - } // Masked out on the way to the UI, so an unchanged password comes back // empty. Without this a save from the settings tab would blank it, and an // enabled server would then fail validation on the next reload. @@ -1658,6 +1509,17 @@ func Parse(data []byte, baseDir string) (*Config, error) { if err := doc.Decode(&c); err != nil { return nil, fmt.Errorf("yaml: %w", err) } + // Old calendars no longer supply occupancy or charging targets. Read only + // the enable flag so obsolete settings cannot prevent an upgrade from booting. + var retired struct { + CalDAV struct { + Enabled bool `yaml:"enabled"` + } `yaml:"caldav"` + } + if doc.Decode(&retired) == nil && retired.CalDAV.Enabled { + c.RetiredCalendarEnabled = true + c.LoadWarnings = append(c.LoadWarnings, "Calendar support has been removed. Set future charging targets under Loadpoints; calendar events no longer change charging or occupancy. Stored calendar data remains in state.db.") + } // An omitted app_link section follows the new default. An explicit YAML // null was a valid opt-out before that default changed, so retain it as an // explicit disabled section instead of letting applyDefaults turn it on. @@ -2088,9 +1950,6 @@ func (c *Config) Validate() error { return err } } - if err := c.CalDAV.Validate(); err != nil { - return err - } if err := c.FleetPing.Validate(); err != nil { return err } diff --git a/go/internal/config/restart_required.go b/go/internal/config/restart_required.go index 8233d7c8..6ba58fef 100644 --- a/go/internal/config/restart_required.go +++ b/go/internal/config/restart_required.go @@ -16,7 +16,7 @@ import "reflect" // {pv_rated_w, latitude, longitude}, fleet_ping.enabled, and // home_assistant.* reload live. // - Everything else (api.port, state.path, price.*, planner.*, nova.*, -// ev_charger.*, caldav.*, weather.provider/arrays, +// ev_charger.*, weather.provider/arrays, // site.control_interval_s, site.watchdog_timeout_s, site.smoothing_alpha, // site.gain) needs the binary restarted to take effect. // @@ -88,9 +88,6 @@ func RestartRequiredFor(oldCfg, newCfg *Config) []string { if !pointerEqual(oldCfg.EVCharger, newCfg.EVCharger) { reasons = append(reasons, "ev_charger — EV charger client is constructed once at startup") } - if !pointerEqual(oldCfg.CalDAV, newCfg.CalDAV) { - reasons = append(reasons, "caldav — native server, credentials, collections, and polling are wired at startup") - } // Weather: PVRatedW, Latitude, Longitude reload live; everything else // (provider, arrays, tilt/azimuth, heating coefficient) is captured diff --git a/go/internal/config/restart_required_test.go b/go/internal/config/restart_required_test.go index 3410a7c2..3ec893fe 100644 --- a/go/internal/config/restart_required_test.go +++ b/go/internal/config/restart_required_test.go @@ -86,9 +86,6 @@ func TestRestartRequiredFor_BootSections(t *testing.T) { {"ocpp enabled", func(c *Config) { c.OCPP = &OCPP{Enabled: true, Port: 8887, Username: "ftw", Password: "long-random-string"} }, "ocpp"}, - {"caldav credentials changed", func(c *Config) { - c.CalDAV = &CalDAV{Enabled: true, Username: "calendar-user", Password: "rotated"} - }, "caldav"}, {"weather provider change", func(c *Config) { c.Weather = &Weather{Provider: "open_meteo", Latitude: 59, Longitude: 18} }, "weather"}, diff --git a/go/internal/config/retired_calendar_test.go b/go/internal/config/retired_calendar_test.go new file mode 100644 index 00000000..9777549d --- /dev/null +++ b/go/internal/config/retired_calendar_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRetiredCalendarDoesNotBlockConfigOrReturnCredentials(t *testing.T) { + cfg, err := Parse([]byte(minimalYAML+` +caldav: + enabled: true + password: old-calendar-secret + url: http://old-calendar.invalid + poll_interval_s: -1 + ev_default_target_soc: 80 +loadpoints: + - id: garage + driver: charger +`), "/tmp") + if err != nil { + t.Fatal(err) + } + if len(cfg.Loadpoints) != 1 || cfg.Loadpoints[0].ID != "garage" { + t.Fatal("calendar removal lost the ordinary loadpoint") + } + if len(cfg.LoadWarnings) != 1 || !strings.Contains(cfg.LoadWarnings[0], "Calendar support has been removed") { + t.Fatalf("missing upgrade warning: %v", cfg.LoadWarnings) + } + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "caldav") || strings.Contains(string(raw), "old-calendar-secret") { + t.Fatal("retired calendar settings or credentials returned to the UI") + } +} diff --git a/go/internal/config/units.go b/go/internal/config/units.go index f79e8f32..864dea92 100644 --- a/go/internal/config/units.go +++ b/go/internal/config/units.go @@ -22,10 +22,6 @@ func (c *Config) NormalizeUnits() { p.SoCMinPct = 0 p.SoCMaxPct = 0 } - if c.CalDAV != nil { - c.CalDAV.EVDefaultTargetSoC = pickFraction(c.CalDAV.EVDefaultTargetSoC, c.CalDAV.EVDefaultTargetSoCPct) - c.CalDAV.EVDefaultTargetSoCPct = 0 - } if c.V2X != nil { c.V2X.MinReserveSoC = pickFraction(c.V2X.MinReserveSoC, c.V2X.MinReserveSoCPct) c.V2X.DepartureTargetSoC = pickFraction(c.V2X.DepartureTargetSoC, c.V2X.DepartureTargetSoCPct) diff --git a/go/internal/config/units_test.go b/go/internal/config/units_test.go index dad65845..93f6a960 100644 --- a/go/internal/config/units_test.go +++ b/go/internal/config/units_test.go @@ -13,7 +13,6 @@ func TestNormalizeUnitsFoldsLegacyKWpAndPercent(t *testing.T) { {Name: "south", KWp: 6, TiltDeg: &tilt, AzimuthDeg: &az}, }, }, - CalDAV: &CalDAV{Enabled: true, EVDefaultTargetSoCPct: 80}, Site: Site{PVSurplusAbsorbSoCCapPct: 88}, Vehicles: []Vehicle{{ID: "leaf", TargetSoCPct: 80}}, } @@ -27,9 +26,6 @@ func TestNormalizeUnitsFoldsLegacyKWpAndPercent(t *testing.T) { if c.Weather.PVArrays[1].RatedW != 6000 { t.Fatalf("6 kWp → %v W, want 6000", c.Weather.PVArrays[1].RatedW) } - if c.CalDAV.EVDefaultTargetSoC != 0.80 { - t.Fatalf("caldav default SoC = %v, want 0.80", c.CalDAV.EVDefaultTargetSoC) - } if c.Site.PVSurplusAbsorbSoCCap != 0.88 { t.Fatalf("absorb cap = %v, want 0.88", c.Site.PVSurplusAbsorbSoCCap) } diff --git a/go/internal/state/caldav.go b/go/internal/state/caldav.go deleted file mode 100644 index 06f3b08e..00000000 --- a/go/internal/state/caldav.go +++ /dev/null @@ -1,108 +0,0 @@ -package state - -import ( - "database/sql" - "fmt" -) - -// CalDAVObject is one stored calendar object (.ics) for the native in-process -// CalDAV server (#498). Data is the raw iCalendar text. -type CalDAVObject struct { - Path string - Collection string - ETag string - Data string - ModifiedMs int64 -} - -// CalDAVCalendar is a stored calendar collection. -type CalDAVCalendar struct { - Path string - Name string - Description string -} - -// SaveCalDAVObject upserts one calendar object. -func (s *Store) SaveCalDAVObject(o CalDAVObject) error { - if o.Path == "" { - return fmt.Errorf("SaveCalDAVObject: empty path") - } - _, err := s.db.Exec( - `INSERT INTO caldav_objects (path, collection, etag, data, modified_ms) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (path) DO UPDATE SET - collection=excluded.collection, etag=excluded.etag, - data=excluded.data, modified_ms=excluded.modified_ms`, - o.Path, o.Collection, o.ETag, o.Data, o.ModifiedMs) - return err -} - -// GetCalDAVObject returns one object by path; ok=false when absent. -func (s *Store) GetCalDAVObject(path string) (CalDAVObject, bool, error) { - row := s.db.QueryRow( - `SELECT path, collection, etag, data, modified_ms FROM caldav_objects WHERE path = ?`, path) - var o CalDAVObject - err := row.Scan(&o.Path, &o.Collection, &o.ETag, &o.Data, &o.ModifiedMs) - if err == sql.ErrNoRows { - return CalDAVObject{}, false, nil - } - if err != nil { - return CalDAVObject{}, false, err - } - return o, true, nil -} - -// ListCalDAVObjects returns every object in a collection (indexed scan). -func (s *Store) ListCalDAVObjects(collection string) ([]CalDAVObject, error) { - rows, err := s.db.Query( - `SELECT path, collection, etag, data, modified_ms FROM caldav_objects WHERE collection = ?`, collection) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CalDAVObject - for rows.Next() { - var o CalDAVObject - if err := rows.Scan(&o.Path, &o.Collection, &o.ETag, &o.Data, &o.ModifiedMs); err != nil { - return out, err - } - out = append(out, o) - } - return out, rows.Err() -} - -// DeleteCalDAVObject removes one object; nil even if it was already absent. -func (s *Store) DeleteCalDAVObject(path string) error { - _, err := s.db.Exec(`DELETE FROM caldav_objects WHERE path = ?`, path) - return err -} - -// SaveCalDAVCalendar upserts a calendar collection. -func (s *Store) SaveCalDAVCalendar(c CalDAVCalendar) error { - if c.Path == "" { - return fmt.Errorf("SaveCalDAVCalendar: empty path") - } - _, err := s.db.Exec( - `INSERT INTO caldav_calendars (path, name, description) VALUES (?, ?, ?) - ON CONFLICT (path) DO UPDATE SET name=excluded.name, description=excluded.description`, - c.Path, c.Name, c.Description) - return err -} - -// ListCalDAVCalendars returns all calendar collections. -func (s *Store) ListCalDAVCalendars() ([]CalDAVCalendar, error) { - rows, err := s.db.Query(`SELECT path, name, description FROM caldav_calendars`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []CalDAVCalendar - for rows.Next() { - var c CalDAVCalendar - if err := rows.Scan(&c.Path, &c.Name, &c.Description); err != nil { - return out, err - } - out = append(out, c) - } - return out, rows.Err() -} diff --git a/go/internal/state/retired_calendar.go b/go/internal/state/retired_calendar.go new file mode 100644 index 00000000..7fbf54d0 --- /dev/null +++ b/go/internal/state/retired_calendar.go @@ -0,0 +1,26 @@ +package state + +// RetireCalendarProfile ends the old calendar's persisted away selection once. +// The marker and selection commit together. Model weights and old forecasts +// remain unchanged, and later manual profile choices survive each restart. +func (s *Store) RetireCalendarProfile() error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES ('calendar/retired_v1', '1')`) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 0 { + if _, err := tx.Exec(`INSERT INTO config (key, value) VALUES ('loadmodel/profile', 'home') ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/go/internal/state/retired_calendar_test.go b/go/internal/state/retired_calendar_test.go new file mode 100644 index 00000000..3853b0e3 --- /dev/null +++ b/go/internal/state/retired_calendar_test.go @@ -0,0 +1,90 @@ +package state + +import ( + "path/filepath" + "testing" +) + +func TestRetiredCalendarSurvivesUpgradeAndBackup(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + const calendar = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Charge car 80%\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + // The old release created these tables. New installs must not create them. + for _, query := range []string{ + `CREATE TABLE caldav_calendars (path TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '') STRICT`, + `CREATE TABLE caldav_objects (path TEXT PRIMARY KEY NOT NULL, collection TEXT NOT NULL, etag TEXT NOT NULL, data TEXT NOT NULL, modified_ms INTEGER NOT NULL) STRICT`, + `INSERT INTO caldav_calendars VALUES ('/ftw/energy/', 'Energy', '')`, + } { + if _, err := s.db.Exec(query); err != nil { + t.Fatal(err) + } + } + if _, err := s.db.Exec(`INSERT INTO caldav_objects VALUES ('/ftw/energy/car.ics', '/ftw/energy/', 'etag', ?, 42)`, calendar); err != nil { + t.Fatal(err) + } + for key, value := range map[string]string{ + "caldav_password": "old-calendar-password", + "forecast/site_id": "stable-site", + "forecast/energyplan_state_v1": "{ \"opaque\": true }", + "loadmodel/home": "learned", + "loadmodel/profile": "away", + } { + if err := s.SaveConfig(key, value); err != nil { + t.Fatal(err) + } + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + upgraded, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { upgraded.Close() }) + if err := upgraded.RetireCalendarProfile(); err != nil { + t.Fatal(err) + } + if got, _ := upgraded.LoadConfig("loadmodel/profile"); got != "home" { + t.Fatalf("old away selection survived: %q", got) + } + if err := upgraded.SaveConfig("loadmodel/profile", "away"); err != nil { + t.Fatal(err) + } + if err := upgraded.RetireCalendarProfile(); err != nil { + t.Fatal(err) + } + if got, _ := upgraded.LoadConfig("loadmodel/profile"); got != "away" { + t.Fatal("migration replaced a later manual choice") + } + backupPath := filepath.Join(t.TempDir(), "backup.db") + if err := upgraded.SnapshotTo(backupPath); err != nil { + t.Fatal(err) + } + backup, err := Open(backupPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { backup.Close() }) + for _, store := range []*Store{upgraded, backup} { + var data string + if err := store.db.QueryRow(`SELECT data FROM caldav_objects WHERE path = '/ftw/energy/car.ics'`).Scan(&data); err != nil { + t.Fatal(err) + } + if data != calendar { + t.Fatal("calendar bytes changed") + } + for key, want := range map[string]string{ + "caldav_password": "old-calendar-password", + "forecast/site_id": "stable-site", + "forecast/energyplan_state_v1": "{ \"opaque\": true }", + "loadmodel/home": "learned", + } { + if got, ok := store.LoadConfig(key); !ok || got != want { + t.Fatalf("%s changed: %q", key, got) + } + } + } +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index ef67150c..f30b99b7 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -767,24 +767,6 @@ func (s *Store) migrate() error { json TEXT NOT NULL ) STRICT`, - // CalDAV objects + collections for the native in-process CalDAV server - // (#498). One row per calendar object (.ics), - // keyed by its full path; `collection` is the parent collection path so - // listing a calendar is an indexed scan. `data` is the raw iCalendar. - `CREATE TABLE IF NOT EXISTS caldav_calendars ( - path TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '' - ) STRICT`, - `CREATE TABLE IF NOT EXISTS caldav_objects ( - path TEXT PRIMARY KEY NOT NULL, - collection TEXT NOT NULL, - etag TEXT NOT NULL, - data TEXT NOT NULL, - modified_ms INTEGER NOT NULL - ) STRICT`, - `CREATE INDEX IF NOT EXISTS idx_caldav_objects_collection ON caldav_objects(collection)`, - // Ask why conversations. One row per thread; the turns are JSON // because a thread is read and written whole and is never queried // by its contents. Capped at AssistantThreadCap rows on write — diff --git a/go/internal/state/store_test.go b/go/internal/state/store_test.go index 0c5089c5..b66ac7a2 100644 --- a/go/internal/state/store_test.go +++ b/go/internal/state/store_test.go @@ -40,7 +40,7 @@ func freshStore(t *testing.T) *Store { func TestNewStoreDoesNotCreateRetiredOwnerTables(t *testing.T) { s := freshStore(t) - for _, table := range []string{"trusted_devices", "owner_sessions", "trusted_device_pubkeys"} { + for _, table := range []string{"trusted_devices", "owner_sessions", "trusted_device_pubkeys", "caldav_objects", "caldav_calendars"} { var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil { t.Fatal(err) diff --git a/go/internal/units/consistency_test.go b/go/internal/units/consistency_test.go index 54ce4287..2032e864 100644 --- a/go/internal/units/consistency_test.go +++ b/go/internal/units/consistency_test.go @@ -5,7 +5,6 @@ import ( "reflect" "testing" - "github.com/srcfl/ftw/go/internal/calendar" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/forecast" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -178,25 +177,6 @@ func TestVehiclePickSoCIsFraction(t *testing.T) { } } -func TestCalendarDeadlineSoCIsFraction(t *testing.T) { - typ := reflect.TypeOf(calendar.EVDeadline{}) - if _, ok := typ.FieldByName("TargetSoCPct"); ok { - t.Fatal("calendar.EVDeadline.TargetSoCPct must not exist; store TargetSoC as 0–1") - } - d := calendar.EVDeadline{TargetSoC: 0.80} - raw, err := json.Marshal(d) - if err != nil { - t.Fatal(err) - } - var m map[string]any - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatal(err) - } - if _, ok := m["target_soc_pct"]; ok { - t.Fatalf("EVDeadline JSON still emits target_soc_pct: %s", raw) - } -} - func TestV2XEnvelopeSoCJSONIsFraction(t *testing.T) { env := v2x.Envelope{MinReserveSoC: 0.35, DepartureTargetSoC: 0.80} raw, err := json.Marshal(env) diff --git a/web/index.html b/web/index.html index aa8f7188..ddbff755 100644 --- a/web/index.html +++ b/web/index.html @@ -102,7 +102,6 @@

Settings

- @@ -985,7 +984,6 @@

Price bars (top of the chart)

- diff --git a/web/settings/tabs/caldav.js b/web/settings/tabs/caldav.js deleted file mode 100644 index c733dfc9..00000000 --- a/web/settings/tabs/caldav.js +++ /dev/null @@ -1,245 +0,0 @@ -// Settings → Calendar tab: CalDAV planner-constraints client (#498). -// FTW can manage the calendar credential itself and show it here (with a QR) -// to add to a phone/desktop calendar app. All server-supplied values are -// rendered via textContent / DOM nodes (never innerHTML) so a hostile or -// MITM'd CalDAV server cannot inject markup into the local settings page. -(function () { - var S = (window.FTWSettings = window.FTWSettings || { tabs: {} }); - S.tabs = S.tabs || {}; - - function apiFetch(path, opts) { - return fetch(path, opts); - } - - // Calendar apps on the LAN can't reach "localhost" (that's FTW's own link to - // its in-process CalDAV server); rewrite it to the host serving this dashboard. - function lanURL(u) { - if (!u) return u; - try { - var url = new URL(u); - if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { - url.hostname = window.location.hostname; - } - return url.toString(); - } catch (e) { - return u; - } - } - - // webcalFeed turns a collection's http(s) URL into a one-tap webcal:// link to - // its read-only .ics feed, with the managed credential embedded so the phone - // subscribes without a manual login. Built by string (the URL API refuses to - // switch a special http scheme to the non-special webcal scheme). - function webcalFeed(httpUrl, name, user, pass) { - try { - var u = new URL(lanURL(httpUrl)); - var auth = user ? encodeURIComponent(user) + ":" + encodeURIComponent(pass || "") + "@" : ""; - return "webcal://" + auth + u.host + "/feed/" + name + ".ics"; - } catch (e) { - return null; - } - } - - // el is a tiny safe DOM builder — text is set via textContent, never parsed. - function el(tag, props, kids) { - var n = document.createElement(tag); - if (props) { - Object.keys(props).forEach(function (k) { - if (k === "text") n.textContent = props[k]; - else if (k === "class") n.className = props[k]; - else n.setAttribute(k, props[k]); - }); - } - (kids || []).forEach(function (c) { - if (c) n.appendChild(c); - }); - return n; - } - - function copyBtn(getText) { - var b = el("button", { class: "copy-btn", type: "button", text: "Copy" }); - b.addEventListener("click", function () { - var t = getText(); - if (navigator.clipboard) navigator.clipboard.writeText(t); - b.textContent = "Copied"; - setTimeout(function () { b.textContent = "Copy"; }, 1200); - }); - return b; - } - - // calendarCard renders one calendar as a self-contained block: its name, a - // one-line description of what it does, the link/URL, a Copy button, and a QR. - // c.clickable → render the link as a tappable (the read-only webcal:// - // feeds); otherwise a plain URL to add as an account (read + write). - // The QR is filled in later (after the qrcode.js dynamic import) into c.qrSlot. - function calendarCard(c) { - var value = c.clickable - ? el("a", { class: "caldav-url", href: c.link, target: "_blank", rel: "noopener noreferrer", text: c.displayText || c.link }) - : el("code", { class: "caldav-val", text: c.link }); - c.qrSlot = el("div", { class: "caldav-qr" }); - return el("div", { class: "caldav-cal" }, [ - el("div", { class: "caldav-cal-title", text: c.label }), - el("p", { class: "caldav-cal-desc", text: c.desc }), - el("div", { class: "caldav-row" }, [value, copyBtn(function () { return c.link; })]), - c.qrSlot, - ]); - } - - // drawQR paints qrMatrix(text) into a canvas. - function drawQR(qrMatrix, text, target) { - var matrix = qrMatrix(text); - var n = matrix.length, quiet = 4, total = n + 2 * quiet; - var px = Math.max(2, Math.floor((target || 220) / total)); - var size = total * px; - var canvas = el("canvas"); - canvas.width = size; canvas.height = size; - // No fixed inline width/height — .caldav-qr canvas { max-width:100%; height:auto } - // lets the code scale down inside a narrow phone modal without distortion. - var ctx = canvas.getContext("2d"); - ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - ctx.fillStyle = "#0a0a0a"; - for (var r = 0; r < n; r++) { - for (var c = 0; c < n; c++) { - if (matrix[r][c]) ctx.fillRect((c + quiet) * px, (r + quiet) * px, px, px); - } - } - return canvas; - } - - S.tabs.caldav = { - render: function (ctx) { - var field = ctx.field, config = ctx.config; - if (!config.caldav) config.caldav = {}; - if (config.caldav.ev_default_target_soc == null && config.caldav.ev_default_target_soc_pct != null) { - config.caldav.ev_default_target_soc = config.caldav.ev_default_target_soc_pct / 100; - } - delete config.caldav.ev_default_target_soc_pct; - var managed = config.caldav.manage_credentials !== false; // default on - var html = - '
checking…
' + - '
Calendar (CalDAV)' + - '

FTW hosts its own built-in CalDAV server and turns the events you add into planner constraints. It stays on your local network — nothing here is exposed to the internet.

' + - '" + - '" + - '
' + - field("Server URL", "caldav.url", "text", "http://localhost:5232", "Base URL of the CalDAV server. Defaults to FTW's built-in in-process server.") + - "
" + - field("Calendar path", "caldav.calendar_path", "text", "/ftw/energy/", "Collection FTW reads events from.") + - "
" + - field("Username", "caldav.username", "text", "ftw"); - if (!managed) { - html += field("Password", "caldav.password", "password", "", "Only used when FTW is not managing the password."); - } - html += - '
' + - field("History path", "caldav.history_path", "text", "/ftw/history/", "Read-only EV charging history collection.") + - "
" + - field("Plan path", "caldav.plan_path", "text", "/ftw/plan/", "Read-only planned energy windows collection.") + - "
" + - '
' + - field("Poll interval (s)", "caldav.poll_interval_s", "number", 300) + - "
" + - field("Default EV target SoC (0–1)", "caldav.ev_default_target_soc", "number", 0.80) + - "
" + - '" + - '" + - '

Title an event Away or Vacation to conserve battery while you\'re out. Title one Charge car 80% (with your departure as the event time) so the car is ready in time.

' + - '
' + - '
' + - '
' + - "
"; - return html; - }, - after: function () { - var ind = document.getElementById("caldav-status-indicator"); - var credsEl = document.getElementById("caldav-creds"); - var urlsEl = document.getElementById("caldav-urls"); - var qrEl = document.getElementById("caldav-qr"); - - function refreshStatus() { - apiFetch("/api/caldav/status").then(function (r) { return r.json(); }).then(function (d) { - if (!ind) return; - if (!d.enabled) { - ind.className = "ha-status-indicator ha-off"; - ind.textContent = "○ disabled in config"; - return; - } - if (d.reachable) { - var age = d.last_sync_ms > 0 ? Math.round((Date.now() - d.last_sync_ms) / 1000) + "s ago" : "no sync yet"; - var bits = (d.event_count || 0) + " intent" + (d.event_count === 1 ? "" : "s"); - if (d.away_active) bits += " · away active"; - if (d.history_written) bits += " · " + d.history_written + " history written"; - if (d.plan_enabled) bits += " · " + (d.plan_events || 0) + " plan window" + (d.plan_events === 1 ? "" : "s"); - ind.className = "ha-status-indicator ha-ok"; - ind.textContent = "● reachable · " + bits + " · last sync " + age; - } else { - ind.className = "ha-status-indicator ha-warn"; - ind.textContent = "⚠ CalDAV server unreachable — check it is running"; - } - }).catch(function () { - if (ind) { ind.className = "ha-status-indicator ha-warn"; ind.textContent = "? status endpoint unreachable"; } - }); - } - - function refreshCreds() { - apiFetch("/api/caldav/credentials").then(function (r) { return r.json(); }).then(function (d) { - if (credsEl) { - credsEl.textContent = ""; - if (d.managed && d.username && d.password) { - var box = el("fieldset", { class: "caldav-account" }, [ - el("legend", { text: "Calendar account (managed by FTW)" }), - el("div", { class: "caldav-row" }, [el("label", { text: "Username" }), el("code", { class: "caldav-val", text: d.username }), copyBtn(function () { return d.username; })]), - el("div", { class: "caldav-row" }, [el("label", { text: "Password" }), el("code", { class: "caldav-val", text: d.password }), copyBtn(function () { return d.password; })]), - ]); - credsEl.appendChild(box); - } - } - // One card per calendar: name + what it does + link + QR. - var calendars = []; - if (d.subscribe_url) calendars.push({ - label: "Subscribe (read + write)", - desc: "Add this as a calendar account, then create “Away” or “Charge car 80%” events for FTW to act on. Scan the QR to get the URL onto your phone.", - link: lanURL(d.subscribe_url), - clickable: false, - }); - if (d.plan_url) calendars.push({ - label: "Planned actions (read-only)", - desc: "Subscribe to see FTW’s upcoming battery charge / discharge windows. Tap the link or scan the QR to subscribe in one step.", - link: webcalFeed(d.plan_url, "plan", d.username, d.password), - displayText: webcalFeed(d.plan_url, "plan"), - clickable: true, - }); - if (d.history_url) calendars.push({ - label: "EVSE history (read-only)", - desc: "Subscribe to get one event per completed EV charge session. Tap the link or scan the QR to subscribe in one step.", - link: webcalFeed(d.history_url, "history", d.username, d.password), - displayText: webcalFeed(d.history_url, "history"), - clickable: true, - }); - - if (qrEl) qrEl.textContent = ""; - if (urlsEl) { - urlsEl.textContent = ""; - calendars.forEach(function (c) { - if (c.link) urlsEl.appendChild(calendarCard(c)); - }); - // One dynamic import paints every card's QR (read + write URL, and - // the read-only webcal:// feeds). - import("/vendor/qrcode.js").then(function (m) { - calendars.forEach(function (c) { - if (!c.link || !c.qrSlot) return; - c.qrSlot.appendChild(el("div", { class: "hint", text: "Scan with your phone:" })); - c.qrSlot.appendChild(drawQR(m.qrMatrix, c.link, 160)); - }); - }).catch(function () { /* QR optional */ }); - } - }).catch(function () { /* credentials endpoint optional */ }); - } - - refreshStatus(); - refreshCreds(); - if (window._caldavStatusTimer) clearInterval(window._caldavStatusTimer); - window._caldavStatusTimer = setInterval(refreshStatus, 5000); - }, - }; -})(); diff --git a/web/style.css b/web/style.css index 9edcd812..c5b34b80 100644 --- a/web/style.css +++ b/web/style.css @@ -1731,40 +1731,7 @@ footer { color: var(--fg-dim, #a0a0a0); } -/* ---- Calendar (CalDAV) settings tab ---- */ -/* Credential + subscribe-URL rows. The label sits on its own line; the value - (a long collection URL) is allowed to wrap instead of forcing the whole - settings modal wider than a phone screen; the Copy button stays put. */ -.caldav-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin: 8px 0; -} -.caldav-row > label { - flex: 1 0 100%; - margin: 0; - font-size: 0.8rem; - color: var(--text-dim); -} -.caldav-row > .caldav-url, -.caldav-row > .caldav-val { - flex: 1 1 12ch; - min-width: 0; /* key: lets the value shrink + wrap, never overflow-x */ - overflow-wrap: anywhere; - word-break: break-all; - font-family: var(--mono); - font-size: 0.8rem; - padding: 6px 8px; - background: var(--surface2); - border: 1px solid var(--border); - border-radius: 4px; - color: var(--text); -} -.caldav-row > a.caldav-url { text-decoration: none; } -.caldav-row > a.caldav-url:hover { border-color: var(--accent); } -.caldav-row > .copy-btn { flex: 0 0 auto; } +.copy-btn { flex: 0 0 auto; } .copy-btn { padding: 5px 10px; @@ -1778,37 +1745,6 @@ footer { } .copy-btn:hover { border-color: var(--accent); } -.caldav-qr { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - margin-top: 14px; -} -.caldav-qr canvas { - max-width: 100%; - height: auto; -} - -/* Per-calendar card: name + what-it-does + link + QR, one per collection. */ -.caldav-cal { - border: 1px solid var(--border); - border-radius: 6px; - padding: 10px 12px; - margin: 10px 0; -} -.caldav-cal-title { - font-size: 0.82rem; - font-weight: 600; - color: var(--text); -} -.caldav-cal-desc { - margin: 2px 0 8px; - font-size: 0.78rem; - line-height: 1.4; - color: var(--text-dim); -} - /* ---- The FTW app settings tab ---- */ /* One paired phone. The row is the whole of sharing: who this is, what they may do, when they were last here, and the buttons that change it. It went