Skip to content
Merged
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
68 changes: 46 additions & 22 deletions tado_local/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
server: Optional[uvicorn.Server] = None
shutdown_event: Optional[asyncio.Event] = None


async def run_server(args):
"""Run the Tado Local server."""
global bridge_pairing, tado_api, server, shutdown_event
Expand Down Expand Up @@ -136,6 +137,7 @@ def handle_signal(signum, frame):
logger.debug("Attempting to import zeroconf_register for mDNS registration")
from .zeroconf_register import register_service_async
logger.info("mDNS registration helper loaded")

async def _schedule_mdns():
# Register a single HTTP service so basic clients can discover the API.
# We intentionally publish only the HTTP service to avoid duplicate
Expand Down Expand Up @@ -179,10 +181,9 @@ def _mdns_done(fut: 'asyncio.Future'):
else:
logger.info("mDNS registration disabled by --no-mdns flag")


server_ip = get_primary_ipv4() or "0.0.0.0"

logger.info( "*** Tado Local ready! ***")
logger.info("*** Tado Local ready! ***")
logger.info(f"Bridge IP: {bridge_ip}")
logger.info(f"API Server: http://{server_ip}:{args.port}")
logger.info(f"Documentation: http://{server_ip}:{args.port}/docs")
Expand Down Expand Up @@ -342,6 +343,7 @@ def _mdns_done(fut: 'asyncio.Future'):
# Forced exit after 3 seconds to avoid lingering connections (especially for browsers)
import threading
import sys

def force_exit():
logger.warning("Forcing process exit after 3 seconds to avoid lingering SSE connections.")
os._exit(0)
Expand All @@ -352,6 +354,7 @@ def force_exit():
threading.Timer(3.0, force_exit).start()
logger.info("Shutdown complete. Process will exit in 3 seconds.")


