Architecture

The architecture: goals and constraints first, then context, containers and components, how one ingest run flows, how it is deployed and what the cache holds, then the crosscutting concepts, decisions, quality, risks and glossary. The code level is the API reference. Requirement ids (REQ_…) link to Requirements.

Two properties shape everything below. The client is read-only against trackiwi (Read-only by construction (REQ_READONLY); the only state-changing request is DELETE /api/v2/session), and it has no runtime dependencies: standard library only.

Introduction and goals

trackiwi-client pulls the positions of a personal trackiwi GPS tracker out of the vendor’s cloud and keeps them on hardware the owner controls: a local SQLite cache, exports as GPX, GeoJSON or CSV, and optionally an InfluxDB bucket that Grafana draws. It is unofficial and unaffiliated.

Constraints

Solution strategy

System context

Who and what the client talks to. The vendor’s cloud holds the account and the position history; the client pulls from it and, optionally, mirrors into an InfluxDB the owner runs.

The API is undocumented and can change without notice, so the contract is re-checked against the live service by an opt-in test (TRACKIWI_LIVE=1, see AGENTS.md).

Containers

What runs. One Python program does all the work; a scheduler only calls it. State lives in two small files, both created owner-only (The position cache is 0600 ... (REQ_CACHE_MODE_0600), The token file is 0600 and ... (REQ_CONFIG_MODE_0600)).

The cache is the buffer between the two remote systems: sync fills it, push drains it, and either side can be down without losing a position (Sync is resumable (REQ_SYNC_RESUME), The mirror never advances p... (REQ_MIRROR_RESUME)).

Components

The Python package. cli is the only module that knows about all the others; the rest are small and single-purpose. The arrows are the real imports, and a test (tests/test_architecture_doc.py) fails if this diagram stops matching them.

Rules the shape encodes:

  • client and influx never import each other; client is the only module that talks to trackiwi and influx the only one that talks to InfluxDB (InfluxDB writes are bounded... (REQ_INFLUX_WRITE_SCOPE)). Their shared plumbing is _http.

  • _http refuses every redirect, so a session token can never be forwarded to another host (Redirects are never followed (REQ_NO_REDIRECTS)), and scrubs credentials from error text (The token is never logged o... (REQ_TOKEN_NEVER_LOGGED)).

  • lineprotocol, export and heading are pure: rows in, text or an estimate out, no I/O. store never touches the network.

  • Every module may use the shared contracts in trackiwi/__init__.py (TrackiwiError, AuthError, COLUMNS, Row, __version__), which imports nothing. They are not drawn, to keep the diagram readable.

  • influx only ever writes the trackiwi_position measurement to the one configured target.

One ingest run

The scheduled command. Its three steps fail independently: the push still runs when the sync or the name refresh fails, because the cache already holds positions that should reach InfluxDB.

The mirror position moves only after InfluxDB acknowledges a batch (The mirror never advances p... (REQ_MIRROR_RESUME)), and a row always becomes the same point, so sending it twice overwrites rather than duplicates (Re-sending positions never ... (REQ_MIRROR_IDEMPOTENT)). sync resumes from the highest stored id and treats the server’s offset as exclusive (The sync offset is exclusive (REQ_SYNC_OFFSET_EXCLUSIVE)); a malformed row is skipped and counted, never fatal (A malformed row is skipped ... (REQ_MALFORMED_SKIP)).

Login and sync

The two commands that talk to the trackiwi cloud, drawn as plain sequences. The scheduled run is the ingest sequence above.

Login

        sequenceDiagram
    actor Owner
    participant CLI as CLI
    participant Client as Client
    participant Cloud as trackiwi cloud
    participant Disk as config file 0600

    Owner->>CLI: trackiwi login
    CLI->>Owner: prompt for email and password
    CLI->>Client: login with email and password
    Client->>Cloud: POST login on the trackiwi website
    Cloud-->>Client: server, token, user
    Client->>Client: require https on server
    Client->>Disk: save api_base, token, user_id
    Client-->>CLI: user
    CLI-->>Owner: Logged in
    

The login request goes to the vendor’s website; the API base for everything after it is the server field of the response, checked for https:// before it is stored. The password is never stored. A response that lacks a field is an error that does not echo the body, which could contain the token. The API base comes from the... (REQ_API_BASE_FROM_LOGIN), The API base must be https (REQ_API_BASE_HTTPS), The token file is 0600 and ... (REQ_CONFIG_MODE_0600)

