Skip to content

Software Design Document

acdcnow edited this page Sep 21, 2026 · 4 revisions

Software Design Document (SDD)

Applies to integration 2.0.x · branch ha2026_09_dev · version 2.0.0-beta.2 · Home Assistant 2026.9+

Companion documents: Architecture Design Document · Workflow Diagrams · Documentation home · Archived 1.1.3 SDD.


1. Module inventory

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

2. Structural view

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
Loading

3. Interface contracts

3.1 TimeTreeApi — TimeTree transport

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.

3.2 models.TimeTreeEvent — canonical event

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

3.3 recurrence — expansion

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

3.4 conflicts — decision table

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.

3.5 export — export manager and target adapter

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

3.6 store — persistence

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

4. Component design

4.1 __init__.py — entry lifecycle

  • async_setup_entry: build TimeTreeOptions → TimeTreeApi → TimeTreeCoordinator → async_config_entry_first_refresh (fails the setup with ConfigEntryAuthFailed on bad credentials) → TimeTreeSyncStore.async_load → create/lookup the hub device (dr.async_get_or_create, identifier (DOMAIN, entry_id), DeviceEntryType.SERVICE) → ExportManager → store TimeTreeRuntimeData on entry.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 by TimeTreeOptions.from_entry (calendar_id/calendar_name become a one-element calendar list), so no data reshape is needed.
  • PLATFORMS = [Platform.CALENDAR, Platform.SENSOR].

4.2 options.py

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.

4.3 api.py

  • Transport factory: curl_cffi.requests.Session(impersonate=…) when importable, otherwise requests.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 raises TimeTreeWriteError with the status/body.
  • _prepare_attendees() injects the account's numeric user id (from GET /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).

4.4 coordinator.py

  • _async_update_data() calls async_refresh_calendar_metadata() (refreshes names, members and alias_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.

4.5 calendar.py

  • 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 sync event property never walks recurrence rules in the event loop.
  • Range query: async_get_events expands the requested window in an executor and maps every occurrence to a CalendarEvent with uid = event.uuid, recurrence_id = occurrence.recurrence_id and rrule = bare rule.
  • Writes: _resolve_times() accepts dtstart/dtend (what HA core sends), the WebSocket spelling (start/end are not used; rrule is) 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 readable HomeAssistantError when 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_attributes expose timetree_calendar_id, event_count and the last successful update.

4.6 sensor.py

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.

4.7 config_flow.py

  • Setup: user (e-mail/password) → calendars (multi-select + interval defaults) → entry with data={email, password, calendars[]}. unique_id is 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.

4.8 services.py

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.

4.9 store.py

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.

4.10 export.py

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-chosen uuid (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).

4.11 diagnostics.py

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.


5. Dynamic behaviour

5.1 Setting up an account

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
Loading

5.2 Refresh cycle

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
Loading

5.3 Range query with recurrence expansion

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]
Loading

5.4 Writing an event

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()
Loading

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.

5.5 Export run

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
Loading

5.6 Resolving a conflict

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
Loading

5.7 Changing options

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.


6. Error handling matrix

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

7. Data model

7.1 Sync store (.storage/timetree.sync.<entry_id>)

{
  "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": [] }
  }
}

7.2 Export marker

<pizza night>

TimeTree label: Family
TimeTree attendees: Anna, Martin

[timetree:8f0c2a7d5b5e4a3c9d1f1e2a3b4c5d6e]

7.3 Write payload (create/update)

{
  "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).


8. Localisation

  • strings.json is the English source and is mirrored by translations/en.json.
  • translations/de.json is 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.

9. Build, verification and release

9.1 Local verification harness

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.py

tests/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)

9.2 Static checks

ruff check --select F,E9,B,SIM,UP --ignore SIM108,SIM102,SIM103,SIM105,SIM117 --line-length 100 custom_components
python -m compileall -q custom_components

9.3 Brand assets

brand/ 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).

9.4 Release checklist

  1. Bump manifest.json version and hacs.json/README badges to the tag name (no v prefix in this repository).
  2. Update CHANGELOG.md (Keep a Changelog format) and the release notes.
  3. Run python tests/test_integration.py and the ruff/compile checks.
  4. Commit on the release branch, push, create an annotated tag, push the tag.
  5. Publish the GitHub release; mark pre-releases as Pre-release and uncheck Latest.
  6. Verify asset delivery by downloading …/archive/refs/tags/<tag>.tar.gz and listing custom_components/timetree/brand/.
  7. Verify on a live Home Assistant: create a timed event, an all-day event, delete one, add a recurring event with an EXDATE, run timetree.export_now with dry_run: true, then for real and check the conflict sensor.

10. Coding conventions

  • 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 HomeAssistantError with an actionable sentence; technical details go to the log.
  • Constants live in const.py; no magic strings in the platform code.
  • from __future__ import annotations everywhere; plain dict[...]/| 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.

11. Traceability

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

12. References

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

TimeTree Calendar

🏠 Documentation home


🟢 Current — 2.0.x (branch ha2026_09_dev, HA 2026.9+)


🗄️ Archived — 1.1.3 (branch main)


🔗 Links

Clone this wiki locally