Repository navigation
Software Design Document
Applies to integration 2.0.x · branch
ha2026_09_dev· version2.0.0-beta.2· Home Assistant 2026.9+Companion documents: Architecture Design Document · Workflow Diagrams · Documentation home · Archived 1.1.3 SDD.
custom_components/timetree/
├── __init__.py # runtime data, hub device, platforms, services, options listener
├── api.py # TimeTreeApi: transport, auth, read + write endpoints, errors
├── calendar.py # TimeTreeCalendarEntity: read + create/update/delete
├── config_flow.py # setup flow (user, calendars, reauth) and options flow menu
├── conflicts.py # EventState, SyncRecord, Conflict, decide(), markers (HA free)
├── const.py # domain, API constants, option keys, defaults, service names
├── coordinator.py # TimeTreeCoordinator + TimeTreeData/CalendarData snapshots
├── diagnostics.py # redacted config-entry diagnostics
├── entity.py # device identifiers + shared entity base classes
├── export.py # ExportManager, CalendarTarget, TargetWrite, ExportReport
├── models.py # TimeTreeLabel/Calendar/Event, parsing, payload builder (HA free)
├── options.py # TimeTreeOptions.from_entry (incl. 1.x migration)
├── recurrence.py # occurrence expansion with dateutil (HA free)
├── sensor.py # last updated, sync conflicts, last export, export status
├── services.py # service schemas + handlers
├── store.py # TimeTreeSyncStore (records, conflicts, meta)
├── manifest.json # HA metadata, requirements, minimum version
├── services.yaml # service field descriptions
├── strings.json # English source strings for flows and services
├── translations/ # en.json, de.json
└── brand/ # icon, icon@2x, logo, logo@2x, dark_logo, dark_logo@2x
| Module | Home Assistant free | Purpose | Key symbols |
|---|---|---|---|
models.py |
✅ | parse API payloads, all-day/timezone rules, write payload builder, descriptions |
TimeTreeEvent, TimeTreeCalendar, TimeTreeLabel, build_event_payload, describe_event, filter_events, ms_to_datetime, date_to_utc_ms, get_zone
|
recurrence.py |
✅ | expand occurrences for a window |
Occurrence, split_recurrence_lines, normalize_rule, expand_event, expand_events, MAX_OCCURRENCES, MAX_EXPANSION_DAYS
|
conflicts.py |
✅ | change detection, decision table, conflict + record models, export markers |
Action, ConflictKind, EventState, SyncRecord, Conflict, decide, resolution_to_action, add_marker, find_marker, strip_marker
|
const.py |
✅ | single source of truth for keys/defaults/limits |
DOMAIN, CONF_*, DEFAULT_*, POLICY_*, RESOLUTION_*, SERVICE_*
|
options.py |
– | normalise entry data/options, migrate 1.x |
CalendarOption, TimeTreeOptions
|
api.py |
– | HTTP transport and endpoint knowledge |
TimeTreeApi, create_transport, TimeTreeAuthError, TimeTreeApiError, TimeTreeWriteError
|
coordinator.py |
– | polling and snapshots, write passthrough |
TimeTreeCoordinator, TimeTreeData, CalendarData
|
entity.py |
– | device registry identifiers, shared bases |
hub_identifier, calendar_identifier, TimeTreeEntity, TimeTreeCalendarEntityBase
|
calendar.py |
– | calendar entity (read + write) |
TimeTreeCalendarEntity, _resolve_times
|
sensor.py |
– | status/conflict sensors |
TimeTreeLastUpdatedSensor, TimeTreeStoreSensor, TimeTreeConflictSensor, TimeTreeLastExportSensor, TimeTreeExportStatusSensor
|
config_flow.py |
– | setup and options |
TimeTreeConfigFlow, TimeTreeOptionsFlow, _calendar_select, _definitions
|
services.py |
– | service surface |
async_register_services, async_remove_services, schemas |
store.py |
– | persistence |
TimeTreeSyncStore, SAVE_DELAY
|
export.py |
– | export loop and conflict engine |
ExportManager, CalendarTarget, TargetWrite, ExportReport, extract_created_uuid, STARTUP_DELAY
|
diagnostics.py |
– | support dump |
async_get_config_entry_diagnostics, TO_REDACT
|
classDiagram
class TimeTreeApi {
+bool browser_transport
+async_validate() list
+async_get_calendars() list
+async_fetch_events(calendar, since, include_comments, include_birthdays) list
+async_create_event(calendar, payload) dict
+async_update_event(calendar, uuid, payload) dict
+async_delete_event(calendar, uuid)
+async_close()
}
class TimeTreeCoordinator {
+TimeTreeOptions options
+calendar_ids
+calendar_meta(calendar_id) TimeTreeCalendar
+async_update_options(options)
+async_create_event(calendar_id, payload)
+async_update_event(calendar_id, uuid, payload)
+async_delete_event(calendar_id, uuid)
-_async_update_data() TimeTreeData
}
class TimeTreeData {
+calendars
}
class CalendarData {
+TimeTreeCalendar calendar
+events
}
class TimeTreeEvent {
+str uuid
+int start_at
+int end_at
+bool all_day
+recurrences
+str rrule_line
+ha_start
+ha_end
+fingerprint() str
}
class TimeTreeCalendarEntity {
+CalendarEvent event
+async_get_events(hass, start, end)
+async_create_event(**kwargs)
+async_update_event(uid, event, ...)
+async_delete_event(uid, ...)
}
class ExportManager {
+async_start()
+async_run(reason, dry_run) ExportReport
+async_resolve_conflict(conflict_id, resolution) bool
+async_resolve_all(resolution)
}
class CalendarTarget {
+supports(feature) bool
+async_get_events(start, end)
+async_create(write)
+async_update(uid, write)
+async_delete(uid)
}
class TimeTreeSyncStore {
+records
+conflicts
+async_load()
+async_save_now()
+set_record(record)
+upsert_conflict(conflict)
+set_meta(key, value)
}
class EventState {
+summary
+description
+start
+end
+fingerprint() str
}
TimeTreeCoordinator --> TimeTreeApi
TimeTreeCoordinator --> TimeTreeData
TimeTreeData --> CalendarData
CalendarData --> TimeTreeEvent
TimeTreeCalendarEntity --> TimeTreeCoordinator
TimeTreeCalendarEntity --> TimeTreeEvent
ExportManager --> TimeTreeCoordinator
ExportManager --> CalendarTarget
ExportManager --> TimeTreeSyncStore
ExportManager --> EventState
TimeTreeCoordinator --> TimeTreeEvent
| Aspect | Contract |
|---|---|
| Construction |
TimeTreeApi(hass, email, password, *, impersonate="firefox135"); builds the transport eagerly, exposes browser_transport: bool
|
| Threading | all blocking methods are private (_login, _get_events, _create_event, …) and are always executed through hass.async_add_executor_job
|
| Session | lazily logs in, stores the _session_id cookie, re-logs in once on 401
|
| Read calls |
async_validate, async_get_calendars, async_get_user_id, async_fetch_events(calendar, *, since=0, include_comments=False, include_birthdays=False)
|
| Write calls |
async_create_event(calendar, payload), async_update_event(calendar, uuid, payload), async_delete_event(calendar, uuid) where payload comes from models.build_event_payload
|
| Errors |
TimeTreeAuthError (credentials/blocked), TimeTreeApiError (status, body), TimeTreeWriteError (WAF/route rejection) |
| Cleanup |
async_close() closes the session |
Endpoint map (see the README for the user-facing version):
| Purpose | Method & path |
|---|---|
| Login | PUT /api/v1/auth/email/signin |
| Own user id | GET /api/v1/user |
| Calendars | GET /api/v1/calendars?since=0 |
| Labels | GET /api/v1/calendar/{id}/labels |
| Events |
GET /api/v1/calendar/{id}/events/sync[?since=] (loop while chunk is True) |
| Comments | GET /api/v1/calendar/{id}/event/{uuid}/activities?since= |
| CSRF token |
GET /calendars/{alias_code}/events/new (regex on csrf-token) |
| Create | POST /api/v1/calendar/{id}/event |
| Update |
PUT /api/v1/calendar/{id}/event/{uuid} (fallback: /events/{uuid}) |
| Delete |
DELETE /api/v1/calendar/{id}/event/{uuid} (fallback: /events/{uuid}) |
Write request decoration: X-CSRF-Token, Origin, Referer, Sec-Fetch-Dest: empty,
Sec-Fetch-Mode: cors, Sec-Fetch-Site: same-origin, Content-Type: application/json,
X-Timetreea: web/2.1.0/en; a fresh token is scraped once more before the second attempt.
| Aspect | Contract |
|---|---|
| Identity |
uuid (TimeTree uuid) is the key used in CalendarData.events, in SyncRecord and in export markers |
| Construction |
TimeTreeEvent.from_api(data, calendar_id, calendar=None); never raises on missing keys |
| Time semantics |
start_datetime/end_datetime (aware, event timezone), start_date/end_date_inclusive (TimeTree semantics), end_date (exclusive, +1 day), ha_start/ha_end (what the entity returns) |
| Recurrence |
rrule_line (raw line with prefix), is_recurring, recurrences (all lines) |
| Metadata |
label_id, label_name, label_color, attendee_ids, attendee_names, url, location_lat/lon, comments, event_type (0 normal / 1 birthday), category (1 normal / 2 memo) |
| Description |
describe_event(event, include_details=True) = note + label/attendee lines + URL + comments, joined with a blank line; used identically by the entity and the exporter so fingerprints match |
| Sync |
fingerprint() = SHA-256 over title, note, location, start, end, all_day and recurrences |
| Function | Contract |
|---|---|
split_recurrence_lines(lines) |
→ (rrule_value, exdates, rdates); tolerates the RRULE:, EXDATE;TZID=…:, RDATE: spellings |
is_valid_rule(value) |
True only when FREQ is present (protects against rrule did not contain FREQ) |
normalize_rule(value, *, all_day) |
a date-only UNTIL=YYYYMMDD becomes UNTIL=YYYYMMDDT235959 for timed events (RFC 5545: the whole day is included) |
expand_event(event, window_start, window_end, *, max_occurrences=1000) |
→ list[Occurrence]; falls back to the single stored occurrence for unusable rules; never raises |
expand_events(events, window_start, window_end, *, max_occurrences=1000) |
expands a collection, clamps the window to 1098 days and returns a chronologically sorted list of (event, occurrence) pairs |
Occurrence |
start, end (both date for all-day, both aware datetime otherwise), recurrence_id (%Y%m%dT%H%M%S / %Y%m%d), is_recurring
|
decide(*, source, target, record, policy, two_way, delete_removed, recreate_removed) -> Decision
Inputs: source/target are EventState | None (None = does not exist on that side),
record is the stored SyncRecord | None.
| Situation | Result |
|---|---|
both None
|
NONE |
| only target, not managed |
CREATE_SOURCE in two_way mode, else NONE
|
| only target, managed but source gone |
CONFLICT (source_removed) |
| only source, never exported | CREATE_TARGET |
| only source, exported before |
CREATE_TARGET when recreate_removed, else CONFLICT (target_removed) |
| both, fingerprints equal or nothing changed | NONE |
| both, only source changed | UPDATE_TARGET |
| both, only target changed |
two_way → UPDATE_SOURCE; source_wins/newest_wins → UPDATE_TARGET; target_wins → UPDATE_SOURCE; manual → CONFLICT (target_changed) |
| both, both changed |
source_wins → UPDATE_TARGET; target_wins → UPDATE_SOURCE; else CONFLICT (both_changed) |
| record marked ignored with a matching fingerprint | IGNORE |
resolution_to_action(resolution, conflict) maps use_timetree, use_export_target,
use_newest, skip onto the actions above; ignore_forever is handled by the manager
(marks the record ignored), skip re-baselines.
Marker contract: add_marker(description, uuid) appends [timetree:<uuid>];
find_marker(text) extracts it; strip_marker(text) removes it. EventState.from_calendar_event
strips the marker before fingerprinting so source and target stay comparable.
| Symbol | Contract |
|---|---|
ExportManager(hass, entry, coordinator, store) |
owns the loop (async_start/async_stop), the run (async_run) and conflict resolution |
async_run(*, reason="manual", dry_run=None) |
returns an ExportReport; never raises for API problems (they land in report.errors), one bad event does not abort the run |
| Loop |
async_track_time_interval(interval) + one startup run after STARTUP_DELAY = 45 s; async_stop cancels |
CalendarTarget |
wraps a CalendarEntity: features, supports(feature), async_get_events, async_create(TargetWrite), async_update(uid, TargetWrite), async_delete(uid); unsupported methods raise ExportError
|
TargetWrite |
summary, description (marker included), location, start, end, all_day, rrule; built from a TimeTreeEvent or an EventState
|
| Conflict handling |
_raise_conflict (store + event + notification), async_resolve_conflict(conflict_id, resolution), async_resolve_all(resolution)
|
| Re-baseline | after writes, refresh the coordinator and re-read the target window, then store observed fingerprints/uids for touched events |
| Aspect | Contract |
|---|---|
| Key |
timetree.sync.<entry_id> in .storage/
|
| Schema | {"records": {uuid: SyncRecord}, "conflicts": {id: Conflict}, "meta": {...}} |
| Write policy |
async_save() (debounced, SAVE_DELAY = 10 s), async_save_now() for conflict decisions |
| Record |
uuid, calendar_id, source_fingerprint, target_fingerprint, target_uid, target_entity, last_sync, ignored, ignored_fingerprint
|
| Meta |
last_export, last_export_target, last_report
|
-
async_setup_entry: buildTimeTreeOptions→TimeTreeApi→TimeTreeCoordinator→async_config_entry_first_refresh(fails the setup withConfigEntryAuthFailedon bad credentials) →TimeTreeSyncStore.async_load→ create/lookup the hub device (dr.async_get_or_create, identifier(DOMAIN, entry_id),DeviceEntryType.SERVICE) →ExportManager→ storeTimeTreeRuntimeDataonentry.runtime_data→ forward platforms → register services →exporter.async_start()→ register the options update listener. -
async_unload_entry: unload platforms, stop the loop, flush the store, close the API session, remove services when the last entry is gone. -
async_migrate_entry: re-versions old entries; the 1.x layout is understood byTimeTreeOptions.from_entry(calendar_id/calendar_namebecome a one-element calendar list), so no data reshape is needed. -
PLATFORMS = [Platform.CALENDAR, Platform.SENSOR].
TimeTreeOptions.from_entry(entry) merges entry.options over entry.data per key, so a
partially written options dict (the options flow always writes a full snapshot) never loses
defaults. It also carries the 1.x migration and the derived property two_way.
- Transport factory:
curl_cffi.requests.Session(impersonate=…)when importable, otherwiserequests.Session, reporting which one is active. -
_request()centralises login, retry-on-401, expected status handling, JSON parsing and error construction;_write()adds the CSRF token, the browser headers and the route fallback list. -
_write()retries once with a freshly scraped CSRF token, then raisesTimeTreeWriteErrorwith the status/body. -
_prepare_attendees()injects the account's numeric user id (fromGET /user) when a payload has no attendees — the web client does this itself, the API does not. -
async_fetch_events()loads events and labels in parallel and optionally attaches comments through a bounded thread pool (MAX_COMMENT_WORKERS = 4).
-
_async_update_data()callsasync_refresh_calendar_metadata()(refreshes names, members andalias_code), then fetches every selected calendar sequentially, collecting errors per calendar. All calendars failing →UpdateFailed; some failing → warning plus partial data. -
TimeTreeData.all_events()flattens all calendars for the exporter;find_event(uuid)answers lookups from the calendar entity and the conflict resolver. - Write passthrough methods (
async_create_event,async_update_event,async_delete_event) inject the calendar metadata (alias code needed for CSRF/Referer) and request a refresh afterwards.
- Supported features:
CREATE_EVENT | UPDATE_EVENT | DELETE_EVENT. -
Next event: a bounded lookahead (
LOOKAHEAD_DAYS = 180,MAX_LOOKAHEAD_OCCURRENCES = 25) is rebuilt in an executor on every coordinator update (_handle_coordinator_update→_async_rebuild_and_write), so the synceventproperty never walks recurrence rules in the event loop. -
Range query:
async_get_eventsexpands the requested window in an executor and maps every occurrence to aCalendarEventwithuid = event.uuid,recurrence_id = occurrence.recurrence_idandrrule = bare rule. -
Writes:
_resolve_times()acceptsdtstart/dtend(what HA core sends), the WebSocket spelling (start/endare not used;rruleis) and the legacy keys, and defaults a missing end (+1 day for dates, +1 h for datetimes).build_event_payload()performs the day-boundary and timezone conversion. -
Occurrence guard:
_reject_single_occurrence()raises a readableHomeAssistantErrorwhen a client tries to edit/delete a single occurrence of a recurring series (TimeTree only exposes whole series); the first occurrence counts as the series. -
extra_state_attributesexposetimetree_calendar_id,event_countand the last successful update.
| Entity | Device | State | Attributes |
|---|---|---|---|
<calendar> Last updated |
calendar device | last successful refresh (timestamp, diagnostic) | – |
Sync conflicts |
hub device | number of pending conflicts |
conflicts[] (id, kind, both versions, uuid), target, policy |
Last export |
hub device | last export timestamp | export settings + last report |
Export status |
hub device |
not_run / ok / error
|
last report counters, last_error
|
Store-backed sensors subscribe to the dispatcher signal SIGNAL_STORE_UPDATED_<entry_id>
instead of polling, because the store changes outside the coordinator cycle.
- Setup:
user(e-mail/password) →calendars(multi-select + interval defaults) → entry withdata={email, password, calendars[]}.unique_idis the lower-cased e-mail; duplicate accounts are rejected. -
reauth/reauth_confirm: password only, updates the entry and reloads. - Options menu (
init):calendars,sync,export,conflicts. Every sub-step starts from a full snapshot of the effective options, so saving one page never drops another. Removing a calendar removes its entities/devices on reload; adding the API's current calendar list is preferred, with the stored definitions as offline fallback.
Six services with vol.Optional config_entry_id targeting: refresh_now, export_now
(dry_run), list_conflicts, resolve_conflict (conflict_id, action),
resolve_all_conflicts (action), get_event (uuid, optional calendar_id).
list_conflicts and get_event are SupportsResponse.ONLY, the others OPTIONAL. A module
flag prevents double registration; async_remove_services only unregisters when no entry of
the domain remains loaded.
Thin, typed wrapper around helpers.storage.Store (see §3.6). Records are addressed by
TimeTree uuid; conflicts by make_conflict_id(uuid, kind) so re-detecting the same conflict
refreshes the existing entry (upsert_conflict) instead of stacking duplicates.
The largest module; see §3.5 for the contract and §5.5 for the run sequence. Notable implementation details:
-
_index_target_events()splits the target window into marker-indexed (managed) and unmanaged events; a marker seen twice is reported as an error and the first copy wins. -
_in_window()keeps events relevant for the window (recurring events always count). -
_async_apply()performs exactly one action per event and stores the expected state; the create path stores the marker uuid, the update path preserves the target uid. -
_async_create_source()/_async_import_unmanaged()create TimeTree events with a client-chosenuuid(documented in the 1.1.3 wiki and accepted by the API) so the marker↔uuid link survives; if TimeTree answers with a different uuid, the copy is re-tagged. -
_async_notify_conflict()creates/updates the persistent notification (NOTIFICATION_ID = timetree_conflicts).
Returns the entry (redacted data), the effective options, per-calendar statistics (event
counts, recurring/all-day/memo counts, members, labels, fetch time), the coordinator state,
the export state (transport, last error, last report, record/conflict counts) and the store
meta. TO_REDACT covers e-mail, password and uid.
sequenceDiagram
autonumber
actor U as User
participant CF as TimeTreeConfigFlow
participant API as TimeTreeApi
participant HA as HA core
participant INIT as async_setup_entry
participant CO as TimeTreeCoordinator
participant EX as ExportManager
U->>CF: e-mail + password
CF->>API: async_validate() (executor)
API->>API: _login() → PUT /auth/email/signin
API->>API: GET /calendars?since=0
API-->>CF: list[TimeTreeCalendar]
CF->>U: calendar multi-select
U->>CF: calendars + defaults
CF->>HA: async_create_entry(title, data, options)
HA->>INIT: async_setup_entry(entry)
INIT->>API: new TimeTreeApi(...)
INIT->>CO: new TimeTreeCoordinator(...)
CO->>API: async_config_entry_first_refresh()
INIT->>HA: hub device (dr.async_get_or_create)
INIT->>EX: new ExportManager(...) and async_start()
INIT->>HA: forward calendar + sensor platforms
INIT->>HA: register services
sequenceDiagram
autonumber
participant CO as TimeTreeCoordinator
participant API as TimeTreeApi
participant M as models
participant ENT as TimeTreeCalendarEntity
participant SEN as sensors
CO->>API: async_get_calendars()
API-->>CO: members + alias_code
loop every selected calendar
CO->>API: async_fetch_events(calendar, include_comments, include_birthdays)
API->>API: loop events/sync while chunk is True
API->>API: GET /calendar/{id}/labels (+ activities when enabled)
API-->>CO: list[TimeTreeEvent] (birthdays filtered, deleted dropped)
end
CO->>CO: build TimeTreeData(calendars)
CO-->>ENT: _handle_coordinator_update()
ENT->>ENT: rebuild lookahead in executor → async_write_ha_state()
CO-->>SEN: last updated timestamp
sequenceDiagram
autonumber
participant CARD as Dashboard / calendar.get_events
participant ENT as TimeTreeCalendarEntity
participant REC as recurrence
participant ENTRY as coordinator data
CARD->>ENT: async_get_events(hass, start, end)
ENT->>REC: expand_events(events, start, end) [executor]
REC->>REC: split RRULE/EXDATE/RDATE, normalize date-only UNTIL
REC->>REC: rruleset.xafter(window - duration, max 1000 occurrences)
REC-->>ENT: [(event, occurrence)] sorted
ENT->>ENT: map to CalendarEvent(uid, recurrence_id, rrule)
ENT-->>CARD: list[CalendarEvent]
sequenceDiagram
autonumber
participant SVC as calendar.create_event (service or WS)
participant ENT as TimeTreeCalendarEntity
participant M as models.build_event_payload
participant CO as TimeTreeCoordinator
participant API as TimeTreeApi
participant TT as TimeTree API
SVC->>ENT: async_create_event(**kwargs) with dtstart/dtend (+ rrule)
ENT->>M: build payload (all-day: -1 day, UTC midnight / timed: ms + tz name)
ENT->>CO: async_create_event(calendar_id, payload)
CO->>API: async_create_event(calendar, payload)
API->>API: scrape X-CSRF-Token (GET /calendars/{alias_code}/events/new)
API->>API: add attendees (GET /user when needed)
API->>TT: POST /api/v1/calendar/{id}/event
TT-->>API: 200/201 (event)
API-->>CO: dict
CO->>CO: async_request_refresh()
Update/delete follow the same path with PUT/DELETE on
/calendar/{id}/event/{uuid} (plural fallback). Deleting a single occurrence of a recurring
series is refused before any request is sent.
sequenceDiagram
autonumber
participant T as Timer / timetree.export_now
participant EX as ExportManager
participant TG as CalendarTarget
participant ST as TimeTreeSyncStore
participant CO as TimeTreeCoordinator
T->>EX: async_run(reason, dry_run)
EX->>TG: async_get_events(now - past_days, now + future_days)
TG-->>EX: list[CalendarEvent]
EX->>EX: _index_target_events() → markers + unmanaged
loop union(source uuids, marker uuids)
EX->>ST: get_record(uuid)
EX->>EX: EventState(source) / EventState(target) / decide()
alt decision is a conflict
EX->>ST: upsert_conflict() + notification + event
else create / update / delete / import
EX->>TG: async_create / async_update (create-then-delete) / async_delete
EX->>ST: set_record(expected state)
end
end
EX->>CO: async_refresh() (when something changed)
EX->>TG: async_get_events(...) again
EX->>ST: store observed fingerprints/uids (re-baseline)
EX->>ST: set_meta(last_export, last_report)
EX-->>T: ExportReport + timetree_export_completed event
sequenceDiagram
autonumber
actor U as User (or automation)
participant SVC as timetree.resolve_conflict
participant EX as ExportManager
participant CO as TimeTreeCoordinator
participant TG as CalendarTarget
participant ST as TimeTreeSyncStore
U->>SVC: conflict_id + action
SVC->>EX: async_resolve_conflict(conflict_id, action)
EX->>ST: conflict lookup
alt action = ignore_forever
EX->>ST: mark_ignored(uuid, fingerprint), then pop conflict
else action = skip
EX->>ST: re-baseline from conflict states, then pop conflict
else use_timetree / use_export_target / use_newest
EX->>EX: resolution_to_action(conflict)
alt target side wins
EX->>CO: async_update_event / async_create_event (or delete)
else source side wins
EX->>TG: async_update / async_create / async_delete
end
EX->>ST: pop conflict + save now
end
EX-->>SVC: True/False
The options flow always writes a full options snapshot; _async_options_updated reloads the
config entry, which rebuilds the API client, coordinator, store, exporter and all entities.
The reload is the only way intervals, calendars and export settings change — no in-place
mutation, so there is exactly one code path for every configuration.
| Failure | Detected in | Reaction | User visible |
|---|---|---|---|
Wrong credentials / blocked login (-702) |
TimeTreeApi._login |
TimeTreeAuthError → ConfigEntryAuthFailed
|
reauth flow in the UI |
Session expired mid-run (401) |
_request |
one transparent re-login, then retry | nothing (debug log) |
TimeTree unreachable / 5xx
|
_request / coordinator |
UpdateFailed |
entities become unavailable, Last updated stays stale |
| One calendar fails, others succeed | _async_update_data |
warning + partial data | other calendars keep working |
Write rejected by the WAF (422) |
_write |
route fallback, CSRF refresh, TimeTreeWriteError
|
HomeAssistantError on the service call, warning in the log |
| Update/delete route missing | _write |
plural fallback → TimeTreeWriteError
|
see above, documented risk R-2 |
| Unsupported recurrence rule (lunar) | _build_rruleset |
fallback to the stored occurrence | event still appears |
| Malformed event payload |
models parsing |
defaults, event is skipped when it has no uuid | debug log |
| Occurrence-level edit of a series | _reject_single_occurrence |
refused before any request | readable HomeAssistantError
|
| Export target missing / read only | async_run |
ExportError in the report |
Export status = error, last_error attribute |
| One event fails during export |
_async_process per-event try/except
|
error collected, run continues | report counters + errors |
| Target normalises data | re-baseline | stored fingerprints updated | no false conflicts |
| Marker duplicated in the target | _index_target_events |
first copy wins, error reported | report + log |
| Store unreadable | async_load |
empty state, conflicts/records rebuilt | diagnostics show empty store |
{
"version": 1,
"records": {
"8f0c2a…": {
"uuid": "8f0c2a…",
"calendar_id": "1234567",
"source_fingerprint": "b1d4…",
"target_fingerprint": "b1d4…",
"target_uid": "google-event-id",
"target_entity": "calendar.google",
"last_sync": "2026-09-21T08:12:00+00:00",
"ignored": false,
"ignored_fingerprint": null
}
},
"conflicts": {
"3ab9f1d0c2e4": {
"conflict_id": "3ab9f1d0c2e4",
"uuid": "8f0c2a…",
"calendar_id": "1234567",
"kind": "both_changed",
"summary": "Family dinner",
"message": "both sides changed since the last sync",
"detected_at": "2026-09-21T08:12:00+00:00",
"source": { "summary": "Family dinner", "start": "2026-10-01T18:00:00+02:00", "updated": 1758000000000 },
"target": { "summary": "Dinner with family", "start": "2026-10-01T18:00:00+02:00" },
"baseline": { "uuid": "8f0c2a…", "source_fingerprint": "…", "target_fingerprint": "…" },
"target_uid": "google-event-id",
"target_entity": "calendar.google"
}
},
"meta": {
"last_export": "2026-09-21T08:12:00+00:00",
"last_export_target": "calendar.google",
"last_report": { "created": 2, "updated": 1, "conflicts": 1, "errors": [] }
}
}<pizza night>
TimeTree label: Family
TimeTree attendees: Anna, Martin
[timetree:8f0c2a7d5b5e4a3c9d1f1e2a3b4c5d6e]
{
"title": "Family dinner",
"all_day": false,
"start_at": 1787011200000,
"start_timezone": "Europe/Vienna",
"end_at": 1787014800000,
"end_timezone": "Europe/Vienna",
"label_id": 1,
"note": "pizza night",
"location": "Home",
"attendees": [40124656],
"recurrences": [],
"alerts": [],
"attachment": { "virtual_user_attendees": [] },
"category": 1
}All-day variant: all_day: true, start_timezone/end_timezone: "UTC", both timestamps are
UTC midnights, end_at is the inclusive last day (a one day event has
start_at == end_at).
-
strings.jsonis the English source and is mirrored bytranslations/en.json. -
translations/de.jsonis a full German translation of the setup/options flows, the selector labels (export_direction,conflict_policy) and all service fields. - Entity names are provided in code (
_attr_name); state values of the export status sensor are machine values (not_run,ok,error) and are documented in the README. - New languages only need a translated
translations/<lang>.json.
The integration can be verified without a Home Assistant installation:
# one-time setup of helper packages
pip install --target $env:TEMP\tt-libs python-dateutil tzdata voluptuous
# run the suite (41 tests / 224 checks)
python tests/test_integration.pytests/ha_stub.py injects minimal stand-ins for the homeassistant.* modules the
integration imports (core, config_entries, const, exceptions, util.dt,
helpers.*, components.calendar|sensor|diagnostics, voluptuous, requests). It also
loads custom_components.timetree without executing its __init__.py, so the pure logic and
even the export engine can be exercised deterministically.
| Test group | Covers |
|---|---|
| models | timed/all-day/negative timestamps, labels, filtering, payload round trip |
| recurrence |
COUNT, window filtering, EXDATE, date-only UNTIL, all-day series, invalid rules, ordering |
| conflicts | markers, the full decision table, resolution mapping |
| store | record/conflict/meta round trip, ignore flag |
| api | chunked pagination, label parsing, create payload + headers, CSRF scrape, route fallback, login errors |
| export | create, idempotency, update, dry run, conflict detection, both resolutions, skip, ignore forever, window/unmanaged, import, target errors, source deletion |
| calendar | time resolution (all spellings), event building, occurrence guard, rule validation |
| options | 1.x migration, options-over-data precedence, two_way derivation |
| imports | every module imports cleanly (schema construction included) |
ruff check --select F,E9,B,SIM,UP --ignore SIM108,SIM102,SIM103,SIM105,SIM117 --line-length 100 custom_components
python -m compileall -q custom_componentsbrand/ ships icon.png (256²), icon@2x.png (512²), logo.png (1024×256),
logo@2x.png (2048×512) and dark variants. Assets are generated with Pillow at 4× and
downscaled with LANCZOS; sizes follow the Home Assistant brand-image specification
(icon square, logo shortest side 128–256 / 256–512 for @2x, PNG with alpha, trimmed).
- Bump
manifest.jsonversionandhacs.json/README badges to the tag name (novprefix in this repository). - Update
CHANGELOG.md(Keep a Changelog format) and the release notes. - Run
python tests/test_integration.pyand the ruff/compile checks. - Commit on the release branch, push, create an annotated tag, push the tag.
- Publish the GitHub release; mark pre-releases as Pre-release and uncheck Latest.
- Verify asset delivery by downloading
…/archive/refs/tags/<tag>.tar.gzand listingcustom_components/timetree/brand/. - Verify on a live Home Assistant: create a timed event, an all-day event, delete one, add a
recurring event with an
EXDATE, runtimetree.export_nowwithdry_run: true, then for real and check the conflict sensor.
- HA-free modules (
models,recurrence,conflicts,const) stay importable without Home Assistant; everything that touches HA lives in the platform/flow modules. - Blocking work always runs through
hass.async_add_executor_job; recurrence expansion is additionally bounded. - Parsing is defensive: unknown or missing API keys fall back to defaults instead of raising, and a single broken event never invalidates a whole refresh.
- User-facing errors are
HomeAssistantErrorwith an actionable sentence; technical details go to the log. - Constants live in
const.py; no magic strings in the platform code. -
from __future__ import annotationseverywhere; plaindict[...]/|typing (no PEP 695 aliases) so the modules compile on Python 3.11 toolchains as well. - Docstrings on every public class/function; module docstrings describe the responsibility.
| Issue / source | Root cause | Change | Tests |
|---|---|---|---|
| #1 views stalled |
hass.data storage, HA 2026.x API breaks, malformed rrule
|
entry.runtime_data, 2026.9 APIs, recurrence.py
|
test_all_modules_import, test_recurrence_*
|
#2 rrule did not contain FREQ
|
raw RRULE: string passed to CalendarEvent
|
bare rule via _bare_rrule/recurrence.expand_*
|
test_calendar_entity_rrule_validation, test_recurrence_*
|
#3 combine() … not None
|
entity read start_date_time/start_date instead of dtstart/dtend
|
_resolve_times() |
test_calendar_entity_time_resolution |
#5 write 422
|
plural route, incomplete payload, no CSRF, wrong TLS client |
_event_paths, build_event_payload, _fetch_csrf_token, curl_cffi
|
test_api_write_request, test_api_write_payload_fields, test_api_write_falls_back_to_plural_endpoint
|
| #5 all-day off by one | inclusive vs. exclusive end |
ha_end, build_event_payload
|
test_models_all_day_event, test_models_payload_round_trip
|
| #6 labels/attendees | parsed but dropped |
models.apply_calendar_metadata, describe_event
|
test_api_labels_and_parsing |
| #7 recurrence expansion | occurrences were never materialised | recurrence.expand_events |
test_recurrence_expansion_with_count, test_recurrence_window_filter_in_place
|
| new requirement (export) | – |
export.py, store.py
|
test_export_* |
| new requirement (conflicts) | – |
conflicts.py, export.py, services.py
|
test_decide_table, test_export_conflict_*, test_export_skip_resolution_rebaselines, test_export_ignore_forever
|
| new requirement (multi calendar) | – |
options.py, config/options flow |
test_options_from_entry, test_all_modules_import
|
| Reference | Link |
|---|---|
| Architecture Design Document | Architecture-Design-Document |
| Workflow diagrams | Workflow-Diagrams |
| Archived 1.1.3 design (ACD + SDD) | Archive-1.1.3-Design-Documentation |
| README (installation, options, services, troubleshooting) | README.md |
| CHANGELOG | CHANGELOG.md |
| TimeTree-Exporter (API reference) | https://github.com/eoleedi/TimeTree-Exporter |
| Home Assistant calendar entity docs | https://developers.home-assistant.io/docs/core/entity/calendar |
| Home Assistant storage helper docs | https://developers.home-assistant.io/docs/integration_fetching_data/#storing-data |
🟢 Current — 2.0.x
(branch ha2026_09_dev, HA 2026.9+)
🗄️ Archived — 1.1.3
(branch main)
🔗 Links