Sync (offset-based, resumable)

        sequenceDiagram
    actor Owner
    participant CLI as CLI
    participant Store as Store
    participant Client as Client
    participant Cloud as trackiwi cloud

    Owner->>CLI: trackiwi sync
    CLI->>Store: max_id
    Store-->>CLI: highest stored id, or none
    loop until a page has no rows
        CLI->>Client: sync from offset
        Client->>Cloud: POST trackers sync with offset, or initial_sync
        Cloud-->>Client: CSV page
        Client->>Client: parse rows, skip and count malformed
        Client-->>CLI: rows, skipped, total
        CLI->>Store: upsert rows
        Note over Client: next offset is the highest id on the page
    end
    CLI-->>Owner: N new positions, M cached in total
    

The offset is exclusive: a page requested at offset N holds ids greater than N. The loop stops when a page holds no rows at all. It fails loudly instead of looping if a page has skipped rows but not one readable id, or if the highest id does not pass the offset just requested. A malformed row, even the newest one, is skipped and counted and the offset moves past it. Each page is written before the next is requested, so an interruption loses nothing and the next run starts from max_id. sync --full ignores the stored id and sends initial_sync. Sync is resumable (REQ_SYNC_RESUME), The sync offset is exclusive (REQ_SYNC_OFFSET_EXCLUSIVE), Sync fails loudly on a stuc... (REQ_SYNC_FAIL_LOUD), Sync moves past malformed rows (REQ_SYNC_PAST_MALFORMED)

Deployment

Two supported setups, both generic. A real deployment is configured from its own repository, which consumes this package (Committed deployment templa... (REQ_PORTABLE_CONFIG)).

Option A: turnkey Docker Compose stack (deploy/docker-compose.yml). Published ports bind to 127.0.0.1; the ingest container reaches InfluxDB over the compose network and starts only once InfluxDB is healthy.

Option B: your own InfluxDB (systemd user timer, deploy/systemd/). The command is installed with pipx; the timer runs trackiwi ingest.

The cache

Three tables in one SQLite file. There are no foreign keys: tracker_names and mirror_state refer to positions by value only. Position ids are the vendor’s own, so they are the resume cursor for both sync and the mirror.

        erDiagram
  positions {
    int id PK
    int tracker_id
    int fix_at "epoch seconds"
    int fix_timezone
    real latitude
    real longitude
    int altitude "m"
    real speed "km/h"
    int course "degrees"
    int distance "cm per fix"
    int rssi "GNSS quality 0-5"
    int sat
    int battery "percent"
    int voltage "centivolts"
  }
  tracker_names {
    int tracker_id PK
    text name
  }
  mirror_state {
    text target PK
    int last_id "highest id acknowledged"
  }
  tracker_names ||--o{ positions : "names tracker_id"
  mirror_state }o..o| positions : "last_id marks a position"
    

The cache is a complete movement history of a physical vehicle, so it is the most sensitive file this tool creates. purge deletes it and SQLite’s sidecar files without opening it (Purge deletes the cache eve... (REQ_PURGE_DELETES)).

Crosscutting concepts

Decisions

Decisions that shaped the design, with the rule that now enforces each. Dated design specs live locally and are not part of the repository.

Quality

  • Safety. No code path other than logout changes state at trackiwi. How it is checked: Read-only by construction (REQ_READONLY), an invariant test in tests/test_invariants.py

  • Privacy. A secret or position data is never committed. How it is checked: Three scanners (gitleaks, detect-secrets, tools/check_no_private_data.py) in pre-commit and CI

  • Reliability. Kill a sync or a push at any point, run it again, end with the same state. How it is checked: Sync is resumable (REQ_SYNC_RESUME), The mirror never advances p... (REQ_MIRROR_RESUME)

  • Maintainability. Strict typing, full docstring coverage, 95 percent test coverage. How it is checked: mypy --strict, interrogate, pytest --cov-fail-under=95 in tools/ci.sh and CI

  • Documentation truth. A requirement without a test, or a dangling :need: reference, fails the build. How it is checked: sphinx-build -b html -W docs docs/_build/html in CI

Risks and technical debt

  • Unofficial API. It can change without notice. sync fails loudly on an unreadable or non-advancing page, and the live contract test (TRACKIWI_LIVE=1, never in CI) is the early-warning check.

  • Unverified shapes. The markers and shares endpoints answered with empty lists on the account they were checked against, so their field names are expectations. fix_timezone is an integer of unconfirmed meaning (nothing depends on it).

  • Plaintext token. The session token is readable by any process of the same user and is swept into backups. Moving it into the macOS Keychain is the recommended upgrade and is not done.

  • Location in API responses. The trackers and alarms responses hold geofence and event coordinates. The commands print none, but the responses themselves deserve the care of the cache.

  • Heading is an estimate. A vehicle that reversed into its spot points the other way and nothing in the data can tell.

Glossary

The terms used on this page, such as tracker, offset and mirror position, are defined in the Glossary.