def main():
"""Main entry point for the CLI."""
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -394,26 +397,46 @@ def main():
POST /refresh - Manually refresh data
"""
)
parser.add_argument("--state", default="~/.tado-local.db",
help="Path to state database (default: ~/.tado-local.db)")
parser.add_argument("--no-mdns", action="store_true",
help="Disable mDNS/Avahi/zeroconf service registration at startup")
parser.add_argument("--bridge-ip",
help="IP of the Tado bridge (e.g., 192.168.1.100). If not provided, will auto-discover from existing pairings.")
parser.add_argument("--pin",
help="HomeKit PIN for initial pairing (XXX-XX-XXX format)")
parser.add_argument("--port", type=int, default=4407,
help="Port for REST API server (default: 4407)")
parser.add_argument("--clear-pairings", action="store_true",
help="Clear all existing pairings from database before starting")
parser.add_argument("--verbose", action="store_true",
help="Enable verbose logging (DEBUG level)")
parser.add_argument("--daemon", action="store_true",
help="Run in daemon mode (structured logging for syslog, auto-enables --pid-file)")
parser.add_argument("--syslog",
help="Send logs to syslog instead of stdout (e.g., /dev/log, localhost:514, or remote.server:514)")
parser.add_argument("--pid-file",
help="Write process ID to specified file (useful for daemon mode)")
parser.add_argument(
"--state", default="~/.tado-local.db",
help="Path to state database (default: ~/.tado-local.db)"
)
parser.add_argument(
"--no-mdns", action="store_true",
help="Disable mDNS/Avahi/zeroconf service registration at startup"
)
parser.add_argument(
"--bridge-ip",
help="IP of the Tado bridge (e.g., 192.168.1.100). If not provided, will auto-discover from existing pairings."
)
parser.add_argument(
"--pin",
help="HomeKit PIN for initial pairing (XXX-XX-XXX format)"
)
parser.add_argument(
"--port", type=int, default=4407,
help="Port for REST API server (default: 4407)"
)
parser.add_argument(
"--clear-pairings", action="store_true",
help="Clear all existing pairings from database before starting"
)
parser.add_argument(
"--verbose", action="store_true",
help="Enable verbose logging (DEBUG level)"
)
parser.add_argument(
"--daemon", action="store_true",
help="Run in daemon mode (structured logging for syslog, auto-enables --pid-file)"
)
parser.add_argument(
"--syslog",
help="Send logs to syslog instead of stdout (e.g., /dev/log, localhost:514, or remote.server:514)"
)
parser.add_argument(
"--pid-file",
help="Write process ID to specified file (useful for daemon mode)"
)
# Parse CLI arguments
args = parser.parse_args()

Expand Down Expand Up @@ -499,5 +522,6 @@ def main():
logger.error(f"ERROR: {e}")
exit(1)


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion tado_local/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@
# - 1.1.1: Bug fix update
# - 2.0.0: Major breaking change

__version__ = "1.0.0-alpha1"
__version__ = "1.1.0"
169 changes: 154 additions & 15 deletions tado_local/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@

class TadoLocalAPI:
"""Tado Local that leverages HomeKit for real-time data without cloud dependency."""
accessories_cache : List[Any]
accessories_dict : Dict[str, Any]
accessories_id : Dict[int, str]
characteristic_map : Dict[tuple[int, int], str]
characteristic_iid_map : Dict[tuple[int, str], int]
device_to_characteristics : Dict[int, List[tuple[int, int, str]]] # device_id -> [(aid, iid, char_type)]
accessories_cache: List[Any]
accessories_dict: Dict[str, Any]
accessories_id: Dict[int, str]
characteristic_map: Dict[tuple[int, int], str]
characteristic_iid_map: Dict[tuple[int, str], int]
device_to_characteristics: Dict[int, List[tuple[int, int, str]]] # device_id -> [(aid, iid, char_type)]

def __init__(self, db_path: str):
self.pairing: Optional[IpPairing] = None
Expand All @@ -61,6 +61,7 @@ def __init__(self, db_path: str):
# Cleanup tracking
self.subscribed_characteristics: List[tuple[int, int]] = []
self.background_tasks: List[asyncio.Task] = []
self.window_close_timers: Dict[int, asyncio.Task] = {}
self.is_shutting_down = False

async def initialize(self, pairing: IpPairing):
Expand All @@ -78,6 +79,15 @@ async def cleanup(self):
logger.info("Starting cleanup...")
self.is_shutting_down = True

# Cancel window recheck tasks
if self.window_close_timers:
logger.info(f"Cancelling {len(self.window_close_timers)} window closing timers")
for task in self.window_close_timers.values():
if not task.done():
task.cancel()
await asyncio.gather(*self.window_close_timers.values(), return_exceptions=True)
self.window_close_timers.clear()

# Cancel all background tasks
if self.background_tasks:
logger.info(f"Cancelling {len(self.background_tasks)} background tasks")
Expand Down Expand Up @@ -141,7 +151,7 @@ async def refresh_accessories(self):
raise HTTPException(status_code=503, detail=f"Failed to refresh accessories: {e}")

def _process_raw_accessories(self, raw_accessories):
accessories={}
accessories = {}

for a in raw_accessories:
aid = a.get('aid')
Expand Down Expand Up @@ -266,7 +276,6 @@ async def initialize_device_states(self):

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


async def setup_event_listeners(self):
"""Setup unified change detection with events + polling comparison."""
if not self.pairing:
Expand Down Expand Up @@ -322,7 +331,7 @@ async def setup_persistent_events(self):
logger.info("Setting up persistent event system...")

# Register unified change handler for events
def event_callback(update_data : dict[tuple[int, int], dict]):
def event_callback(update_data: dict[tuple[int, int], dict]):
"""Handle ALL HomeKit characteristic updates."""
logger.debug(f"Event callback received update: {update_data}")
for k, v in update_data.items():
Expand Down Expand Up @@ -413,7 +422,6 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"):
if not char_name:
char_name = f"{aid}.{iid}"


# Check if this is actually a change
last_value = self.change_tracker['last_values'].get(char_key)
if last_value == value:
Expand Down Expand Up @@ -467,6 +475,8 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"):

# Format log message: show zone name, only add device detail if not zone leader
if is_zone_leader:
# Check for window open/close based on leader updates
self._handle_window_open_detection(device_id, device_info, char_type)
# Zone leader - just show zone name
logger.info(f"[{src}] {zone_name} | {char_name}: {last_value} -> {value}")
else:
Expand All @@ -477,13 +487,138 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"):
# Don't send raw characteristic events anymore - we'll send aggregated state changes

# Broadcast aggregated state change for relevant characteristics
if char_name in ['TargetTemperature', 'CurrentTemperature', 'TargetHeatingCoolingState',
'CurrentHeatingCoolingState', 'CurrentRelativeHumidity', 'ValvePosition']:
if char_name in [
'TargetTemperature', 'CurrentTemperature', 'TargetHeatingCoolingState',
'CurrentHeatingCoolingState', 'CurrentRelativeHumidity', 'ValvePosition'
]:
await self.broadcast_state_change(device_id, zone_name)

except Exception as e:
logger.error(f"Error handling unified change: {e}")

def _handle_window_open_detection(self, device_id, device_info, char_type):
"""Detect window open/close based on leader device temperature update."""
try:
# Only check for window open/close if the characteristic is relevant (temperature changes in leader)
if char_type.lower() in [DeviceStateManager.CHAR_CURRENT_TEMPERATURE]:
# Get current state for leader device
leader_state = self.state_manager.get_current_state(device_id)
if not leader_state:
# No state info available
return

zone_name = device_info.get('zone_name', 'No Zone')

# Simple heuristic: if temperature drops significantly within time threshold, a window open is asumed
# temp_change_threshold = 2.0 # degrees Celsius
# temp_drop_time_threshold = 10 # minutes
temp_change_threshold = 1.0 # degrees Celsius
temp_change_time_threshold = 20 # minutes

# Get old values from database of the last temp_drop_time_threshold minutes to compare trends
history = self.state_manager.get_device_history_info(device_id, age=temp_change_time_threshold)

# history structure: history_count, earliest_entry (temp, window, window_lastupdate), latest_entry (temp, window, window_lastupdate)
if not history or history['history_count'] < 1:
# No data, just ignore
return

# calculate time difference from latest entry to see how long the window has been open/closed/rest
time_diff = (time.time() - int(history['latest_entry'][2])) // 60
current_window_state = leader_state.get('window')

# If window is currently open (1) and has been open for longer than the open time threshold, set it to rest (2)
if current_window_state == 1 and time_diff > device_info.get('window_open_time', 15):
logger.info(f"[Window] {zone_name} | Window set to close again, being open over {time_diff:.0f} mins")
# Consider it closed again -> put in rest (2) state to avoid rapid open/close detection
self.state_manager.update_device_window_status(device_id, 2)
self._cancel_window_close_timer(device_id)
return

if history['history_count'] < 2:
# Temperature drop is to slow to call it an open window
logger.info(f"[Window] {zone_name} | Not enough readings (only {history['history_count']} " +
f"entry in last {temp_change_time_threshold} minutes)")
return

mode = leader_state.get('cur_heating', 0) # (0=Off, 1=Heating, 2=Cooling)
if mode == 2:
# In cooling mode, calculate temp _rise_ (lastest - earlier) as temp_change
temp_change = history['latest_entry'][0] - history['earliest_entry'][0]
logger.info(f"[Window] {zone_name} | cooling | Window status {current_window_state} " +
f"for {time_diff:.0f} mins | Temp rise {temp_change:.1f}")
else:
# In heating mode, calculate temp _drop_ (earlier - lastest) as temp_change
temp_change = history['earliest_entry'][0] - history['latest_entry'][0]
logger.info(f"[Window] {zone_name} | heating | Window status {current_window_state} " +
f"for {time_diff:.0f} mins | Temp drop {temp_change:.1f}")

# check if window is currently closed (0) or in rest (2) long enough to consider it closed again
if current_window_state == 0 or (current_window_state == 2 and time_diff > device_info.get('window_rest_time', 15)):
# A significant temp _rise_ is likely to be caused by an open window
window_status = 1 if temp_change >= temp_change_threshold else 0

# Update state manager with window status
self.state_manager.update_device_window_status(device_id, window_status)

# If window is set open (1), schedule a timer to close it again after 'window_open_time' minutes
if window_status == 1:
window_close_delay = device_info.get('window_open_time', 30)
self._schedule_window_close_timer(device_id, window_close_delay, device_info)

except Exception as e:
logger.error(f"Error in window open detection: {e}")
import traceback
print(traceback.format_exc())

def _schedule_window_close_timer(self, device_id: int, window_close_delay: int, device_info: Dict[str, Any]):
"""" Schedule a timer to set the window status back to closed after a delay."""
if self.is_shutting_down:
return

