Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retire-built-in-calendar.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
30 changes: 0 additions & 30 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:`.
Expand Down
2 changes: 0 additions & 2 deletions deploy/pi-gen/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions deploy/pi-gen/stage-ftw/01-ftw-setup/00-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 0 additions & 18 deletions docker-compose.macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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://<mac-ip>: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
Expand Down Expand Up @@ -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://<mac-ip>:5232/<user>/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:
17 changes: 0 additions & 17 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host-ip>:5232/<user>/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:
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
153 changes: 14 additions & 139 deletions docs/caldav-integration.md
Original file line number Diff line number Diff line change
@@ -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://<host-ip>: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:<id>` 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://<host-ip>: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.
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading