Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions tado_local/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from .api import TadoLocalAPI
from .cloud import TadoCloudAPI
from .routes import create_app, register_routes
from .scheduler import SchedulerService

# Logger will be configured in main() based on daemon/console mode
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -129,6 +130,11 @@ def handle_signal(signum, frame):
# Initialize the API with the pairing
await tado_api.initialize(bridge_pairing)

# Initialize and start scheduler service
tado_api.scheduler_service = SchedulerService(str(db_path), tado_api)
tado_api.scheduler_service.start()
logger.info("Temperature scheduler service started")

# Register mDNS service asynchronously (Avahi via DBus preferred, fall back to zeroconf)
if not args.no_mdns:
try:
Expand Down
119 changes: 118 additions & 1 deletion tado_local/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from .state import DeviceStateManager
from .homekit_uuids import get_characteristic_name
from .scheduler import SchedulerService

# Configure logging
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -62,13 +63,20 @@ def __init__(self, db_path: str):
self.subscribed_characteristics: List[tuple[int, int]] = []
self.background_tasks: List[asyncio.Task] = []
self.is_shutting_down = False

# Scheduler service
self.scheduler_service: Optional[Any] = None

async def initialize(self, pairing: IpPairing):
"""Initialize the API with a HomeKit pairing."""
self.pairing = pairing
self.is_initializing = True # Suppress change logging during init
await self.refresh_accessories()
await self.initialize_device_states()

# Set all zones to Auto mode on startup
await self._set_all_zones_to_auto_mode()

self.is_initializing = False # Re-enable change logging
await self.setup_event_listeners()
logger.info("Tado Local initialized successfully")
Expand All @@ -78,6 +86,10 @@ async def cleanup(self):
logger.info("Starting cleanup...")
self.is_shutting_down = True

# Stop scheduler service
if self.scheduler_service:
await self.scheduler_service.stop()

# Cancel all background tasks
if self.background_tasks:
logger.info(f"Cancelling {len(self.background_tasks)} background tasks")
Expand Down Expand Up @@ -266,6 +278,40 @@ async def initialize_device_states(self):

logger.info(f"Device state initialization complete - baseline established for {len(self.device_to_characteristics)} devices")

async def _set_all_zones_to_auto_mode(self):
"""Set all zones to Auto mode (tracked_mode=3) on startup."""
if not self.pairing:
logger.warning("No pairing available, skipping Auto mode initialization")
return

zones_set = 0
for zone_id, zone_info in self.state_manager.zone_cache.items():
try:
# Set tracked_mode to 3 (Auto) in database and cache
self.state_manager.set_zone_tracked_mode(zone_id, 3)
zone_name = zone_info.get('name', f'Zone {zone_id}')

# Set HomeKit target_heating_cooling_state to 1 (HEAT) for zone leader
# Auto mode shows as HEAT to HomeKit
leader_device_id = zone_info.get('leader_device_id')
if leader_device_id:
try:
await self.set_device_characteristics(
leader_device_id,
{'target_heating_cooling_state': 1}
)
zones_set += 1
logger.info(f"Zone {zone_id} ({zone_name}): Set to Auto mode on startup")
except Exception as e:
logger.warning(f"Zone {zone_id} ({zone_name}): Failed to set HomeKit state to HEAT: {e}")
else:
logger.debug(f"Zone {zone_id} ({zone_name}): No leader device, tracked_mode set to Auto")
zones_set += 1
except Exception as e:
logger.error(f"Zone {zone_id}: Failed to set to Auto mode: {e}")

if zones_set > 0:
logger.info(f"Set {zones_set} zone(s) to Auto mode on startup")

async def setup_event_listeners(self):
"""Setup unified change detection with events + polling comparison."""
Expand Down Expand Up @@ -455,6 +501,61 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"):
)
if field_name:
logger.debug(f"Updated device {accessory['id']} {field_name}: {old_val} -> {new_val}")

# Handle target_temperature changes - check if scheduled or manual
if field_name == 'target_temperature' and is_zone_leader and device_id:
zone_id = device_info.get('zone_id')
if zone_id:
current_tracked_mode = self.state_manager.get_zone_tracked_mode(zone_id)

