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
14 changes: 7 additions & 7 deletions demos/zone-manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,20 @@
def resolve_zone_names(zone_specs):
"""
Resolve zone specifications (IDs or names) to zone IDs.

Check failure on line 52 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (W293)

demos/zone-manager.py:52:1: W293 Blank line contains whitespace
Args:
zone_specs: List of zone IDs (int) or zone names (str)

Check failure on line 55 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (W293)

demos/zone-manager.py:55:1: W293 Blank line contains whitespace
Returns:
List of zone IDs (int)
"""
global zone_info

if not zone_info:
zone_info, _ = get_zones_and_homes()

resolved = []

for spec in zone_specs:
if isinstance(spec, int):
# Already an ID
Expand All @@ -71,20 +71,20 @@
# Try to find by name (case-insensitive partial match)
spec_lower = spec.lower()
matched = False

for zone in zone_info:
zone_name_lower = zone['name'].lower()
if spec_lower in zone_name_lower or zone_name_lower in spec_lower:
resolved.append(zone['zone_id'])
matched = True
if verbose > 0:
print(f"Matched zone name '{spec}' to '{zone['name']}' (ID: {zone['zone_id']})")

Check failure on line 81 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:81:89: E501 Line too long (104 > 88)
break

if not matched:
print(f"Error: No zone found matching '{spec}'")
sys.exit(1)

return resolved


Expand Down Expand Up @@ -121,17 +121,17 @@
help='suppress all output (for cron jobs)')
parser.add_argument('-z', '--zone', action='append', dest='zones',
metavar='ZONE_ID_OR_NAME',
help='select zone(s) by ID (integer) or name (string, case-insensitive partial match)')

Check failure on line 124 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:124:89: E501 Line too long (111 > 88)

action_group = parser.add_mutually_exclusive_group()
action_group.add_argument('-t', '--temperature', type=float, metavar='CELSIUS',
help='set temperature in zone(s) (>= 5 to enable heating)')

Check failure on line 128 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:128:89: E501 Line too long (89 > 88)
action_group.add_argument('--limit-temp', '--limit', type=float, metavar='CELSIUS', dest='limit_temp',

Check failure on line 129 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:129:89: E501 Line too long (106 > 88)
help='limit temperature: only lower if zone is ON and above this value')

Check failure on line 130 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:130:89: E501 Line too long (102 > 88)
action_group.add_argument('-d', '--disable', action='store_true',
help='disable heating (turn off)')
action_group.add_argument('-r', '--reset', action='store_true',
help='reset to schedule (re-enable heating at current target temp)')

Check failure on line 134 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:134:89: E501 Line too long (98 > 88)

return parser

Expand Down Expand Up @@ -200,9 +200,9 @@

# Format output
if cur_temp is not None and cur_hum is not None:
print(f'{zone_id:<2} {zone_data["name"]:<14} {setting:>7} {status_str:<8} {cur_temp:5.1f}°C {cur_hum:5.1f}%')

Check failure on line 203 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:203:89: E501 Line too long (126 > 88)
else:
print(f'{zone_id:<2} {zone_data["name"]:<14} {"":>7} {"-":<8} {"":>5} {"":>5}')

Check failure on line 205 in demos/zone-manager.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Ruff (E501)

demos/zone-manager.py:205:89: E501 Line too long (99 > 88)


def set_temperature(zones, temp):
Expand Down Expand Up @@ -389,7 +389,7 @@
parsed_specs.append(int(spec))
except ValueError:
parsed_specs.append(spec)

zones = resolve_zone_names(parsed_specs)

# Determine if we should list zones
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-cov>=4.0.0
pytest-httpx>=0.36

# Linting and code quality
ruff>=0.1.0
Expand Down
23 changes: 13 additions & 10 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 .zeroconf_register import get_primary_ipv4

