Skip to content

Latest commit

 

History

History
1770 lines (1432 loc) · 62.5 KB

File metadata and controls

1770 lines (1432 loc) · 62.5 KB

Tado Local - Design Document

Overview

Tado Local is a Python-based REST API server that provides local control of Tado smart heating devices via the HomeKit protocol. It bypasses Tado's cloud API rate limits by communicating directly with the Tado Internet Bridge using HomeKit over IP.

Key Design Goals

  1. Local-First: Direct HomeKit communication without cloud dependency
  2. Clean REST API: Simple HTTP endpoints for easy integration
  3. Real-Time Updates: Event-driven state management with SSE streaming
  4. State Persistence: SQLite-backed history and configuration storage
  5. Modular Architecture: Clean separation of concerns for maintainability
  6. Network Resilience: Automatic reconnection and change detection

Project Structure

tado-local/
├── local.py                    # Backward compatibility entry point
├── requirements.txt           # Python dependencies
├── setup.py                   # Package configuration and distribution
├── README.md                  # User documentation and usage guide
├── INSTALLATION.md            # Installation and setup instructions
└── tado_local/                # Main Python package
    ├── __init__.py            # Package initialization
    ├── __main__.py            # CLI entry point (~140 lines)
    ├── api.py                 # Main API class (~532 lines)
    ├── routes.py              # FastAPI route handlers (~715 lines)
    ├── bridge.py              # HomeKit pairing logic (~796 lines)
    ├── state.py               # Device state management (~443 lines)
    ├── cache.py               # SQLite characteristic cache (~160 lines)
    ├── database.py            # Database schema definitions (~100 lines)
    └── homekit_uuids.py       # HomeKit UUID to name mappings

Architecture

Technology Stack

Component Technology Purpose
HomeKit Protocol aiohomekit Direct communication with Tado bridge for local control
Cloud API Tado OAuth2 API Device metadata, battery status, zone configuration
REST API FastAPI Modern async web framework with auto-docs
Web Server uvicorn ASGI server for FastAPI
Database SQLite State persistence, history, and credentials
Real-Time Events Server-Sent Events (SSE) Live state updates to clients
Authentication Bearer tokens Optional multi-key API authentication
Async Runtime asyncio Efficient I/O handling
Cryptography cryptography HomeKit pairing encryption
Service Discovery zeroconf mDNS for bridge discovery

System Architecture Diagram

┌─────────────────────────────────────────────────────────────┐
│                     Client Applications                      │
│  (Domoticz, Home Automation, Scripts, Web UI)               │
└────────────────┬────────────────────────────────────────────┘
                 │ HTTP REST / SSE
                 ▼
┌─────────────────────────────────────────────────────────────┐
│                   Tado Local                                │
│                                                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐     │
│  │   FastAPI    │  │  TadoLocalAPI │  │    State     │     │
│  │   Routes     │◄─┤    Manager    │◄─┤   Manager    │     │
│  └──────────────┘  └───────┬───────┘  └──────┬───────┘     │
│                            │                  │              │
│  ┌──────────────┐  ┌──────▼───────┐  ┌──────▼───────┐     │
│  │  TadoBridge  │  │   IpPairing  │  │   SQLite     │     │
│  │   (Pairing)  │  │  (HomeKit)   │  │  Database    │     │
│  └──────────────┘  └──────┬───────┘  └──────┬───────┘     │
│                            │                  │              │
│  ┌──────────────┐  ┌──────▼───────┐  ┌──────▼───────┐     │
│  │ TadoCloudAPI │  │ OAuth2 Token │  │ Cloud Cache  │     │
│  │ (Metadata)   │  │  Management  │  │ (4hr refresh)│     │
│  └──────┬───────┘  └──────────────┘  └──────────────┘     │
└─────────┼───────────────┬──────────────────────────────────┘
          │               │ HomeKit over IP
          │               ▼
          │     ┌─────────────────────┐
          │     │  Tado Internet      │
          │     │  Bridge (HomeKit)   │
          │     └─────────┬───────────┘
          │               │ Wireless
          │               ▼
          │     ┌─────────────────────┐
          │     │  Tado Thermostats   │
          │     │  & Smart Radiator   │
          │     │  Thermostats        │
          │     └─────────────────────┘
          │ HTTPS (every 4 hours)
          ▼
┌─────────────────────┐
│  Tado Cloud API     │
│  (OAuth2)           │
│  - Battery status   │
│  - Device metadata  │
│  - Zone names       │
└─────────────────────┘

Core Modules

1. __main__.py - CLI Entry Point

Location: tado_local/__main__.py Lines: ~140 Purpose: Command-line interface and application bootstrap

Key Responsibilities:

  • Parse command-line arguments (--bridge-ip, --pin, --port, --state, --accessory-ip, --accessory-pin)
  • Initialize database and create API instance
  • Manage bridge pairing (load existing or create new)
  • Optionally pair standalone HomeKit accessories (e.g. Smart AC Control V3+)
  • Start OAuth2 device flow for cloud authentication (browser-based)
  • Configure and start uvicorn web server
  • Handle graceful shutdown and cleanup

Main Function Flow:

main()
  ├── parse_arguments()
  ├── run_server(args)
  │   ├── TadoLocalAPI.__init__(db_path)
  │   ├── create_app() + register_routes()
  │   ├── TadoBridge.pair_or_load()
  │   ├── TadoBridge.pair_or_load_accessory()  # for each --accessory-ip
  │   ├── TadoLocalAPI.initialize(pairing, extra_pairings=...)
  │   └── uvicorn.Server.serve()
  └── cleanup()

Entry Points:

  • python -m tado_local (recommended)
  • tado-local (console script after pip install)
  • python local.py (backward compatibility)

2. api.py - TadoLocalAPI Class

Location: tado_local/api.py Lines: ~532 Purpose: Core API logic, HomeKit connection management, and event system

Key Responsibilities:

  • Manage HomeKit pairing connection (IpPairing)
  • Cache and process HomeKit accessories
  • Setup event listeners and polling systems
  • Coordinate state updates with DeviceStateManager
  • Broadcast real-time events to SSE clients
  • Handle device characteristic changes

Main Components:

class TadoLocalAPI:
    # Core state
    pairing: IpPairing                           # HomeKit connection
    accessories_cache: List[Dict]                # Raw accessory data
    accessories_dict: Dict[str, Dict]            # device_id -> accessory
    state_manager: DeviceStateManager            # Persistent state tracking

    # Event system
    event_listeners: List[asyncio.Queue]         # SSE client queues
    change_tracker: Dict                         # Track events vs polling
    characteristic_map: Dict[Tuple, str]         # (aid, iid) -> name

    # Background tasks
    background_tasks: List[asyncio.Task]         # Polling loops
    subscribed_characteristics: List[Tuple]      # For cleanup