existing_task = self.window_close_timers.get(device_id)
if existing_task and not existing_task.done():
existing_task.cancel()

task = asyncio.create_task(self._window_close_handler(device_id, device_info, window_close_delay))
self.window_close_timers[device_id] = task
task.add_done_callback(lambda t: self._window_close_timer_stop(device_id, t))

def _cancel_window_close_timer(self, device_id: int):
""" Cancel any existing window close timer for the device."""
task = self.window_close_timers.pop(device_id, None)
if task and not task.done():
task.cancel()

def _window_close_timer_stop(self, device_id: int, task: asyncio.Task):
""" Callback to clean up after window close timer finishes or is cancelled."""
if self.window_close_timers.get(device_id) is task:
del self.window_close_timers[device_id]

async def _window_close_handler(self, device_id: int, device_info: Dict[str, Any], closing_delay: int):
""" Wait for the specified delay and then set the window status back to closed if it's still open."""
interval = closing_delay * 60

try:
await asyncio.sleep(interval)
if self.is_shutting_down:
return

current_state = self.state_manager.get_current_state(device_id)
if not current_state or current_state.get('window') != 1:
return

zone_name = device_info.get('zone_name', 'No Zone')
logger.info(f"[Window] {zone_name} | Window set to close again")
# Consider it closed again -> put in rest state (2) to avoid rapid open/close detection
self.state_manager.update_device_window_status(device_id, 2)

except asyncio.CancelledError:
return
except Exception as e:
logger.error(f"Window closing error for device {device_id}: {e}")
return

async def broadcast_event(self, event_data):
"""Broadcast change event to all connected SSE clients."""
try:
Expand Down Expand Up @@ -545,6 +680,7 @@ def _build_device_state(self, device_id: int) -> dict:
'cur_heating': 1 if state.get('current_heating_cooling_state') == 1 else 0,
'valve_position': state.get('valve_position'),
'battery_low': battery_low,
'window': state.get('window'),
}

async def broadcast_state_change(self, device_id: int, zone_name: str):
Expand Down Expand Up @@ -595,14 +731,17 @@ 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,
'cur_heating': 0
'cur_heating': 0,
'window_open': leader_state['window'] == 1,
}

# Apply circuit driver logic for heating states (using cache)
if is_circuit_driver:
# Circuit driver - check radiator valves in zone (from cache)
other_devices = [dev_id for dev_id, dev_info in self.state_manager.device_info_cache.items()
if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver')]
other_devices = [
dev_id for dev_id, dev_info in self.state_manager.device_info_cache.items()
if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver')
]

if other_devices:
for valve_id in other_devices:
Expand Down
Loading
Loading