# If in AUTO mode, check if the temperature matches scheduled temperature
if current_tracked_mode == 3: # AUTO mode
# Check if this matches the latest scheduled temperature (within 0.1°C tolerance)
is_scheduled_match = False

# First check if it was marked as scheduled change (quick path)
if self.state_manager.is_scheduled_change(zone_id, max_age_seconds=30.0):
is_scheduled_match = True
self.state_manager.clear_scheduled_change(zone_id)
logger.debug(f"Zone {zone_id} ({zone_name}): Temperature change was scheduled, keeping AUTO mode")
else:
# Check if the temperature matches the latest scheduled temperature
# (looks back through today and yesterday to find most recent schedule)
if hasattr(self, 'scheduler_service') and self.scheduler_service:
scheduled_temp = self.scheduler_service.get_latest_schedule_temperature(zone_id)
if scheduled_temp is not None:
# Check if reported temperature matches latest scheduled (within 0.1°C tolerance)
temp_diff = abs(float(value) - float(scheduled_temp))
if temp_diff <= 0.1:
is_scheduled_match = True
logger.debug(f"Zone {zone_id} ({zone_name}): Temperature {value}°C matches latest scheduled {scheduled_temp}°C (diff: {temp_diff:.2f}°C), keeping AUTO mode")

if not is_scheduled_match:
# Temperature doesn't match latest scheduled - this is a manual change
self.state_manager.set_zone_tracked_mode(zone_id, 1)
logger.info(f"Zone {zone_id} ({zone_name}): Manual temperature change detected ({value}°C), switching from AUTO to HEAT mode")

# Sync tracked_mode when TargetHeatingCoolingState changes externally
if field_name == 'target_heating_cooling_state' and is_zone_leader and device_id:
zone_id = device_info.get('zone_id')
if zone_id:
current_tracked_mode = self.state_manager.get_zone_tracked_mode(zone_id)
new_hvac_state = int(value) # value is 0 or 1 from HomeKit

# If currently in Auto (3), only exit Auto mode if device reports OFF (0)
# If device reports HEAT (1), that's expected for Auto mode - don't exit Auto
if current_tracked_mode == 3: # AUTO mode
if new_hvac_state == 0: # Device reports OFF - user manually turned off
self.state_manager.set_zone_tracked_mode(zone_id, 0)
logger.info(f"Zone {zone_id} ({zone_name}): Manual OFF detected, exiting AUTO mode (tracked_mode: 3 -> 0)")
# If new_hvac_state == 1, that's expected for Auto mode - device is just confirming our change
# Don't update tracked_mode, keep it at 3 (Auto)
else:
# Not in Auto mode - update tracked_mode to match external change
new_tracked_mode = new_hvac_state
if current_tracked_mode != new_tracked_mode:
self.state_manager.set_zone_tracked_mode(zone_id, new_tracked_mode)
logger.info(f"Zone {zone_id} ({zone_name}): tracked_mode updated from {current_tracked_mode} to {new_tracked_mode} (external change)")

# Skip logging during initialization
if not self.is_initializing:
Expand Down Expand Up @@ -535,7 +636,13 @@ def _build_device_state(self, device_id: int) -> dict:
battery_state = device_info.get('battery_state')
battery_low = battery_state is not None and battery_state != 'NORMAL'