Key Methods:

Method Purpose
initialize(pairing) Setup API with HomeKit pairing
refresh_accessories() Poll all accessories from bridge
setup_event_listeners() Initialize event + polling system
setup_persistent_events() Subscribe to HomeKit events
setup_polling_system() Backup polling for reliability
handle_change(aid, iid, data, source) Unified change handler
broadcast_event(data) Send to SSE clients
cleanup() Graceful shutdown

Change Detection Strategy:

The API uses a triple-source approach for reliability:

  1. HomeKit Events (Primary): Real-time notifications from bridge

    • Subscribe to all characteristics with ev permission
    • Instant updates for temperature, heating state, valve position
    • Register dispatcher callback for all events
  2. HomeKit Polling (Backup):

    • Fast Poll (60s): Priority characteristics (humidity) that don't reliably send events
    • Slow Poll (120s): All characteristics as safety net
    • Detects missed events or stale values
    • Compares polled values against last known state
  3. Cloud API Sync (Metadata):

    • Every 4 hours: Battery status, device metadata, zone configuration
    • Uses ETag caching (304 responses) to minimize data transfer
    • OAuth2 token auto-refresh on-demand
    • Only 6 requests per day (well within 100/day limit)
  4. Change Tracking:

    • last_values dict stores previous values
    • Only logs/saves when values actually change
    • Tracks source (EVENT, POLLING, or CLOUD) for diagnostics

3. routes.py - FastAPI Endpoints

Location: tado_local/routes.py Lines: ~715 Purpose: HTTP REST API route handlers with optional Bearer token authentication

Authentication System:

The REST API supports optional multi-key authentication via Bearer tokens:

# Environment variable configuration
API_KEYS_RAW = os.environ.get('TADO_API_KEYS', '').strip()
API_KEYS = set(key.strip() for key in API_KEYS_RAW.split() if key.strip())

# FastAPI security dependency
security = HTTPBearer(auto_error=False)