# Logger will be configured in main() based on daemon/console mode
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -135,7 +136,6 @@ 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 All @@ -145,14 +145,14 @@ async def _schedule_mdns():
from .__version__ import __version__ as tado_version
# Do not advertise the bridge IP here; advertise the daemon host
# so clients connect to this service instance to manage Tado.
ok, method, msg = await register_service_async(name='tado-local', port=args.port, props={
ok, method, msg, server_ip = await register_service_async(name='tado-local', port=args.port, props={
'path': '/',
'version': tado_version,
'app': 'tado-local',
'id': 'tado-local'
}, service_type='_http._tcp.local.')
if ok:
logger.info(f"HTTP mDNS service registered via {method} (advertising daemon host A/AAAA records)")
logger.info(f"HTTP mDNS service registered via {method} (advertising daemon host A/AAAA records on {server_ip})")
else:
logger.warning(f"HTTP mDNS registration: {msg} (advertising daemon host)")
except Exception as e:
Expand All @@ -179,13 +179,16 @@ 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(f"Bridge IP: {bridge_ip}")
logger.info(f"API Server: http://0.0.0.0:{args.port}")
logger.info(f"Documentation: http://0.0.0.0:{args.port}/docs")
logger.info(f"Status: http://0.0.0.0:{args.port}/status")
logger.info(f"Thermostats: http://0.0.0.0:{args.port}/thermostats")
logger.info(f"Live Events: http://0.0.0.0:{args.port}/events")
logger.info(f"API Server: http://{server_ip}:{args.port}")
logger.info(f"Documentation: http://{server_ip}:{args.port}/docs")
logger.info(f"Status: http://{server_ip}:{args.port}/status")
logger.info(f"Thermostats: http://{server_ip}:{args.port}/thermostats")
logger.info(f"Live Events: http://{server_ip}:{args.port}/events")

# Configure uvicorn logging to match our format and prevent duplicates
if args.syslog:
Expand Down Expand Up @@ -265,7 +268,7 @@ def _mdns_done(fut: 'asyncio.Future'):
"uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False},
},
}

# Start the FastAPI server
config = uvicorn.Config(
app,
Expand Down Expand Up @@ -407,7 +410,7 @@ def main():
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",
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)")
Expand Down
2 changes: 1 addition & 1 deletion tado_local/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ def get_iid_from_characteristics(self, aid: int, char_name: str) -> Optional[int
"""Helper to find IID from characteristic type in an accessory."""
char_key = (aid, char_name)
return self.characteristic_iid_map.get(char_key)

async def handle_change(self, aid, iid, update_data, source="UNKNOWN"):
"""Unified handler for all characteristic changes (events AND polling)."""
try:
Expand Down
2 changes: 1 addition & 1 deletion tado_local/homekit_uuids.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,4 @@ def add_tado_specific_info(enhanced_char, char_name, value):
# Note: Apple standard characteristics (SerialNumber, Name, FirmwareRevision, etc.)
# are NOT modified here - they retain their official Apple HomeKit meanings

return enhanced_char
return enhanced_char
2 changes: 1 addition & 1 deletion tado_local/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)):
leader_type = zone_info['leader_type']
is_circuit_driver = zone_info['is_circuit_driver']
tado_zone_id = zone_info['tado_zone_id']

# Get device count for this zone (quick loop through device cache)
device_count = sum(1 for dev_info in tado_api.state_manager.device_info_cache.values()
if dev_info.get('zone_id') == zone_id)
Expand Down
34 changes: 16 additions & 18 deletions tado_local/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def __init__(self, db_path: str):
self.current_state: Dict[int, Dict[str, Any]] = {} # device_id -> current state
self.last_saved_bucket: Dict[int, str] = {} # device_id -> last saved bucket
self.bucket_state_snapshot: Dict[int, Dict[str, Any]] = {} # device_id -> state when bucket was saved