return {
# Get tracked_mode from zone if device belongs to one
zone_id = device_info.get('zone_id')
tracked_mode = None
if zone_id:
tracked_mode = self.state_manager.get_zone_tracked_mode(zone_id)

result = {
'cur_temp_c': cur_temp_c,
'cur_temp_f': self._celsius_to_fahrenheit(cur_temp_c) if cur_temp_c is not None else None,
'hum_perc': state.get('humidity'),
Expand All @@ -546,6 +653,12 @@ def _build_device_state(self, device_id: int) -> dict:
'valve_position': state.get('valve_position'),
'battery_low': battery_low,
}

# Add tracked_mode if device belongs to a zone
if tracked_mode is not None:
result['tracked_mode'] = tracked_mode

return result

async def broadcast_state_change(self, device_id: int, zone_name: str):
"""
Expand Down Expand Up @@ -587,6 +700,9 @@ async def broadcast_state_change(self, device_id: int, zone_name: str):
if leader_device_id:
leader_state = self._build_device_state(leader_device_id)

# Get tracked_mode for zone
tracked_mode = self.state_manager.get_zone_tracked_mode(zone_id)

# Build zone state using zone logic
zone_state = {
'cur_temp_c': leader_state['cur_temp_c'],
Expand All @@ -595,6 +711,7 @@ async def broadcast_state_change(self, device_id: int, zone_name: str):
'target_temp_c': leader_state['target_temp_c'],
'target_temp_f': leader_state['target_temp_f'],
'mode': 0,
'tracked_mode': tracked_mode,
'cur_heating': 0
}

Expand Down
100 changes: 99 additions & 1 deletion tado_local/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
zone_type TEXT,
leader_device_id INTEGER,
order_id INTEGER,
tracked_mode INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tado_home_id) REFERENCES tado_homes(tado_home_id) ON DELETE CASCADE,
Expand Down Expand Up @@ -168,7 +169,7 @@ def ensure_schema_and_migrate(db_path: str):
# Supported schema version for this codebase. If the database reports a
# higher user_version we should refuse to start to avoid silent data loss
# or incompatible assumptions.
SUPPORTED_SCHEMA_VERSION = 2
SUPPORTED_SCHEMA_VERSION = 4

# Open connection and check current schema version before applying changes
conn = sqlite3.connect(db_path)
Expand Down Expand Up @@ -235,6 +236,103 @@ def _apply_script_tolerant(conn, script: str):
else:
conn.close()

# Re-open connection for next migration check
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA user_version")
row = cursor.fetchone()
current_version = row[0] if row else 0

# Migration to version 3: add tracked_mode column to zones
if current_version < 3:
try:
# Use explicit transaction to ensure atomic migration
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute("ALTER TABLE zones ADD COLUMN tracked_mode INTEGER DEFAULT 1")
except Exception:
# Column may already exist depending on prior runs
pass

# Initialize existing zones with tracked_mode = 1 (HEAT) if NULL
conn.execute("UPDATE zones SET tracked_mode = 1 WHERE tracked_mode IS NULL")

conn.execute("PRAGMA user_version = 3")
current_version = 3
conn.commit()
except Exception:
# Rollback any partial changes on error
try:
conn.rollback()
except Exception:
pass
raise
finally:
conn.close()
else:
conn.close()

# Re-open connection for next migration check
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA user_version")
row = cursor.fetchone()
current_version = row[0] if row else 0

# Migration to version 4: add zone_schedules and app_config tables
if current_version < 4:
try:
# Use explicit transaction to ensure atomic migration
conn.execute("BEGIN IMMEDIATE")
try:
# Create zone_schedules table
conn.execute("""
CREATE TABLE IF NOT EXISTS zone_schedules (
schedule_id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id INTEGER NOT NULL,
schedule_type INTEGER NOT NULL CHECK (schedule_type IN (1, 2, 3)),
day_of_week INTEGER CHECK (day_of_week IS NULL OR (day_of_week >= 0 AND day_of_week <= 6)),
day_type TEXT CHECK (day_type IS NULL OR day_type IN ('weekday', 'weekend')),
time TEXT NOT NULL CHECK (time GLOB '[0-2][0-9]:[0-5][05]'),
temperature REAL NOT NULL,
enabled BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (zone_id) REFERENCES zones(zone_id) ON DELETE CASCADE,
UNIQUE(zone_id, schedule_type, day_of_week, day_type, time)
)
""")

# Create index for efficient schedule lookups
conn.execute("CREATE INDEX IF NOT EXISTS idx_zone_schedules_zone ON zone_schedules(zone_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_zone_schedules_type ON zone_schedules(zone_id, schedule_type)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_zone_schedules_time ON zone_schedules(time)")

# Create app_config table
conn.execute("""
CREATE TABLE IF NOT EXISTS app_config (
config_key TEXT PRIMARY KEY,
config_value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
except Exception:
# Tables may already exist depending on prior runs
pass

conn.execute("PRAGMA user_version = 4")
current_version = 4
conn.commit()
except Exception:
# Rollback any partial changes on error
try:
conn.rollback()
except Exception:
pass
raise
finally:
conn.close()
else:
conn.close()

# Ensure all schema scripts applied now that migrations are done
conn = sqlite3.connect(db_path)
_apply_script_tolerant(conn, DB_SCHEMA)
Expand Down
Loading
Loading