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.
- Local-First: Direct HomeKit communication without cloud dependency
- Clean REST API: Simple HTTP endpoints for easy integration
- Real-Time Updates: Event-driven state management with SSE streaming
- State Persistence: SQLite-backed history and configuration storage
- Modular Architecture: Clean separation of concerns for maintainability
- Network Resilience: Automatic reconnection and change detection
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
| 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 |
┌─────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────┘
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)
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 cleanupKey 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:
-
HomeKit Events (Primary): Real-time notifications from bridge
- Subscribe to all characteristics with
evpermission - Instant updates for temperature, heating state, valve position
- Register dispatcher callback for all events
- Subscribe to all characteristics with
-
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
-
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)
-
Change Tracking:
last_valuesdict stores previous values- Only logs/saves when values actually change
- Tracks source (
EVENT,POLLING, orCLOUD) for diagnostics
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.credentialsAuthentication Behavior:
- Disabled by default: If
TADO_API_KEYSnot 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:
GET /- Web UI (unprotected)GET /status- System health and statisticsGET /api- API information
GET /accessories- Raw HomeKit accessories (with optional UUID enhancement)GET /accessories/{id}- Single accessory details
GET /thermostats- All thermostats with live stateGET /thermostats/{id}- Single thermostat with live statePOST /thermostats/{id}/set- Control temperature
GET /devices- All registered devices with current stateGET /devices/{id}- Single device detailsGET /devices/{id}/history- Time-series state historyPOST /devices/{id}/set- Control device
GET /zones- All zones with device groupingsGET /zones/{id}- Single zone detailsPOST /zones/{id}/set- Control zone (temperature, heating state)
GET /events- Server-Sent Events streamPOST /refresh- Manual refresh from bridgePOST /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 logicLocation: 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:
- Single Connection: Keep connection open for Part 1 + Part 2
- Reconnect Between Parts: Close after Part 1, reopen for Part 2
- 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_identitytable - Reused across restarts
- Enables session resumption
- Prevents re-pairing on every restart
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 savedKey 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 |
Location: tado_local/cache.py
Lines: ~160
Purpose: Persistent caching of HomeKit accessory metadata
Key Responsibilities:
- Implement
CharacteristicCacheMemoryinterface - 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:
- In-Memory First: All reads from RAM (inherited from parent class)
- Write-Through: Updates go to both memory and SQLite
- Load on Startup: Populate memory from DB on initialization
- Scales Well: Designed for dozens to thousands of accessories
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 cacheKey Tables:
CREATE TABLE pairings (
id INTEGER PRIMARY KEY,
bridge_ip TEXT UNIQUE,
pairing_data TEXT -- JSON: {AccessoryPairingID, AccessoryLTPK, iOSDevicePairingID, ...}
);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
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)
);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
);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)
);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);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)
);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 loopAPI 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)
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_idLocation: tado_local/homekit_uuids.py (and legacy in root)
Purpose: Human-readable names for HomeKit UUIDs
Functions:
get_service_name(uuid)- Service type namesget_characteristic_name(uuid)- Characteristic namesenhance_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',
}┌─────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────┘
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) │
└─────────────────┘ └──────────────────┘ └──────────────────┘
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 │
└────────────────┘
┌─────────────────────┐
│ 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 │
└─────────────────────────────┘
- Zones ↔ Devices: One-to-many (zone contains multiple devices)
- Zones ↔ Leader Device: Self-referential (zone has one leader device)
- Devices ↔ History: One-to-many (device has many history records)
- Controller Identity ↔ Pairing Sessions: One-to-many (identity reused across sessions)
The event system uses three complementary data sources for maximum reliability:
# 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
# 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
# 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 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)@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"
}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)
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:
- Survive Restarts: Don't need to re-pair on every app restart
- Session Resumption: Can resume from Part 2 if Part 1 succeeded but Part 2 failed
- Stable Identity: Bridge recognizes the same controller
- Audit Trail: Track which controller performed actions
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)- Encryption: All HomeKit communication uses ChaCha20-Poly1305
- Authentication: SRP prevents man-in-the-middle attacks
- Key Storage: Private keys stored in SQLite (should be file-system encrypted)
- Single Connection: Bridge allows only ONE HomeKit pairing at a time
The REST API supports optional Bearer token authentication:
Authentication Model:
- Multi-key support: Space-separated API keys in
TADO_API_KEYSenvironment variable - Disabled by default: If not configured, all endpoints are public (backward compatible)
- FastAPI Security: Uses
HTTPBearerwith 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/zonesProtected 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
- 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
- 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
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 startingRecommended:
# Install as package
pip install -e .
# Run from anywhere
python -m tado_local --bridge-ip 192.168.1.100Development:
# Install dependencies only
pip install -r requirements.txt
# Run directly
python local.py --bridge-ip 192.168.1.100Default: ~/.tado-local.db
Custom location:
python -m tado_local --state /opt/tado/data.dbStructure:
~/.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)
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.targetWindows Service (with NSSM):
nssm install TadoLocal "C:\Python311\python.exe" "-m tado_local --bridge-ip 192.168.1.100"
nssm start TadoLocalimport 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"]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 })
});
}# 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| 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 |
| 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 |
- 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
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) vssf=1(unpaired) in mDNS
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/humidityCauses:
- Characteristic doesn't support events (missing
evpermission) - Device firmware bug
- Connection interruption
Mitigation: Polling backup automatically handles this
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
Symptoms: None values in logs, intermittent errors
Built-in Handling:
- Ignores
Nonevalues (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-
Docker Support
- Pre-built container image
- docker-compose.yml for easy deployment
- Health checks and auto-restart
-
Home Assistant Integration
- HACS custom component
- Discovery via mDNS
- Native entities and automation
-
Web UI
- Real-time dashboard
- Zone management
- Historical graphs
- Temperature control
-
Advanced Features
- Schedule management
- Presence detection integration
- Multi-bridge support
- Custom automation rules
-
Testing & CI/CD
- Unit test coverage
- Integration tests
- GitHub Actions pipeline
- Automated releases
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
- Keep modules focused: Each file should have a single responsibility
- Use type hints: All public functions should have type annotations
- Document with docstrings: Class and method documentation required
- Follow PEP 8: Use
blackfor formatting,mypyfor type checking - Write tests: New features require test coverage
# 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/Apache License 2.0 - See LICENSE file
- 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