def get_api_key(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
    """Validate API key - disabled if no keys configured"""
    if not API_KEYS:
        return None  # Authentication disabled (backward compatible)
    
    if not credentials or credentials.credentials not in API_KEYS:
        raise HTTPException(status_code=401, detail="Invalid authentication")
    
    return credentials.credentials

Authentication Behavior:

  • Disabled by default: If TADO_API_KEYS not set, all endpoints are public
  • Multi-key support: Space-separated keys in environment variable
  • Protected endpoints: All REST API routes require valid Bearer token when enabled
  • Unprotected endpoints: Web UI (/, /static/*) remains accessible without authentication
  • Backward compatible: Existing deployments continue to work without changes

Configuration Examples:

# Single key
export TADO_API_KEYS="my-secret-key"

# Multiple keys (for different clients)
export TADO_API_KEYS="domoticz-key homeassistant-key nodered-key"

# Windows PowerShell
$env:TADO_API_KEYS="key1 key2"

Client Usage:

# cURL with Bearer token
curl -H "Authorization: Bearer my-secret-key" http://localhost:4407/zones

# Python requests
headers = {"Authorization": "Bearer my-secret-key"}
response = requests.get("http://localhost:4407/zones", headers=headers)

Route Organization:

Information Endpoints

  • GET / - Web UI (unprotected)
  • GET /status - System health and statistics
  • GET /api - API information

HomeKit Data

  • GET /accessories - Raw HomeKit accessories (with optional UUID enhancement)
  • GET /accessories/{id} - Single accessory details

Tado-Specific

  • GET /thermostats - All thermostats with live state
  • GET /thermostats/{id} - Single thermostat with live state
  • POST /thermostats/{id}/set - Control temperature

Device Management

  • GET /devices - All registered devices with current state
  • GET /devices/{id} - Single device details
  • GET /devices/{id}/history - Time-series state history
  • POST /devices/{id}/set - Control device

Zone Management

  • GET /zones - All zones with device groupings
  • GET /zones/{id} - Single zone details
  • POST /zones/{id}/set - Control zone (temperature, heating state)

Real-Time & Admin

  • GET /events - Server-Sent Events stream
  • POST /refresh - Manual refresh from bridge
  • POST /refresh/cloud - Manual cloud sync

Route Registration:

def register_routes(app: FastAPI, get_tado_api: Callable):
    """Register all routes with dependency injection and authentication"""
    @app.get("/status", dependencies=[Depends(get_api_key)])
    async def get_status():
        tado_api = get_tado_api()  # Get current API instance
        # ... route logic

4. bridge.py - HomeKit Pairing

Location: tado_local/bridge.py Lines: ~796 Purpose: HomeKit bridge discovery, pairing, and connection management

Key Responsibilities:

  • Persistent controller identity management
  • Multi-approach pairing strategies
  • Pairing session state persistence and resumption
  • Connection lifecycle management

Core Components:

class TadoBridge:
    @staticmethod
    async def pair_or_load(bridge_ip, pin, db_path, clear_pairings)
        """Main entry point: load existing pairing or create new"""

    @staticmethod
    async def perform_pairing(host, port, pin, db_path)
        """Execute HomeKit pairing with persistent identity"""

    @staticmethod
    async def get_or_create_controller_identity(db_path)
        """Persistent Ed25519 controller identity"""

    @staticmethod
    async def save_pairing_session(db_path, bridge_ip, ...)
        """Save Part 1 state for resumption if Part 2 fails"""

Pairing Flow:

1. Check Database
   ├─► Existing pairing found
   │   ├─► Test connection
   │   ├─► Success: Use existing
   │   └─► Fail: Keep pairing (might be temporary network issue)
   └─► No pairing found
       └─► Require PIN

2. Initial Pairing (with PIN)
   ├─► Get/Create Persistent Controller Identity (Ed25519)
   ├─► Check for saved Part 1 session
   │   └─► Resume from Part 2 if available
   └─► Perform fresh pairing
       ├─► Part 1: SRP authentication
       │   └─► Save state to DB (in case Part 2 fails)
       ├─► Part 2: Finish with PIN verification
       └─► Save pairing data to DB

3. Connection Management
   ├─► Create IpPairing with controller instance
   ├─► Test connection with list_accessories()
   └─► Ready for API use

Pairing Strategies:

The bridge tries multiple approaches due to device-specific quirks:

  1. Single Connection: Keep connection open for Part 1 + Part 2
  2. Reconnect Between Parts: Close after Part 1, reopen for Part 2
  3. Feature Flag Variations: Try different HomeKit feature flags (0, 1)

Persistent Controller Identity:

Unlike typical HomeKit controllers, this uses a persistent Ed25519 identity:

  • Stored in controller_identity table
  • Reused across restarts
  • Enables session resumption
  • Prevents re-pairing on every restart

5. state.py - Device State Management

Location: tado_local/state.py Lines: ~443 Purpose: Device registry, state tracking, and time-series history

Key Responsibilities:

  • Device registration with serial number tracking
  • Zone assignment and management
  • Current state tracking in memory
  • Time-series history with 10-second bucketing
  • Change detection to avoid duplicate saves

Core Components:

class DeviceStateManager:
    # Characteristic UUID constants
    CHAR_CURRENT_TEMPERATURE = '00000011-...'
    CHAR_TARGET_TEMPERATURE = '00000035-...'
    CHAR_CURRENT_HUMIDITY = '00000010-...'
    # ... (13 tracked characteristics)

    # Caches
    device_id_cache: Dict[str, int]              # serial -> device_id
    device_info_cache: Dict[int, Dict]           # device_id -> info
    current_state: Dict[int, Dict]               # device_id -> current state

    # Change tracking
    last_saved_bucket: Dict[int, str]            # device_id -> last bucket
    bucket_state_snapshot: Dict[int, Dict]       # state when bucket saved

Key Methods:

Method Purpose
get_or_create_device(serial, aid, data) Register device, extract metadata
update_device_characteristic(id, type, value, time) Update single characteristic
_has_state_changed(device_id) Compare current vs snapshot
_save_to_history(device_id, time) Write to database with bucket
get_current_state(device_id) Retrieve live state
get_device_history(id, start, end, limit) Query time-series data

Time-Series Bucketing:

# 10-second bucket format: YYYYMMDDHHMMSSx (x = 0-5)
# Example: 20250101123450 (12:34:50-12:34:59)

def _get_timestamp_bucket(timestamp: float) -> str:
    dt = datetime.fromtimestamp(timestamp)
    second = (dt.second // 10) * 10  # Round down to 10s
    return dt.strftime(f'%Y%m%d%H%M{second:02d}')

State Update Flow:

1. Characteristic Change Event
   ├─► Map UUID to field name
   ├─► Check if value actually changed
   └─► Update current_state[device_id][field]

2. Save Decision
   ├─► Calculate current bucket (10s resolution)
   ├─► Compare to last saved bucket
   │   ├─► New bucket? Save
   │   └─► Same bucket? Check if state changed
   │       ├─► Changed? Save (update bucket)
   │       └─► Same? Skip (avoid duplicate)
   └─► Update snapshot for next comparison

3. Database Write
   ├─► INSERT OR REPLACE into device_state_history
   ├─► Use COALESCE to preserve existing values
   └─► Track bucket + snapshot for next change

Tracked Characteristics:

Category Characteristics
Temperature Current, Target, Heating/Cooling Threshold
HVAC State Current Mode, Target Mode, Display Units
Humidity Current, Target
Battery Level, Low Battery Status
Control Active State, Valve Position

6. cache.py - SQLite Characteristic Cache

Location: tado_local/cache.py Lines: ~160 Purpose: Persistent caching of HomeKit accessory metadata

Key Responsibilities:

  • Implement CharacteristicCacheMemory interface
  • Store accessory configurations in SQLite
  • Reduce HomeKit protocol overhead
  • Enable fast restarts without full discovery

Core Implementation:

class CharacteristicCacheSQLite(CharacteristicCacheMemory):
    """SQLite-backed cache with in-memory performance"""

    def __init__(self, db_path: str):
        super().__init__()  # Initialize in-memory cache
        self._init_db()     # Setup homekit_cache table
        self._load_from_db()  # Populate from SQLite

    def async_create_or_update_map(self, homekit_id, config_num, accessories, ...):
        # Update memory
        super().async_create_or_update_map(...)
        # Persist to DB
        self._save_to_db(...)

Cache Data Structure:

# Stored per homekit_id (pairing identifier)
{
    'config_num': int,           # HomeKit config version
    'accessories': list,         # Full accessory tree
    'broadcast_key': str,        # Optional encryption key
    'state_num': int,            # State tracking number
}

Performance Strategy:

  1. In-Memory First: All reads from RAM (inherited from parent class)
  2. Write-Through: Updates go to both memory and SQLite
  3. Load on Startup: Populate memory from DB on initialization
  4. Scales Well: Designed for dozens to thousands of accessories

7. database.py - Schema Definitions

Location: tado_local/database.py Lines: ~100 Purpose: Centralized database schema definitions

Schema Components:

-- Pairing and Identity
pairings                    -- Saved HomeKit pairing data
controller_identity         -- Persistent Ed25519 identity
pairing_sessions           -- Resume failed pairing attempts

-- Device Organization
zones                      -- Room/zone groupings
devices                    -- Device registry with metadata

-- State Tracking
device_state_history       -- Time-series state data (10s buckets)

-- HomeKit Cache
homekit_cache              -- Accessory metadata cache

Key Tables:

pairings

CREATE TABLE pairings (
    id INTEGER PRIMARY KEY,
    bridge_ip TEXT UNIQUE,
    pairing_data TEXT  -- JSON: {AccessoryPairingID, AccessoryLTPK, iOSDevicePairingID, ...}
);

tado_cloud_auth

CREATE TABLE tado_cloud_auth (
    id INTEGER PRIMARY KEY,
    home_id TEXT UNIQUE,
    access_token TEXT,          -- OAuth 2.0 Bearer access token
    refresh_token TEXT,         -- OAuth 2.0 refresh token
    token_expires_at TIMESTAMP, -- Token expiry time
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

Authentication Flow:

  • Uses OAuth 2.0 Device Authorization Grant (RFC 8628)
  • Access tokens are Bearer tokens sent as Authorization: Bearer <token>
  • Tokens auto-refresh before expiry using stored refresh_token
  • User authenticates once via browser, credentials never stored locally

tado_cloud_cache

CREATE TABLE tado_cloud_cache (
    home_id TEXT,
    endpoint TEXT,              -- e.g., '/zones', '/deviceList'
    response_data TEXT,         -- JSON response
    etag TEXT,                  -- For conditional requests (304 responses)
    fetched_at TIMESTAMP,
    expires_at TIMESTAMP,       -- Cache lifetime (4 hours)
    PRIMARY KEY (home_id, endpoint)
);

controller_identity

CREATE TABLE controller_identity (
    id INTEGER PRIMARY KEY,
    controller_id TEXT UNIQUE,    -- UUID
    private_key BLOB,             -- Ed25519 private key (DER)
    public_key BLOB,              -- Ed25519 public key (DER)
    created_at TIMESTAMP
);

devices

CREATE TABLE devices (
    device_id INTEGER PRIMARY KEY AUTOINCREMENT,
    serial_number TEXT UNIQUE NOT NULL,
    aid INTEGER,                  -- HomeKit accessory ID
    zone_id INTEGER,              -- Foreign key to zones
    device_type TEXT,             -- thermostat, temperature_sensor, etc.
    name TEXT,                    -- User-friendly name
    model TEXT,                   -- From AccessoryInformation
    manufacturer TEXT,
    first_seen TIMESTAMP,
    last_seen TIMESTAMP,
    FOREIGN KEY (zone_id) REFERENCES zones(zone_id)
);

device_state_history

CREATE TABLE device_state_history (
    device_id INTEGER NOT NULL,
    timestamp_bucket TEXT NOT NULL,  -- YYYYMMDDHHMMSSx (10s buckets)
    current_temperature REAL,
    target_temperature REAL,
    current_heating_cooling_state INTEGER,
    target_heating_cooling_state INTEGER,
    humidity REAL,
    battery_level INTEGER,
    valve_position INTEGER,
    -- ... 13 tracked fields total
    updated_at TIMESTAMP,
    PRIMARY KEY (device_id, timestamp_bucket),
    FOREIGN KEY (device_id) REFERENCES devices(device_id)
);
CREATE INDEX idx_history_device_time ON device_state_history(device_id, timestamp_bucket DESC);

zones

CREATE TABLE zones (
    zone_id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    leader_device_id INTEGER,     -- Primary thermostat for zone
    order_id INTEGER,             -- Display ordering
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    FOREIGN KEY (leader_device_id) REFERENCES devices(device_id)
);

8. cloud.py - Tado Cloud API Integration

Location: tado_local/cloud.py Lines: ~950 Purpose: OAuth2 Device Authorization Grant flow and cloud data synchronization

Key Responsibilities:

  • OAuth 2.0 Device Flow authentication (browser-based login)
  • Token management (access token, refresh token, auto-refresh)
  • API rate limit tracking (100 requests/day)
  • Response caching with ETag support (4-hour lifetime)
  • Background sync task (every 4 hours)

Core Components:

class TadoCloudAPI:
    # OAuth 2.0 endpoints
    AUTH_BASE_URL = "https://login.tado.com/oauth2"
    API_BASE_URL = "https://my.tado.com/api/v2"
    CLIENT_ID = "1bb50063-6b0c-4d11-bd99-387f4a91cc46"
    
    # User-Agent for API identification (establishes communication channel with Tado)
    USER_AGENT = f"TadoLocal/{__version__} (+https://github.com/ampscm/TadoLocal)"
    
    # Authentication state
    home_id: str                           # Tado home ID
    access_token: str                      # Current OAuth2 access token (Bearer)
    refresh_token: str                     # For token renewal
    token_expires_at: float                # Token expiry timestamp

    # OAuth device flow state (during authentication)
    device_code: str                       # Polling identifier
    auth_verification_uri: str             # Browser URL for user
    auth_user_code: str                    # Code to enter in browser

    # Rate limiting
    rate_limit: RateLimit                  # 100/day tracking

    # Background sync
    _refresh_task: asyncio.Task            # 4-hour sync loop

API Identification:

All requests to Tado Cloud API include a User-Agent header identifying TadoLocal:

  • Format: TadoLocal/{version} (+https://github.com/ampscm/TadoLocal)
  • Example: TadoLocal/1.0.0-alpha1 (+https://github.com/ampscm/TadoLocal)
  • Purpose: Establishes a communication channel with Tado for API changes and support

Key Methods:

Method Purpose
authenticate() Start OAuth2 device flow, poll for completion
ensure_authenticated() Check token validity, refresh if needed
get_headers() Build headers with Authorization: Bearer <token> and User-Agent
get_home_info() Fetch home details (cached 4 hours)
get_zones() Fetch zone configuration (cached 4 hours)
get_zone_states() Fetch battery status (cached 4 hours)
get_device_list() Fetch device metadata (cached 4 hours)
start_background_sync() Start 4-hour sync task
_background_sync_loop() Periodic sync with retry logic

OAuth 2.0 Device Authorization Grant Flow:

The system uses the Device Flow (RFC 8628) for browser-based authentication:

# Step 1: Request device code
POST https://login.tado.com/oauth2/device_authorize
Headers: {
    "User-Agent": "TadoLocal/1.0.0-alpha1 (+https://github.com/ampscm/TadoLocal)"
}
Body: {
    "client_id": "1bb50063-6b0c-4d11-bd99-387f4a91cc46",
    "scope": "offline_access"
}
Response: {
    "device_code": "xxx",
    "user_code": "ABC-DEF",
    "verification_uri_complete": "https://app.tado.com/oauth/device?user_code=ABC-DEF",
    "expires_in": 300,
    "interval": 5
}

# Step 2: Display URL to user
https://app.tado.com/oauth/device?user_code=ABC-DEF
(User logs in via browser, authorizes device)

# Step 3: Poll for token (every 5 seconds)
POST https://login.tado.com/oauth2/token
Headers: {
    "User-Agent": "TadoLocal/1.0.0-alpha1 (+https://github.com/ampscm/TadoLocal)"
}
Body: {
    "client_id": "1bb50063-6b0c-4d11-bd99-387f4a91cc46",
    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
    "device_code": "xxx"
}
Response (when authorized): {
    "access_token": "xxx",
    "refresh_token": "yyy",
    "token_type": "Bearer",
    "expires_in": 599
}

# Step 4: Store in database
INSERT INTO tado_cloud_auth (
    home_id, access_token, refresh_token, token_expires_at
) VALUES (?, ?, ?, ?)

# Step 5: Auto-refresh before expiry
POST https://login.tado.com/oauth2/token
Headers: {
    "User-Agent": "TadoLocal/1.0.0-alpha1 (+https://github.com/ampscm/TadoLocal)"
}
Body: {
    "client_id": "1bb50063-6b0c-4d11-bd99-387f4a91cc46",
    "grant_type": "refresh_token",
    "refresh_token": "yyy"
}

# Step 6: API requests with User-Agent
GET https://my.tado.com/api/v2/homes/{home_id}/zones
Headers: {
    "Authorization": "Bearer xxx",
    "User-Agent": "TadoLocal/1.0.0-alpha1 (+https://github.com/ampscm/TadoLocal)",
    "Content-Type": "application/json"
}

Caching Strategy:

  • ETag Support: 304 responses when data unchanged
  • 4-hour lifetime: Balance freshness vs API limits
  • Per-endpoint cache: Separate expiry for home, zones, devices
  • 6 requests/day: 4 endpoints × 6 syncs = 24 API calls (well under 100 limit)

9. sync.py - Cloud to Database Sync

Location: tado_local/sync.py Lines: ~400 Purpose: Synchronize cloud API data to local SQLite database

Key Responsibilities:

  • Parse cloud API responses
  • Update device metadata (model, manufacturer, battery state)
  • Create/update zones and zone assignments
  • Match cloud devices to HomeKit devices via serial numbers

Core Method:

class TadoCloudSync:
    async def sync_all(self, cloud_api) -> bool:
        """
        Comprehensive sync from cloud to database.

        Steps:
        1. Fetch home info, zones, zone states, device list from cloud
        2. For each zone: Create/update in DB, parse battery status
        3. For each device: Match by serial, update metadata and zone
        4. Set zone leaders based on device types
        """

Device Matching:

# Match cloud devices to local devices by serial number
cloud_device = {"shortSerialNo": "RU1234567890", "batteryState": "NORMAL"}
local_device_id = get_device_id_by_serial("RU1234567890")

# Update local device with cloud metadata
UPDATE devices SET
    battery_state = 'NORMAL',
    device_type = 'RU02',
    zone_id = 4
WHERE device_id = local_device_id

10. homekit_uuids.py - UUID Mappings

Location: tado_local/homekit_uuids.py (and legacy in root) Purpose: Human-readable names for HomeKit UUIDs

Functions:

  • get_service_name(uuid) - Service type names
  • get_characteristic_name(uuid) - Characteristic names
  • enhance_accessory_data(accessories) - Add readable names to raw data

Example Mappings:

SERVICE_UUIDS = {
    '0000003e-...': 'AccessoryInformation',
    '0000004a-...': 'Thermostat',
    '0000008a-...': 'TemperatureSensor',
    '00000082-...': 'HumiditySensor',
}

CHARACTERISTIC_UUIDS = {
    '00000011-...': 'CurrentTemperature',
    '00000035-...': 'TargetTemperature',
    '00000010-...': 'CurrentRelativeHumidity',
    '00000068-...': 'BatteryLevel',
}

Data Flow

1. Initial Startup Flow

┌─────────────────────────────────────────────────────────────┐
│ 1. Command Line Parsing                                      │
│    First run: python -m tado_local --bridge-ip IP --pin PIN │
│    Subsequent: python -m tado_local (auto-discovers)        │
└────────────────┬────────────────────────────────────────────┘
                 ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Database Initialization                                   │
│    - Create/open ~/.tado-local.db                           │
│    - Execute schema (pairings, cloud_auth, devices, zones)  │
└────────────────┬────────────────────────────────────────────┘
                 ▼
┌─────────────────────────────────────────────────────────────┐
│ 3. HomeKit Pairing Setup (TadoBridge.pair_or_load)          │
│    ├─► Load existing pairing from DB                        │
│    ├─► Test connection                                      │
│    └─► Or perform fresh pairing with PIN (first run only)   │
└────────────────┬────────────────────────────────────────────┘
                 ▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Cloud API Setup (TadoCloudAPI)                           │
│    ├─► Load OAuth2 tokens from DB                           │
│    ├─► If not authenticated: Start OAuth flow               │
│    │   └─► Display browser URL for user login              │
│    ├─► If authenticated: Validate token                     │
│    └─► Start 4-hour background sync task                    │
└────────────────┬────────────────────────────────────────────┘
                 ▼
┌─────────────────────────────────────────────────────────────┐
│ 5. API Initialization (TadoLocalAPI.initialize)             │
│    ├─► Refresh accessories from bridge                      │
│    ├─► Sync device metadata from cloud                      │
│    ├─► Register devices in database                         │
│    ├─► Load last known state from history                   │
│    ├─► Setup HomeKit event subscriptions                    │
│    └─► Start background polling tasks                       │
└────────────────┬────────────────────────────────────────────┘
                 ▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Web Server Start (uvicorn)                               │
│    - FastAPI app with all routes                            │
│    - Listen on 0.0.0.0:4407                                 │
│    - Interactive docs at /docs                              │
│    - Status at /status shows cloud auth state               │
└─────────────────────────────────────────────────────────────┘

2. Real-Time Update Flow

HomeKit Bridge State Change
         │
         ▼
┌─────────────────────┐
│ Event Notification  │ ◄─── HomeKit events (instant)
│  or Polling Result  │ ◄─── Background polling (60s/120s)
└──────────┬──────────┘
           │
           ▼
┌─────────────────────────────────────────────────────────────┐
│ TadoLocalAPI.handle_change(aid, iid, value, source)        │
│                                                              │
│  1. Lookup device_id from aid                               │
│  2. Check last_values for actual change                     │
│  3. Map characteristic UUID to field name                   │
│  4. Log change with zone/device context                     │
│  5. Update change_tracker metrics                           │
└──────────┬──────────────────────────────────────────────────┘
           │
           ▼
┌─────────────────────────────────────────────────────────────┐
│ DeviceStateManager.update_device_characteristic()           │
│                                                              │
│  1. Update current_state[device_id][field] = value          │
│  2. Calculate 10-second bucket                              │
│  3. Compare to last_saved_bucket and snapshot               │
│  4. Save to DB if: new bucket OR state changed              │
│  5. Update snapshot for next comparison                     │
└──────────┬──────────────────────────────────────────────────┘
           │
           ├─────────────────────┬─────────────────────────────┐
           ▼                     ▼                             ▼
  ┌─────────────────┐  ┌──────────────────┐   ┌──────────────────┐
  │  SQLite Write   │  │ Broadcast to SSE │   │ REST API Queries │
  │  (history table)│  │ Event Clients    │   │ (live data)      │
  └─────────────────┘  └──────────────────┘   └──────────────────┘

3. REST API Request Flow

Client HTTP Request
         │
         ▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Route Handler (routes.py)                           │
│  - GET /thermostats                                         │
│  - GET /devices/{id}                                        │
│  - POST /thermostats/{id}/set_temperature                   │
└──────────┬──────────────────────────────────────────────────┘
           │
           ▼
┌─────────────────────────────────────────────────────────────┐
│ TadoLocalAPI / DeviceStateManager                           │
│  - Access current_state (in-memory)                         │
│  - Query device_state_history (SQLite)                      │
│  - Send HomeKit commands via pairing.put_characteristics()  │
└──────────┬──────────────────────────────────────────────────┘
           │
           ▼
  ┌────────────────┐
  │ JSON Response  │
  └────────────────┘

Database Design

Entity Relationship Diagram

┌─────────────────────┐
│ controller_identity │
│─────────────────────│
│ id (PK)             │
│ controller_id       │◄────────┐
│ private_key         │         │
│ public_key          │         │
└─────────────────────┘         │
                                │
┌─────────────────────┐         │
│ pairing_sessions    │         │
│─────────────────────│         │
│ id (PK)             │         │
│ bridge_ip           │         │
│ controller_id ──────┼─────────┘
│ session_state       │
│ part1_salt          │
│ part1_public_key    │
└─────────────────────┘

┌─────────────────────┐
│ pairings            │
│─────────────────────│
│ id (PK)             │
│ bridge_ip (UNIQUE)  │
│ pairing_data (JSON) │
└─────────────────────┘

                    ┌─────────────────────┐
                    │ zones               │
                    │─────────────────────│
                    │ zone_id (PK)        │◄──┐
                    │ name                │   │
              ┌────►│ leader_device_id    │   │
              │     │ order_id            │   │
              │     └─────────────────────┘   │
              │                               │
┌─────────────┴─────────┐                     │
│ devices               │                     │
│───────────────────────│                     │
│ device_id (PK)        │─────────────────────┤
│ serial_number (UNIQUE)│                     │
│ aid                   │                     │
│ zone_id (FK) ─────────┘                     │
│ device_type           │                     │
│ name                  │                     │
│ model                 │                     │
│ manufacturer          │                     │
└───────────┬───────────┘
            │
            │ 1:N
            ▼
┌─────────────────────────────┐
│ device_state_history        │
│─────────────────────────────│
│ device_id (PK, FK)          │
│ timestamp_bucket (PK)       │
│ current_temperature         │
│ target_temperature          │
│ humidity                    │
│ battery_level               │
│ valve_position              │
│ ... (13 fields total)       │
└─────────────────────────────┘

┌─────────────────────────────┐
│ homekit_cache               │
│─────────────────────────────│
│ homekit_id (PK)             │
│ config_num                  │
│ accessories (JSON)          │
│ broadcast_key               │
│ state_num                   │
└─────────────────────────────┘

Key Relationships

  1. Zones ↔ Devices: One-to-many (zone contains multiple devices)
  2. Zones ↔ Leader Device: Self-referential (zone has one leader device)
  3. Devices ↔ History: One-to-many (device has many history records)
  4. Controller Identity ↔ Pairing Sessions: One-to-many (identity reused across sessions)

Event System Design

Triple-Source Architecture

The event system uses three complementary data sources for maximum reliability:

1. HomeKit Event Subscriptions (Primary)

# Subscribe to ALL event-capable characteristics
all_event_chars = [(aid, iid) for char in all_chars if 'ev' in char['perms']]
await pairing.subscribe(all_event_chars)

# Register unified callback
def event_callback(update_data):
    for (aid, iid), value_dict in update_data.items():
        asyncio.create_task(handle_change(aid, iid, value_dict, "EVENT"))

pairing.dispatcher_connect(event_callback)

Advantages:

  • Instant notifications (< 1 second latency)
  • Low network overhead
  • Battery efficient for devices

Limitations:

  • Some characteristics don't reliably send events (humidity)
  • Connection interruptions can miss events
  • Device firmware bugs may not fire events

2. HomeKit Background Polling (Backup)

# Two polling speeds
FAST_POLL = 60s   # Priority characteristics (humidity) that don't reliably send events
SLOW_POLL = 120s  # Everything else (safety net)

async def background_polling_loop():
    while not shutting_down:
        # Fast poll priority chars
        if elapsed >= FAST_POLL:
            await poll_characteristics(priority_chars, "FAST-POLL")

        # Slow poll all chars
        if elapsed >= SLOW_POLL:
            await poll_characteristics(all_chars, "POLLING")

Advantages:

  • Catches missed events
  • Detects stale cached values
  • Works even if events fail
  • Regular health check

Limitations:

  • Higher latency (60-120 seconds)
  • More network traffic
  • Bridge processing overhead

3. Cloud API Sync (Metadata & Battery)

# Background sync every 4 hours
CLOUD_SYNC_INTERVAL = 4 * 3600  # 14400 seconds

async def background_sync_loop():
    while not shutting_down:
        if is_authenticated():
            # Fetch device metadata, battery status, zone config
            home_info = await get_home_info()
            zones = await get_zones()
            zone_states = await get_zone_states()
            devices = await get_device_list()

            # Update database with fresh metadata
            await sync.sync_all(cloud_api)

        await asyncio.sleep(CLOUD_SYNC_INTERVAL)

Advantages:

  • Battery status (not available via HomeKit)
  • Device metadata (model, manufacturer, serial)
  • Zone names and configuration
  • Uses ETag caching (304 responses)
  • Only 6 requests/day (well within 100/day limit)

Limitations:

  • Requires OAuth2 authentication
  • 4-hour latency for battery updates
  • Internet dependency

Change Detection & Deduplication

# Change tracker stores last known values
change_tracker = {
    'last_values': {(aid, iid): value, ...},
    'events_received': count,
    'polling_changes': count,
    'cloud_syncs': count,
}

async def handle_change(aid, iid, update_data, source):
    value = update_data['value']

    # Ignore None values (connection issues)
    if value is None:
        return

    # Check if actually changed
    last_value = change_tracker['last_values'].get((aid, iid))
    if last_value == value:
        return  # No change, skip

    # Store new value
    change_tracker['last_values'][(aid, iid)] = value

    # Update state manager
    state_manager.update_device_characteristic(device_id, char_type, value, time)

    # Log with source
    logger.info(f"[{source[0]}] Z: {zone} | D: {device} | {char}: {last} -> {value}")

    # Broadcast to SSE clients
    await broadcast_event(event_data)

Server-Sent Events (SSE) Stream

@app.get("/events")
async def get_events():
    async def event_publisher():
        client_queue = asyncio.Queue()
        tado_api.event_listeners.append(client_queue)

        try:
            while True:
                event_data = await asyncio.wait_for(client_queue.get(), timeout=30)
                yield event_data  # "data: {json}\n\n" format
        except asyncio.TimeoutError:
            yield "data: {'type': 'keepalive'}\n\n"
        finally:
            tado_api.event_listeners.remove(client_queue)

    return StreamingResponse(
        event_publisher(),
        media_type="text/event-stream"
    )

Event Format:

{
  "source": "EVENT",
  "timestamp": 1730476832.5,
  "aid": 2,
  "iid": 15,
  "characteristic": "CurrentTemperature",
  "value": 21.3,
  "previous_value": 21.2,
  "id": 1,
  "zone_name": "Living Room",
  "device_name": "Thermostat 01"
}

Pairing & Security

HomeKit Pairing Process

HomeKit uses SRP (Secure Remote Password) authentication with Ed25519 key exchange:

┌──────────────────────────────────────────────────────────────┐
│ Part 1: SRP Authentication (no PIN required)                 │
└──────────┬───────────────────────────────────────────────────┘
           │
           ├─► M1: Client sends SRP start (username)
           ├─► M2: Accessory responds with salt, public key B
           ├─► M3: Client sends public key A, proof M1
           ├─► M4: Accessory sends proof M2
           │
           └─► Result: salt, server_public_key
                       (Save to DB for potential resumption)

┌──────────────────────────────────────────────────────────────┐
│ Part 2: Key Exchange & Verification (requires PIN)           │
└──────────┬───────────────────────────────────────────────────┘
           │
           ├─► M5: Client sends encrypted device info + LTPK
           │       (Long-Term Public Key from persistent identity)
           ├─► M6: Accessory responds with encrypted accessory info + LTSK
           │       (Long-Term Secret Key)
           │
           └─► Result: Pairing complete
                       {AccessoryPairingID, AccessoryLTPK,
                        iOSDevicePairingID, iOSDeviceLTSK, ...}
                       (Save to pairings table)

Persistent Controller Identity

Unlike most HomeKit controllers, this system uses a persistent Ed25519 identity:

# Stored in controller_identity table
controller_id = str(uuid.uuid4())  # e.g., "a3b4c5d6-..."
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()

# Serialized as DER for SQLite storage
private_key_bytes = private_key.private_bytes(
    encoding=serialization.Encoding.DER,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption()
)

Benefits:

  1. Survive Restarts: Don't need to re-pair on every app restart
  2. Session Resumption: Can resume from Part 2 if Part 1 succeeded but Part 2 failed
  3. Stable Identity: Bridge recognizes the same controller
  4. Audit Trail: Track which controller performed actions

Pairing Session Persistence

If Part 2 fails, Part 1 state is saved:

INSERT INTO pairing_sessions (
    bridge_ip,
    controller_id,
    session_state,  -- 'part1_complete'
    part1_salt,
    part1_public_key
) VALUES (?, ?, ?, ?, ?)

Next attempt can resume:

saved_session = get_pairing_session(db_path, bridge_ip)
if saved_session:
    # Skip Part 1, go straight to Part 2
    perform_part2_only(host, port, pin, controller_id, salt, public_key)

Security Considerations

HomeKit Security

  1. Encryption: All HomeKit communication uses ChaCha20-Poly1305
  2. Authentication: SRP prevents man-in-the-middle attacks
  3. Key Storage: Private keys stored in SQLite (should be file-system encrypted)
  4. Single Connection: Bridge allows only ONE HomeKit pairing at a time

REST API Security

The REST API supports optional Bearer token authentication:

Authentication Model:

  • Multi-key support: Space-separated API keys in TADO_API_KEYS environment variable
  • Disabled by default: If not configured, all endpoints are public (backward compatible)
  • FastAPI Security: Uses HTTPBearer with Bearer token validation
  • Selective protection: API endpoints require authentication, Web UI remains public

Configuration:

# Single key
export TADO_API_KEYS="my-secret-key"

# Multiple keys for different clients
export TADO_API_KEYS="domoticz-key homeassistant-key testing-key"

Usage:

# Client sends Bearer token
curl -H "Authorization: Bearer my-secret-key" http://localhost:4407/zones

Protected vs Unprotected:

  • Protected (when auth enabled): All REST API endpoints (/zones, /devices, /thermostats, /events, etc.)
  • Always public: Web UI (/, /static/*) for troubleshooting

Security Characteristics:

  • ✅ Prevents accidental access from unauthorized clients
  • ✅ Allows key rotation (add new keys, remove old)
  • ✅ Supports multiple clients with distinct keys
  • ⚠️ HTTP-based (not encrypted in transit)
  • ⚠️ Basic authentication, not OAuth/JWT
  • ⚠️ Not suitable for internet-exposed endpoints

Cloud API Security

  • OAuth 2.0 Device Flow: Browser-based authentication, no password storage
  • Bearer tokens: All cloud API requests use Authorization: Bearer <token>
  • Token refresh: Automatic refresh before expiry
  • Secure storage: OAuth tokens stored in SQLite database

General Security Recommendations

  • Use filesystem encryption for ~/.tado-local.db (contains pairing keys and OAuth tokens)
  • Restrict file permissions: chmod 600 ~/.tado-local.db
  • Run on trusted local network only
  • For remote access, use reverse proxy with TLS (nginx, Traefik, Caddy)
  • Enable REST API authentication for production deployments
  • Consider firewall rules to restrict access to trusted clients

Configuration & Deployment

Command-Line Options

python -m tado_local [OPTIONS]

Options:
  --state PATH          Database path (default: ~/.tado-local.db)
  --bridge-ip IP        Bridge IP address (auto-discover if omitted)
  --pin XXX-XX-XXX      HomeKit PIN for initial pairing
  --port PORT           API port (default: 4407)
  --clear-pairings      Remove all pairings before starting

Environment Setup

Recommended:

# Install as package
pip install -e .

# Run from anywhere
python -m tado_local --bridge-ip 192.168.1.100

Development:

# Install dependencies only
pip install -r requirements.txt

# Run directly
python local.py --bridge-ip 192.168.1.100

Database Location

Default: ~/.tado-local.db

Custom location:

python -m tado_local --state /opt/tado/data.db

Structure:

~/.tado-local.db
├── pairings (HomeKit connection credentials)
├── controller_identity (persistent Ed25519 keys)
├── pairing_sessions (session resumption data)
├── zones (room organization)
├── devices (device registry)
├── device_state_history (time-series data)
└── homekit_cache (accessory metadata)

Running as Service

systemd (Linux):

[Unit]
Description=Tado Local
After=network.target

[Service]
Type=simple
User=tado
ExecStart=/usr/bin/python3 -m tado_local --bridge-ip 192.168.1.100
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Windows Service (with NSSM):

nssm install TadoLocal "C:\Python311\python.exe" "-m tado_local --bridge-ip 192.168.1.100"
nssm start TadoLocal

API Usage Examples

Python Client

import requests

BASE_URL = "http://localhost:4407"
API_KEY = "your-secret-key"  # Optional - only if server has TADO_API_KEYS configured

# Configure headers with optional Bearer token
headers = {}
if API_KEY:
    headers["Authorization"] = f"Bearer {API_KEY}"

# Get all thermostats
response = requests.get(f"{BASE_URL}/thermostats", headers=headers)
thermostats = response.json()["thermostats"]

for thermo in thermostats:
    print(f"{thermo['name']}: {thermo['current_temperature']}°C")

# Set temperature
requests.post(
    f"{BASE_URL}/thermostats/1/set",
    json={"temperature": 22.0},
    headers=headers
)

# Get device history
response = requests.get(
    f"{BASE_URL}/devices/1/history",
    params={"limit": 100},
    headers=headers
)
history = response.json()["history"]

JavaScript/Node.js

const fetch = require('node-fetch');

const BASE_URL = 'http://localhost:4407';
const API_KEY = 'your-secret-key';  // Optional

// Configure headers with optional Bearer token
const headers = { 'Content-Type': 'application/json' };
if (API_KEY) {
  headers['Authorization'] = `Bearer ${API_KEY}`;
}

// SSE event stream with authentication
const EventSource = require('eventsource');
const eventSourceOptions = API_KEY ? {
  headers: { 'Authorization': `Bearer ${API_KEY}` }
} : {};
const events = new EventSource(`${BASE_URL}/events`, eventSourceOptions);

events.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log(`${data.device_name}: ${data.characteristic} = ${data.value}`);
};

// Control temperature
async function setTemp(deviceId, temperature) {
  await fetch(`${BASE_URL}/thermostats/${deviceId}/set`, {
    method: 'POST',
    headers: headers,
    body: JSON.stringify({ temperature })
  });
}

Bash/cURL

# Configuration
API_KEY="your-secret-key"  # Optional - only if server has TADO_API_KEYS configured

# Get status (with optional authentication)
curl -H "Authorization: Bearer $API_KEY" http://localhost:4407/status | jq

# Get all zones
curl -H "Authorization: Bearer $API_KEY" http://localhost:4407/zones | jq '.zones[] | {name, device_count}'

# Stream events
curl -N -H "Authorization: Bearer $API_KEY" http://localhost:4407/events

# Set temperature
curl -X POST http://localhost:4407/thermostats/1/set \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"temperature": 21.5}'

# Get device history
curl -H "Authorization: Bearer $API_KEY" \
  "http://localhost:4407/devices/1/history?limit=50" | jq

# Without authentication (if TADO_API_KEYS not configured)
curl http://localhost:4407/status | jq

Performance Characteristics

Response Times

Operation Typical Latency Notes
GET /thermostats < 10ms In-memory state
GET /devices/{id}/history 10-50ms SQLite query
POST /set_temperature 100-500ms HomeKit command + ACK
Event notification < 1s Real-time events
Polling update 60-120s Background poll cycle

Scalability

Metric Typical Maximum Tested
Devices 5-20 50+
SSE Clients 1-5 20+
Database Size 10-50 MB/year Unlimited
Memory Usage 50-100 MB Depends on accessories
CPU Usage < 5% Background tasks

Database Performance

  • State History: ~10 MB per device per year (10s buckets)
  • Query Speed: < 50ms for 1000 records
  • Write Speed: Batched, ~1 write per 10s per device
  • Indexes: Optimized for time-range queries

Troubleshooting

Common Issues

1. Pairing Fails with "Unavailable"

Cause: Bridge already paired to another controller

Solutions:

  • Remove from iPhone/iPad Home app
  • Remove from other Home Assistant instances
  • Factory reset bridge (hold button 10+ seconds)
  • Check for sf=0 (paired) vs sf=1 (unpaired) in mDNS

2. Events Not Firing

Symptoms: Values only update every 60-120 seconds

Diagnosis:

# Check event vs polling ratio
curl http://localhost:4407/status | jq '.events_received, .polling_changes'

# Debug specific characteristic
curl http://localhost:4407/debug/humidity

Causes:

  • Characteristic doesn't support events (missing ev permission)
  • Device firmware bug
  • Connection interruption

Mitigation: Polling backup automatically handles this

3. Database Locked Errors

Cause: SQLite concurrency limits

Solutions:

  • Ensure only one instance running
  • Check for hung processes
  • Delete lock file: rm ~/.tado-local.db-shm ~/.tado-local.db-wal

4. Connection Drops

Symptoms: None values in logs, intermittent errors

Built-in Handling:

  • Ignores None values (prevents false state updates)
  • Events restore correct state when connection returns
  • Background polling provides safety net

Manual Recovery:

# Restart proxy
systemctl restart tado-local

# Or force refresh
curl -X POST http://localhost:4407/refresh

Future Enhancements

Planned Features

  1. Docker Support

    • Pre-built container image
    • docker-compose.yml for easy deployment
    • Health checks and auto-restart
  2. Home Assistant Integration

    • HACS custom component
    • Discovery via mDNS
    • Native entities and automation
  3. Web UI

    • Real-time dashboard
    • Zone management
    • Historical graphs
    • Temperature control
  4. Advanced Features

    • Schedule management
    • Presence detection integration
    • Multi-bridge support
    • Custom automation rules
  5. Testing & CI/CD

    • Unit test coverage
    • Integration tests
    • GitHub Actions pipeline
    • Automated releases

Architecture Evolution

Current: Monolithic API server with embedded state management

Future: Microservices approach

  • HomeKit gateway service
  • REST API service
  • WebSocket event service
  • State persistence service
  • Web UI service

Contributing

Code Organization Guidelines

  1. Keep modules focused: Each file should have a single responsibility
  2. Use type hints: All public functions should have type annotations
  3. Document with docstrings: Class and method documentation required
  4. Follow PEP 8: Use black for formatting, mypy for type checking
  5. Write tests: New features require test coverage

Development Workflow

# Setup
git clone https://github.com/yourusername/tado-local.git
cd tado-local
pip install -e .[dev]

# Run
python -m tado_local --bridge-ip 192.168.1.100

# Test
pytest tests/

# Format
black tado_local/
mypy tado_local/

License

Apache License 2.0 - See LICENSE file

Acknowledgments

  • aiohomekit: HomeKit protocol implementation by Jc2k
  • FastAPI: Modern web framework by Sebastián Ramírez
  • Home Assistant: Inspiration for pairing logic and error handling
  • Tado Community: Reverse engineering efforts and documentation

Document Version: 1.1 Last Updated: November 3, 2025 Maintainer: Tado Local Team

Change Log:

  • v1.1 (Nov 3, 2025): Added OAuth 2.0 Device Flow documentation, Bearer token authentication system, security model updates
  • v1.0 (Nov 1, 2025): Initial comprehensive design document