# Optimistic update tracking (for UI responsiveness)
self.optimistic_state: Dict[int, Dict[str, Any]] = {} # device_id -> predicted state changes
self.optimistic_timestamps: Dict[int, float] = {} # device_id -> timestamp when prediction was made
Expand Down Expand Up @@ -117,7 +117,7 @@ def _load_zone_cache(self):
cursor = conn.execute("""
SELECT z.zone_id, z.name, z.leader_device_id, z.order_id,
d.serial_number as leader_serial, d.device_type as leader_type,
z.tado_zone_id,
z.tado_zone_id,
d.is_circuit_driver, z.uuid
FROM zones z
LEFT JOIN devices d ON z.leader_device_id = d.device_id
Expand Down Expand Up @@ -514,10 +514,10 @@ def get_current_state(self, device_id: int = None) -> Dict:
def set_optimistic_state(self, device_id: int, state_changes: Dict[str, Any]):
"""
Set optimistic state prediction for a device.

This allows immediate UI feedback before HomeKit confirms the change.
Predictions automatically expire after self.optimistic_timeout seconds.

Args:
device_id: Device to update
state_changes: Dict of state keys to predicted values
Expand All @@ -533,38 +533,38 @@ def clear_optimistic_state(self, device_id: int):
# This would indicate the device rejected or modified our change
predicted = self.optimistic_state[device_id]
actual = self.current_state.get(device_id, {})

mismatches = []
for key, predicted_value in predicted.items():
actual_value = actual.get(key)
if actual_value is not None and actual_value != predicted_value:
mismatches.append(f"{key}: predicted={predicted_value}, actual={actual_value}")

if mismatches:
logger.info(f"Device {device_id}: Optimistic state was overridden by device - {', '.join(mismatches)}")

del self.optimistic_state[device_id]
del self.optimistic_timestamps[device_id]
logger.debug(f"Cleared optimistic state for device {device_id}")

def get_state_with_optimistic(self, device_id: int) -> Dict:
"""
Get device state with optimistic predictions overlaid.

If optimistic predictions exist and haven't expired, they override
the real state values. Expired predictions are automatically cleared.

Returns:
Dict with current state + active optimistic overrides
"""
# Start with real state
state = self.current_state.get(device_id, {}).copy()

# Check for optimistic overrides
if device_id in self.optimistic_state:
prediction_time = self.optimistic_timestamps[device_id]
age = time.time() - prediction_time

if age > self.optimistic_timeout:
# Prediction expired - clear it
logger.debug(f"Optimistic state for device {device_id} expired after {age:.1f}s")
Expand All @@ -574,27 +574,27 @@ def get_state_with_optimistic(self, device_id: int) -> Dict:
optimistic = self.optimistic_state[device_id]
state.update(optimistic)
logger.debug(f"Applied optimistic state to device {device_id} (age: {age:.1f}s)")

return state

def get_all_devices(self) -> List[Dict]:
"""Get all registered devices with full details including zone information."""
conn = sqlite3.connect(self.db_path)

cursor = conn.execute("""
SELECT d.device_id, d.serial_number, d.aid, d.device_type, d.name,
d.model, d.manufacturer, d.firmware_version, d.zone_id,
z.name as zone_name, d.is_zone_leader, d.is_circuit_driver,
d.first_seen, d.last_seen
d.battery_state, d.first_seen, d.last_seen
FROM devices d
LEFT JOIN zones z ON d.zone_id = z.zone_id
ORDER BY device_id
""")

# Extract column names from cursor metadata
column_names = [desc[0] for desc in cursor.description]

# Convert each row to a dictionary with column names as keys
# Convert each row to a dictionary with column names as keys
# (convert 'is_zone_leader' and 'is_circuit_driver' to bool type)
devices = []
for row in cursor.fetchall():
Expand All @@ -606,5 +606,3 @@ def get_all_devices(self) -> List[Dict]:
conn.close()

return devices


8 changes: 4 additions & 4 deletions tado_local/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,23 +262,23 @@ def sync_zone_states_data(self, zone_states_data: List[Dict[str, Any]], home_id:
Returns:
True if successful
"""

try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()

humidity_updates = 0

zones = zone_states_data.get('zoneStates', {})
for zone_id, zone_state in zones.items():
settings = zone_state.get('setting', {})

if not settings or settings.get('type') == 'HOT_WATER':
continue # Do not process HOT_WATER zones for now

sensoDataPoints = zone_state.get('sensorDataPoints', {})
humidity = str(sensoDataPoints.get('humidity', {}).get('percentage'))

if humidity != "None":
logger.debug(f"Get all aids for zone: {zone_id}")
# Get all devices of this zone to generate update humidity
Expand Down
10 changes: 6 additions & 4 deletions tado_local/zeroconf_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def _pack_ipv4(addr: str):
return None


def _get_primary_ipv4():
def get_primary_ipv4():
"""Return a best-effort primary IPv4 address for this host as a string, or None.

We use a UDP socket connect trick which doesn't send packets but reveals the
Expand Down Expand Up @@ -71,8 +71,10 @@ async def register_service_async(name: str = 'tado-local', port: int = 4407, pro
# Determine address bytes to advertise. If the caller provided an
# explicit advertise_addr use that; otherwise pick a sensible local IPv4.
addresses = None
ip4 = None
try:
addr_to_use = advertise_addr or _get_primary_ipv4()
addr_to_use = advertise_addr or get_primary_ipv4()
ip4 = addr_to_use
if addr_to_use:
packed = _pack_ipv4(addr_to_use)
if packed:
Expand Down Expand Up @@ -123,10 +125,10 @@ async def register_service_async(name: str = 'tado-local', port: int = 4407, pro
"AsyncZeroconf registered service %s (published as: %s) on port %s (addresses=%s srv=%s props=%s)",
name, actual_name or name, port, addr_str, srv_target, decoded_props,
)
return True, 'zeroconf_async', None
return True, 'zeroconf_async', None, ip4
except Exception as e:
logger.exception("AsyncZeroconf registration failed for %s", name)
return False, None, str(e)
return False, None, str(e), "0.0.0.0"


async def unregister_service_async():
Expand Down
Loading