${tempHtml}°
${zone.name}
@@ -1386,6 +1502,50 @@
Zone
};
}
+ // Theme toggle handlers
+ function getEffectiveTheme() {
+ const saved = localStorage.getItem('theme');
+ if (saved) return saved;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+
+ function applyTheme(theme) {
+ if (theme === 'dark') {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ document.getElementById('theme-toggle').textContent = '\u2600\uFE0F';
+ } else {
+ document.documentElement.setAttribute('data-theme', 'light');
+ document.getElementById('theme-toggle').textContent = '\uD83C\uDF19';
+ }
+ // Rebuild chart if visible so colors update
+ if (historyChart && currentZone) {
+ const hours = parseInt(document.getElementById('history-period').value);
+ loadHistory(currentZone.zone_id, hours);
+ }
+ }
+
+ // Handle Window status toggle
+ function applyWindowStatus() {
+ document.getElementById('window-toggle-icon').setAttribute("href", showWindowStatus == "show" ? '#window-closed' :
+ (showWindowStatus == "hide" ? '#window-disabled' :'#window-open'));
+
+ document.querySelectorAll('.window-icon').forEach(icon => {
+ // Show or hide window status icons based on setting, and if "open-only", only show when window is open
+ if (showWindowStatus == "show") {
+ icon.style.display = 'block';
+ } else if (showWindowStatus == "open-only") {
+ stat = icon.getElementsByTagName('use')[0].getAttribute("href");
+ if (stat === '#window-open') {
+ icon.style.display = 'block';
+ } else {
+ icon.style.display = 'none';
+ }
+ } else {
+ icon.style.display = 'none';
+ }
+ });
+ }
+
// Event listeners
document.getElementById('temp-up').addEventListener('click', () => {
if (tempValue < 30) {
@@ -1461,28 +1621,21 @@
Zone
}
});
- // Theme toggle
- function getEffectiveTheme() {
- const saved = localStorage.getItem('theme');
- if (saved) return saved;
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
- }
+ // Add Window status toggle listener
+ document.getElementById('window-toggle').addEventListener('click', () => {
+ showWindowStatus = showWindowStatus == "hide" ? "show" : showWindowStatus == "show" ? "open-only" : "hide";
+ localStorage.setItem('windowStatus', showWindowStatus);
+ applyWindowStatus();
+ });
- function applyTheme(theme) {
- if (theme === 'dark') {
- document.documentElement.setAttribute('data-theme', 'dark');
- document.getElementById('theme-toggle').textContent = '\u2600\uFE0F';
- } else {
- document.documentElement.setAttribute('data-theme', 'light');
- document.getElementById('theme-toggle').textContent = '\uD83C\uDF19';
- }
- // Rebuild chart if visible so colors update
- if (historyChart && currentZone) {
- const hours = parseInt(document.getElementById('history-period').value);
- loadHistory(currentZone.zone_id, hours);
- }
+ // Init showWindowStatus from localStorage
+ showWindowStatus = localStorage.getItem('windowStatus'); // 'show', 'hide' or 'open-only'
+ if (!showWindowStatus) {
+ showWindowStatus = 'open-only'; // default to "open-only" if not set
}
+ applyWindowStatus();
+ // Add theme toggle listener
document.getElementById('theme-toggle').addEventListener('click', () => {
const current = getEffectiveTheme();
const next = current === 'dark' ? 'light' : 'dark';
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..4cc55c6
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,163 @@
+import pytest
+import sqlite3
+import tempfile
+import os
+from tado_local.cache import CharacteristicCacheSQLite
+
+@pytest.fixture
+def temp_db():
+ """Create a temporary database file for testing."""
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ yield path
+ if os.path.exists(path):
+ os.remove(path)
+
+@pytest.fixture
+def cache(temp_db):
+ """Create a CharacteristicCacheSQLite instance for testing."""
+ return CharacteristicCacheSQLite(temp_db)
+
+
+class TestCharacteristicCacheSQLite:
+ def test_init_creates_database(self, temp_db):
+ """Test that initialization creates the database."""
+ cache = CharacteristicCacheSQLite(temp_db)
+ assert os.path.exists(temp_db)
+ assert cache.db_path == temp_db
+
+ def test_storage_data_accessible(self, cache):
+ """Test that storage_data dict is accessible."""
+ assert hasattr(cache, 'storage_data')
+ assert isinstance(cache.storage_data, dict)
+
+ def test_async_create_or_update_map(self, cache, temp_db):
+ """Test creating or updating a pairing cache."""
+ homekit_id = "test_home_1"
+ config_num = 42
+ accessories = [{"aid": 1, "services": []}]
+ broadcast_key = "key123"
+ state_num = 1
+
+ result = cache.async_create_or_update_map(
+ homekit_id, config_num, accessories, broadcast_key, state_num
+ )
+
+ assert result is not None
+ assert homekit_id in cache.storage_data
+
+ def test_async_create_or_update_map_persists_to_db(self, cache, temp_db):
+ """Test that data is persisted to database."""
+ homekit_id = "test_home_2"
+ config_num = 50
+ accessories = [{"aid": 1, "services": []}]
+ broadcast_key = "key456"
+
+ cache.async_create_or_update_map(homekit_id, config_num, accessories, broadcast_key)
+
+ conn = sqlite3.connect(temp_db)
+ cursor = conn.execute(
+ "SELECT homekit_id, config_num FROM homekit_cache WHERE homekit_id = ?",
+ (homekit_id,)
+ )
+ row = cursor.fetchone()
+ conn.close()
+
+ assert row is not None
+ assert row[0] == homekit_id
+ assert row[1] == config_num
+
+ def test_async_delete_map(self, cache, temp_db):
+ """Test deleting a pairing cache."""
+ homekit_id = "test_home_3"
+ accessories = [{"aid": 1}]
+
+ cache.async_create_or_update_map(homekit_id, 1, accessories)
+ assert homekit_id in cache.storage_data
+
+ cache.async_delete_map(homekit_id)
+ assert homekit_id not in cache.storage_data
+
+ def test_async_delete_map_removes_from_db(self, cache, temp_db):
+ """Test that deletion removes data from database."""
+ homekit_id = "test_home_4"
+ cache.async_create_or_update_map(homekit_id, 1, [{"aid": 1}])
+
+ cache.async_delete_map(homekit_id)
+
+ conn = sqlite3.connect(temp_db)
+ cursor = conn.execute(
+ "SELECT * FROM homekit_cache WHERE homekit_id = ?",
+ (homekit_id,)
+ )
+ assert cursor.fetchone() is None
+ conn.close()
+
+ def test_load_from_db_on_init(self, temp_db):
+ """Test that data is loaded from database on initialization."""
+ cache1 = CharacteristicCacheSQLite(temp_db)
+ homekit_id = "test_home_5"
+ accessories = [{"aid": 1, "services": []}]
+ cache1.async_create_or_update_map(homekit_id, 10, accessories)
+
+ cache2 = CharacteristicCacheSQLite(temp_db)
+ assert homekit_id in cache2.storage_data
+
+ def test_save_to_db_with_none_values(self, cache, temp_db):
+ """Test saving to database with optional None values."""
+ homekit_id = "test_home_6"
+ cache.async_create_or_update_map(homekit_id, 5, [{"aid": 1}], None, None)
+
+ conn = sqlite3.connect(temp_db)
+ cursor = conn.execute(
+ "SELECT broadcast_key, state_num FROM homekit_cache WHERE homekit_id = ?",
+ (homekit_id,)
+ )
+ row = cursor.fetchone()
+ conn.close()
+
+ assert row[0] is None
+ assert row[1] is None
+
+ def test_accessories_json_serialization(self, cache, temp_db):
+ """Test that accessories are properly serialized/deserialized."""
+ homekit_id = "test_home_7"
+ accessories = [
+ {"aid": 1, "services": [{"iid": 1, "type": "service_type"}]},
+ {"aid": 2, "services": []}
+ ]
+ cache.async_create_or_update_map(homekit_id, 1, accessories)
+
+ loaded_data = cache.storage_data[homekit_id]
+ assert loaded_data['accessories'] == accessories
+
+ def test_update_existing_cache_entry(self, cache, temp_db):
+ """Test updating an existing cache entry."""
+ homekit_id = "test_home_8"
+ cache.async_create_or_update_map(homekit_id, 1, [{"aid": 1}])
+ cache.async_create_or_update_map(homekit_id, 2, [{"aid": 2}])
+
+ conn = sqlite3.connect(temp_db)
+ cursor = conn.execute(
+ "SELECT COUNT(*) FROM homekit_cache WHERE homekit_id = ?",
+ (homekit_id,)
+ )
+ count = cursor.fetchone()[0]
+ conn.close()
+
+ assert count == 1
+
+ def test_load_from_db_with_corrupt_json(self, temp_db):
+ """Test handling of corrupted JSON in database."""
+ CharacteristicCacheSQLite(temp_db)
+ conn = sqlite3.connect(temp_db)
+ conn.execute("""
+ INSERT INTO homekit_cache
+ (homekit_id, config_num, accessories, broadcast_key, state_num)
+ VALUES (?, ?, ?, ?, ?)
+ """, ("bad_json", 1, "INVALID_JSON", "key", 1))
+ conn.commit()
+ conn.close()
+
+ cache2 = CharacteristicCacheSQLite(temp_db)
+ assert "bad_json" not in cache2.storage_data
diff --git a/tests/test_homekit_uuids.py b/tests/test_homekit_uuids.py
new file mode 100644
index 0000000..b4860c3
--- /dev/null
+++ b/tests/test_homekit_uuids.py
@@ -0,0 +1,325 @@
+from tado_local.homekit_uuids import (
+ get_service_name,
+ get_characteristic_name,
+ get_characteristic_value_name,
+ enhance_accessory_data,
+ add_tado_specific_info,
+)
+
+
+class TestHomekitUUIDsGetServiceName:
+ def test_get_service_name_apple_standard(self):
+ """Test retrieving standard Apple HomeKit service names."""
+ uuid = "0000003E-0000-1000-8000-0026BB765291"
+ assert get_service_name(uuid) == "AccessoryInformation"
+
+ uuid = "0000004A-0000-1000-8000-0026BB765291"
+ assert get_service_name(uuid) == "Thermostat"
+
+ def test_get_service_name_case_insensitive(self):
+ """Test that service name lookup is case-insensitive."""
+ uuid_lower = "0000003e-0000-1000-8000-0026bb765291"
+ uuid_upper = "0000003E-0000-1000-8000-0026BB765291"
+ assert get_service_name(uuid_lower) == get_service_name(uuid_upper)
+
+ def test_get_service_name_tado_custom(self):
+ """Test retrieving Tado custom service names."""
+ uuid = "E44673A0-247B-4360-8A76-DB9DA69C0100"
+ assert get_service_name(uuid) == "TadoProprietaryService"
+
+ def test_get_service_name_unknown_returns_uuid(self):
+ """Test that unknown UUIDs are returned as-is."""
+ unknown_uuid = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"
+ assert get_service_name(unknown_uuid) == unknown_uuid.upper()
+
+
+class TestHomekitUUIDsGetCharacteristicName:
+ def test_get_characteristic_name_apple_standard(self):
+ """Test retrieving standard Apple HomeKit characteristic names."""
+ uuid = "00000011-0000-1000-8000-0026BB765291"
+ assert get_characteristic_name(uuid) == "CurrentTemperature"
+
+ uuid = "00000035-0000-1000-8000-0026BB765291"
+ assert get_characteristic_name(uuid) == "TargetTemperature"
+
+ def test_get_characteristic_name_case_insensitive(self):
+ """Test that characteristic name lookup is case-insensitive."""
+ uuid_lower = "00000011-0000-1000-8000-0026bb765291"
+ uuid_upper = "00000011-0000-1000-8000-0026BB765291"
+ assert get_characteristic_name(uuid_lower) == get_characteristic_name(uuid_upper)
+
+ def test_get_characteristic_name_tado_custom(self):
+ """Test retrieving Tado custom characteristic names."""
+ uuid = "E44673A0-247B-4360-8A76-DB9DA69C0101"
+ assert get_characteristic_name(uuid) == "TadoProprietaryControl"
+
+ def test_get_characteristic_name_unknown_returns_uuid(self):
+ """Test that unknown UUIDs are returned as-is."""
+ unknown_uuid = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"
+ assert get_characteristic_name(unknown_uuid) == unknown_uuid.upper()
+
+
+class TestHomekitUUIDsGetCharacteristicValueName:
+ def test_get_characteristic_value_name_heating_cooling(self):
+ """Test value name retrieval for heating/cooling states."""
+ assert get_characteristic_value_name("CurrentHeatingCoolingState", 0) == "Off"
+ assert get_characteristic_value_name("CurrentHeatingCoolingState", 1) == "Heat"
+ assert get_characteristic_value_name("CurrentHeatingCoolingState", 2) == "Cool"
+ assert get_characteristic_value_name("CurrentHeatingCoolingState", 3) == "Auto"
+
+ def test_get_characteristic_value_name_temperature_units(self):
+ """Test value name retrieval for temperature display units."""
+ assert get_characteristic_value_name("TemperatureDisplayUnits", 0) == "Celsius"
+ assert get_characteristic_value_name("TemperatureDisplayUnits", 1) == "Fahrenheit"
+
+ def test_get_characteristic_value_name_battery_status(self):
+ """Test value name retrieval for battery status."""
+ assert get_characteristic_value_name("StatusLowBattery", 0) == "Normal"
+ assert get_characteristic_value_name("StatusLowBattery", 1) == "Low Battery"
+
+ def test_get_characteristic_value_name_unknown_returns_string(self):
+ """Test that unknown values are returned as strings."""
+ result = get_characteristic_value_name("UnknownCharacteristic", 42)
+ assert result == "42"
+
+ def test_get_characteristic_value_name_unknown_value_returns_string(self):
+ """Test that unknown values for known characteristics return string."""
+ result = get_characteristic_value_name("CurrentHeatingCoolingState", 99)
+ assert result == "99"
+
+
+class TestHomekitUUIDsAddTadoSpecificInfo:
+ def test_add_tado_specific_info_current_temperature(self):
+ """Test Tado-specific temperature conversion."""
+ enhanced_char = {}
+ result = add_tado_specific_info(enhanced_char, "CurrentTemperature", 20.0)
+
+ assert result["temperature_celsius"] == 20.0
+ assert result["temperature_fahrenheit"] == 68.0
+
+ def test_add_tado_specific_info_temperature_conversion_precision(self):
+ """Test temperature conversion maintains 1 decimal precision."""
+ enhanced_char = {}
+ result = add_tado_specific_info(enhanced_char, "CurrentTemperature", 22.5)
+
+ assert result["temperature_fahrenheit"] == 72.5
+
+ def test_add_tado_specific_info_humidity(self):
+ """Test Tado-specific humidity formatting."""
+ enhanced_char = {}
+ result = add_tado_specific_info(enhanced_char, "CurrentRelativeHumidity", 45)
+
+ assert result["humidity_percent"] == "45%"
+
+ def test_add_tado_specific_info_none_temperature(self):
+ """Test that None temperature is not processed."""
+ enhanced_char = {"value": None}
+ result = add_tado_specific_info(enhanced_char, "CurrentTemperature", None)
+
+ assert "temperature_celsius" not in result
+ assert "temperature_fahrenheit" not in result
+
+ def test_add_tado_specific_info_other_characteristic(self):
+ """Test that non-Tado-specific characteristics are unmodified."""
+ enhanced_char = {"value": "test"}
+ result = add_tado_specific_info(enhanced_char, "SerialNumber", "ABC123")
+
+ assert len(result) == 1
+ assert result == {"value": "test"}
+
+
+class TestHomekitUUIDsEnhanceAccessoryData:
+ def test_enhance_accessory_data_basic_structure(self):
+ """Test basic enhancement of accessory data structure."""
+ accessories = [
+ {
+ "id": 1,
+ "aid": 1,
+ "serial_number": "ABC123",
+ "services": []
+ }
+ ]
+
+ result = enhance_accessory_data(accessories)
+
+ assert len(result) == 1
+ assert result[0]["aid"] == 1
+ assert result[0]["serial_number"] == "ABC123"
+
+ def test_enhance_accessory_data_with_temperature_service(self):
+ """Test enhancement of temperature sensor service."""
+ accessories = [
+ {
+ "aid": 1,
+ "services": [
+ {
+ "type": "0000008A-0000-1000-8000-0026BB765291",
+ "iid": 10,
+ "characteristics": [
+ {
+ "type": "00000011-0000-1000-8000-0026BB765291",
+ "iid": 11,
+ "value": 20.5,
+ "perms": ["pr", "ev"],
+ "format": "float"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ result = enhance_accessory_data(accessories)
+ service = result[0]["services"][0]
+
+ assert service["type_name"] == "TemperatureSensor"
+ char = service["characteristics"][0]
+ assert char["type_name"] == "CurrentTemperature"
+ assert char["value"] == 20.5
+ assert char["value_name"] == "20.5"
+ assert char["temperature_celsius"] == 20.5
+ assert char["temperature_fahrenheit"] == 68.9
+
+ def test_enhance_accessory_data_with_thermostat_states(self):
+ """Test enhancement of thermostat with heating/cooling states."""
+ accessories = [
+ {
+ "aid": 1,
+ "services": [
+ {
+ "type": "0000004A-0000-1000-8000-0026BB765291",
+ "iid": 10,
+ "characteristics": [
+ {
+ "type": "0000000F-0000-1000-8000-0026BB765291",
+ "iid": 11,
+ "value": 1,
+ "perms": ["pr", "ev"]
+ },
+ {
+ "type": "00000033-0000-1000-8000-0026BB765291",
+ "iid": 12,
+ "value": 1,
+ "perms": ["pr", "pw", "ev"]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ result = enhance_accessory_data(accessories)
+ chars = result[0]["services"][0]["characteristics"]
+
+ assert chars[0]["type_name"] == "CurrentHeatingCoolingState"
+ assert chars[0]["value_name"] == "Heat"
+ assert chars[1]["type_name"] == "TargetHeatingCoolingState"
+ assert chars[1]["value_name"] == "Heat"
+
+ def test_enhance_accessory_data_with_multiple_accessories(self):
+ """Test enhancement of multiple accessories."""
+ accessories = [
+ {"aid": 1, "services": []},
+ {"aid": 2, "services": []},
+ {"aid": 3, "services": []}
+ ]
+
+ result = enhance_accessory_data(accessories)
+
+ assert len(result) == 3
+ assert result[0]["aid"] == 1
+ assert result[1]["aid"] == 2
+ assert result[2]["aid"] == 3
+
+ def test_enhance_accessory_data_with_constraints(self):
+ """Test that min/max constraints are preserved."""
+ accessories = [
+ {
+ "aid": 1,
+ "services": [
+ {
+ "type": "0000004A-0000-1000-8000-0026BB765291",
+ "iid": 10,
+ "characteristics": [
+ {
+ "type": "00000035-0000-1000-8000-0026BB765291",
+ "iid": 11,
+ "value": 21,
+ "minValue": 5,
+ "maxValue": 35,
+ "minStep": 0.5,
+ "perms": ["pr", "pw", "ev"]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ result = enhance_accessory_data(accessories)
+ char = result[0]["services"][0]["characteristics"][0]
+
+ assert char["minValue"] == 5
+ assert char["maxValue"] == 35
+ assert char["minStep"] == 0.5
+
+ def test_enhance_accessory_data_humidity_with_sensor(self):
+ """Test humidity sensor enhancement."""
+ accessories = [
+ {
+ "aid": 1,
+ "services": [
+ {
+ "type": "00000082-0000-1000-8000-0026BB765291",
+ "iid": 10,
+ "characteristics": [
+ {
+ "type": "00000010-0000-1000-8000-0026BB765291",
+ "iid": 11,
+ "value": 55,
+ "perms": ["pr", "ev"]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ result = enhance_accessory_data(accessories)
+ char = result[0]["services"][0]["characteristics"][0]
+
+ assert char["type_name"] == "CurrentRelativeHumidity"
+ assert char["humidity_percent"] == "55%"
+
+ def test_enhance_accessory_data_empty_accessories_list(self):
+ """Test enhancement with empty accessories list."""
+ result = enhance_accessory_data([])
+ assert result == []
+
+ def test_enhance_accessory_data_preserves_perms(self):
+ """Test that permissions are preserved in enhancement."""
+ accessories = [
+ {
+ "aid": 1,
+ "services": [
+ {
+ "type": "0000004A-0000-1000-8000-0026BB765291",
+ "iid": 10,
+ "characteristics": [
+ {
+ "type": "00000035-0000-1000-8000-0026BB765291",
+ "iid": 11,
+ "value": 21,
+ "perms": ["pr", "pw", "ev"]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ result = enhance_accessory_data(accessories)
+ char = result[0]["services"][0]["characteristics"][0]
+
+ assert char["perms"] == ["pr", "pw", "ev"]
+
diff --git a/tests/test_localapi.py b/tests/test_localapi.py
new file mode 100644
index 0000000..c0bc72c
--- /dev/null
+++ b/tests/test_localapi.py
@@ -0,0 +1,2713 @@
+import pytest
+import json
+from unittest.mock import AsyncMock, patch, Mock
+from fastapi import HTTPException
+from collections import defaultdict
+
+import asyncio
+
+from tado_local.api import TadoLocalAPI
+from tado_local.state import DeviceStateManager
+
+@pytest.fixture
+def api_instance(tmp_path):
+ """Fixture to create a TadoLocalAPI instance."""
+ db_path = str(tmp_path / "test_tado.db")
+ return TadoLocalAPI(db_path=db_path)
+
+
+@pytest.fixture
+def mock_pairing():
+ """Fixture to create a mock IpPairing."""
+ pairing = AsyncMock()
+ pairing.list_accessories_and_characteristics = AsyncMock(return_value=[])
+ pairing.subscribe = AsyncMock()
+ pairing.unsubscribe = AsyncMock()
+ pairing.get_characteristics = AsyncMock(return_value={})
+ pairing.put_characteristics = AsyncMock(return_value={})
+ pairing.dispatcher_connect = Mock()
+ return pairing
+
+class TestTadoLocalAPI:
+ @pytest.mark.asyncio
+ async def test_api_initialization(self, api_instance):
+ """Test TadoLocalAPI initialization."""
+ assert api_instance is not None
+ assert api_instance.pairing is None
+ assert api_instance.accessories_cache == []
+ assert api_instance.accessories_dict == {}
+ assert api_instance.accessories_id == {}
+ assert api_instance.characteristic_map == {}
+ assert api_instance.characteristic_iid_map == {}
+ assert api_instance.device_to_characteristics == {}
+ assert api_instance.event_listeners == []
+ assert api_instance.zone_event_listeners == []
+ assert api_instance.last_update is None
+ assert api_instance.is_initializing is False
+ assert api_instance.is_shutting_down is False
+ assert api_instance.state_manager is not None
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_with_pairing(self, api_instance, mock_pairing):
+ """Test API initialization with pairing."""
+ with patch.object(api_instance, 'refresh_accessories', new_callable=AsyncMock) as mock_refresh, \
+ patch.object(api_instance, 'initialize_device_states', new_callable=AsyncMock) as mock_init_states, \
+ patch.object(api_instance, 'setup_event_listeners', new_callable=AsyncMock) as mock_setup:
+
+ await api_instance.initialize(mock_pairing)
+
+ assert api_instance.pairing == mock_pairing
+ mock_refresh.assert_called_once()
+ mock_init_states.assert_called_once()
+ mock_setup.assert_called_once()
+ assert api_instance.is_initializing is False
+
+ @pytest.mark.asyncio
+ async def test_cleanup(self, api_instance, mock_pairing):
+ """Test cleanup properly shuts down resources."""
+ api_instance.pairing = mock_pairing
+ api_instance.subscribed_characteristics = [(1, 1), (1, 2)]
+
+ # Create actual asyncio tasks that can be cancelled and gathered
+ async def dummy_task():
+ await asyncio.sleep(10)
+
+ mock_task1 = asyncio.create_task(dummy_task())
+ mock_task2 = asyncio.create_task(dummy_task())
+ api_instance.background_tasks = [mock_task1, mock_task2]
+
+ # Add mock window timer
+ window_task = asyncio.create_task(dummy_task())
+ api_instance.window_close_timers = {1: window_task}
+
+ # Add event listeners
+ queue1 = AsyncMock()
+ queue2 = AsyncMock()
+ api_instance.event_listeners = [queue1, queue2]
+ api_instance.zone_event_listeners = [queue1]
+
+ await api_instance.cleanup()
+
+ assert api_instance.is_shutting_down is True
+ mock_pairing.unsubscribe.assert_called_once()
+
+ # Verify tasks were cancelled
+ assert mock_task1.cancelled() or mock_task1.done()
+ assert mock_task2.cancelled() or mock_task2.done()
+ assert window_task.cancelled() or window_task.done()
+
+ @pytest.mark.asyncio
+ async def test_event_listeners_management(self, api_instance):
+ """Test event listener queues can be added."""
+ queue1 = asyncio.Queue()
+ queue2 = asyncio.Queue()
+
+ api_instance.event_listeners.append(queue1)
+ api_instance.zone_event_listeners.append(queue2)
+
+ assert len(api_instance.event_listeners) == 1
+ assert len(api_instance.zone_event_listeners) == 1
+ assert queue1 in api_instance.event_listeners
+ assert queue2 in api_instance.zone_event_listeners
+
+
+ @pytest.mark.asyncio
+ async def test_initialization_flag(self, api_instance):
+ """Test initialization flag prevents logging during init."""
+ assert api_instance.is_initializing is False
+
+ api_instance.is_initializing = True
+ assert api_instance.is_initializing is True
+
+ api_instance.is_initializing = False
+ assert api_instance.is_initializing is False
+
+
+ @pytest.mark.asyncio
+ async def test_shutdown_flag(self, api_instance):
+ """Test shutdown flag."""
+ assert api_instance.is_shutting_down is False
+
+ api_instance.is_shutting_down = True
+ assert api_instance.is_shutting_down is True
+
+
+class TestTadoLocalAPIRefreshAccessories:
+ @pytest.mark.asyncio
+ async def test_process_raw_accessories(self, api_instance):
+ """Test raw accessories processing."""
+ raw_accessories = [
+ {
+ 'aid': 1,
+ 'services': [
+ {
+ 'type': '0000003E-0000-1000-8000-0026BB765291',
+ 'characteristics': []
+ }
+ ]
+ }
+ ]
+
+ with patch.object(api_instance.state_manager, 'get_or_create_device', return_value=1):
+ result = api_instance._process_raw_accessories(raw_accessories)
+ assert isinstance(result, dict)
+
+ def test_process_raw_accessories_with_serial_number(self, api_instance):
+ """Test processing accessories with serial number."""
+ raw_accessories = [
+ {
+ 'aid': 1,
+ 'services': [
+ {
+ 'type': '0000003E-0000-1000-8000-0026BB765291',
+ 'characteristics': [
+ {'type': '00000030-0000-1000-8000-0026BB765291', 'value': 'SN12345'},
+ {'type': '00000011-0000-1000-8000-0026BB765291', 'value': '21.3'}
+ ]
+ }
+ ]
+ }
+ ]
+
+ with patch.object(api_instance.state_manager, 'get_or_create_device', return_value=1):
+ result = api_instance._process_raw_accessories(raw_accessories)
+ assert result[1]['serial_number'] == "SN12345"
+
+ @pytest.mark.asyncio
+ async def test_refresh_accessories_without_pairing(self, api_instance):
+ """Test refresh_accessories raises exception when pairing is None."""
+ with pytest.raises(HTTPException) as exc_info:
+ await api_instance.refresh_accessories()
+
+ assert exc_info.value.status_code == 503
+ assert "Bridge not connected" in str(exc_info.value.detail)
+
+ @pytest.mark.asyncio
+ async def test_refresh_accessories_success(self, api_instance, mock_pairing):
+ """Test successful accessories refresh."""
+ mock_accessories = [
+ {
+ 'aid': 1,
+ 'services': [
+ {
+ 'type': '0000003E-0000-1000-8000-0026BB765291',
+ 'characteristics': [
+ {'type': '00000030-0000-1000-8000-0026BB765291', 'value': 'SN12345'}
+ ]
+ }
+ ]
+ }
+ ]
+
+ api_instance.pairing = mock_pairing
+ mock_pairing.list_accessories_and_characteristics.return_value = mock_accessories
+
+ with patch.object(api_instance, '_process_raw_accessories', return_value={'device1': {'aid': 1}}):
+ await api_instance.refresh_accessories()
+
+ assert api_instance.last_update is not None
+ assert len(api_instance.accessories_cache) > 0
+ mock_pairing.list_accessories_and_characteristics.assert_called_once()
+
+
+class TestTadoLocalAPIDeviceStates:
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_without_pairing(self, api_instance):
+ """Test initialize_device_states returns early when pairing is None."""
+ api_instance.pairing = None
+
+ # Should return early without raising
+ await api_instance.initialize_device_states()
+
+ # No calls should be made
+ assert api_instance.pairing is None
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_no_char_to_poll(self, api_instance, mock_pairing):
+ """Test initialize_device_states when no readable characteristics exist."""
+ api_instance.pairing = mock_pairing
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature')]
+ }
+
+ # Setup accessories with non-readable characteristic
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [
+ {
+ 'characteristics': [
+ {
+ 'iid': 10,
+ 'perms': ['pw'] # Write-only, not readable
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ await api_instance.initialize_device_states()
+
+ # Should not call get_characteristics
+ mock_pairing.get_characteristics.assert_not_called()
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_single_readable_char(self, api_instance, mock_pairing):
+ """Test initialize_device_states with a single readable characteristic."""
+ api_instance.pairing = mock_pairing
+
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature')]
+ }
+
+ # Setup accessories with readable characteristic
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [
+ {
+ 'characteristics': [
+ {
+ 'iid': 10,
+ 'perms': ['pr', 'ev'] # Readable
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ mock_pairing.get_characteristics.return_value = {
+ (1, 10): {'value': 21.5}
+ }
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('temperature', None, 21.5)) as mock_update:
+ await api_instance.initialize_device_states()
+
+ mock_pairing.get_characteristics.assert_called_once_with([(1, 10)])
+ mock_update.assert_called_once()
+ call_args = mock_update.call_args[0]
+ assert call_args[0] == 1 # device_id
+ assert call_args[1] == 'temperature' # char_type
+ assert call_args[2] == 21.5 # value
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_multiple_char(self, api_instance, mock_pairing):
+ """Test initialize_device_states with multiple readable characteristics."""
+ api_instance.pairing = mock_pairing
+
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature'), (1, 11, 'humidity')],
+ 2: [(2, 20, 'temperature')]
+ }
+
+ # Setup accessories
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [
+ {
+ 'characteristics': [
+ {'iid': 10, 'perms': ['pr']},
+ {'iid': 11, 'perms': ['pr', 'ev']}
+ ]
+ }
+ ]
+ },
+ {
+ 'aid': 2,
+ 'services': [
+ {
+ 'characteristics': [
+ {'iid': 20, 'perms': ['pr']}
+ ]
+ }
+ ]
+ }
+ ]
+
+ mock_pairing.get_characteristics.return_value = {
+ (1, 10): {'value': 21.5},
+ (1, 11): {'value': 55},
+ (2, 20): {'value': 22.0}
+ }
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('field', None, 'value')) as mock_update:
+ await api_instance.initialize_device_states()
+
+ mock_pairing.get_characteristics.assert_called_once()
+ assert mock_update.call_count == 3
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_batch_processing(self, api_instance, mock_pairing):
+ """Test that characteristics are polled in batches of 10."""
+ api_instance.pairing = mock_pairing
+
+ # Create 25 characteristics (should require 3 batches)
+ device_to_chars = {}
+ accessories = []
+
+ for i in range(25):
+ aid = i + 1
+ iid = 10
+ device_to_chars[aid] = [(aid, iid, 'temperature')]
+
+ accessories.append({
+ 'aid': aid,
+ 'services': [{
+ 'characteristics': [{'iid': iid, 'perms': ['pr']}]
+ }]
+ })
+
+ api_instance.device_to_characteristics = device_to_chars
+ api_instance.accessories_cache = accessories
+
+ # Mock responses for all characteristics
+ mock_results = {(i+1, 10): {'value': 20.0 + i} for i in range(25)}
+ mock_pairing.get_characteristics.return_value = mock_results
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('temperature', None, 20.0)):
+ await api_instance.initialize_device_states()
+
+ # Should be called 3 times (batch_size=10: 10, 10, 5)
+ assert mock_pairing.get_characteristics.call_count == 3
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_handles_batch_errors(self, api_instance, mock_pairing):
+ """Test that errors in one batch don't prevent other batches."""
+ api_instance.pairing = mock_pairing
+
+ # Create 15 characteristics (2 batches)
+ device_to_chars = {}
+ accessories = []
+
+ for i in range(15):
+ aid = i + 1
+ iid = 10
+ device_to_chars[aid] = [(aid, iid, 'temperature')]
+
+ accessories.append({
+ 'aid': aid,
+ 'services': [{
+ 'characteristics': [{'iid': iid, 'perms': ['pr']}]
+ }]
+ })
+
+ api_instance.device_to_characteristics = device_to_chars
+ api_instance.accessories_cache = accessories
+
+ # First batch fails, second succeeds
+ mock_pairing.get_characteristics.side_effect = [
+ Exception("Connection error"),
+ {(i, 10): {'value': 20.0} for i in range(11, 16)}
+ ]
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('temperature', None, 20.0)):
+ await api_instance.initialize_device_states()
+
+ # Should attempt both batches despite first one failing
+ assert mock_pairing.get_characteristics.call_count == 2
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_skips_none_values(self, api_instance, mock_pairing):
+ """Test that None values are skipped during initialization."""
+ api_instance.pairing = mock_pairing
+
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature')]
+ }
+
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [{'iid': 10, 'perms': ['pr']}]
+ }]
+ }
+ ]
+
+ # Return None value
+ mock_pairing.get_characteristics.return_value = {
+ (1, 10): {'value': None}
+ }
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic') as mock_update:
+ await api_instance.initialize_device_states()
+
+ # Should not call update_device_characteristic for None values
+ mock_update.assert_not_called()
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_missing_characteristic_data(self, api_instance, mock_pairing):
+ """Test handling when characteristic is not in results."""
+ api_instance.pairing = mock_pairing
+
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature'), (1, 11, 'humidity')]
+ }
+
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'perms': ['pr']},
+ {'iid': 11, 'perms': ['pr']}
+ ]
+ }]
+ }
+ ]
+
+ # Only return one characteristic
+ mock_pairing.get_characteristics.return_value = {
+ (1, 10): {'value': 21.5}
+ # (1, 11) missing
+ }
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('temperature', None, 21.5)) as mock_update:
+ await api_instance.initialize_device_states()
+
+ # Should only update the one present
+ assert mock_update.call_count == 1
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_mixed_permissions(self, api_instance, mock_pairing):
+ """Test that only readable characteristics are polled."""
+ api_instance.pairing = mock_pairing
+
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature'), (1, 11, 'target_temp'), (1, 12, 'mode')]
+ }
+
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'perms': ['pr']}, # Readable
+ {'iid': 11, 'perms': ['pw']}, # Write-only
+ {'iid': 12, 'perms': ['pr', 'pw']} # Read-write
+ ]
+ }]
+ }
+ ]
+
+ mock_pairing.get_characteristics.return_value = {
+ (1, 10): {'value': 21.5},
+ (1, 12): {'value': 1}
+ }
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('field', None, 'value')):
+ await api_instance.initialize_device_states()
+
+ # Should only poll characteristics with 'pr' permission
+ call_args = mock_pairing.get_characteristics.call_args[0][0]
+ assert len(call_args) == 2
+ assert (1, 10) in call_args
+ assert (1, 12) in call_args
+ assert (1, 11) not in call_args
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_uses_timestamp(self, api_instance, mock_pairing):
+ """Test that timestamp is passed to state manager."""
+ api_instance.pairing = mock_pairing
+
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature')]
+ }
+
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [{'iid': 10, 'perms': ['pr']}]
+ }]
+ }
+ ]
+
+ mock_pairing.get_characteristics.return_value = {
+ (1, 10): {'value': 21.5}
+ }
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('temperature', None, 21.5)) as mock_update, \
+ patch('time.time', return_value=1234567890.0):
+
+ await api_instance.initialize_device_states()
+
+ # Check that timestamp was passed
+ call_args = mock_update.call_args[0]
+ assert call_args[3] == 1234567890.0 # timestamp argument
+
+
+ @pytest.mark.asyncio
+ async def test_initialize_device_states_logs_initialization(self, api_instance, mock_pairing):
+ """Test that initialization is properly logged."""
+ api_instance.pairing = mock_pairing
+
+ api_instance.device_to_characteristics = {
+ 1: [(1, 10, 'temperature')]
+ }
+
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [{'iid': 10, 'perms': ['pr']}]
+ }]
+ }
+ ]
+
+ mock_pairing.get_characteristics.return_value = {
+ (1, 10): {'value': 21.5}
+ }
+
+ with patch.object(api_instance.state_manager, 'update_device_characteristic',
+ return_value=('temperature', None, 21.5)):
+ await api_instance.initialize_device_states()
+
+ # Method should complete without errors
+ assert True
+
+
+class TestTadoLocalAPISetupEventListeners:
+ @pytest.mark.asyncio
+ async def test_setup_event_listeners_without_pairing(self, api_instance):
+ """Test setup_event_listeners returns early when pairing is None."""
+ api_instance.pairing = None
+
+ await api_instance.setup_event_listeners()
+
+ # Should return without setting up change_tracker
+ assert not hasattr(api_instance, 'change_tracker')
+
+
+ @pytest.mark.asyncio
+ async def test_setup_event_listeners_initializes_change_tracker(self, api_instance, mock_pairing):
+ """Test that change_tracker is properly initialized."""
+ api_instance.pairing = mock_pairing
+ api_instance.device_to_characteristics = {}
+ api_instance.accessories_cache = []
+
+ with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True):
+ await api_instance.setup_event_listeners()
+
+ assert hasattr(api_instance, 'change_tracker')
+ assert api_instance.change_tracker['events_received'] == 0
+ assert api_instance.change_tracker['polling_changes'] == 0
+ assert isinstance(api_instance.change_tracker['last_values'], dict)
+ assert isinstance(api_instance.change_tracker['event_characteristics'], set)
+
+
+ @pytest.mark.asyncio
+ async def test_setup_event_listeners_populates_last_values(self, api_instance, mock_pairing):
+ """Test that last_values is populated from current device states."""
+ api_instance.pairing = mock_pairing
+ api_instance.device_to_characteristics = {
+ 1: [
+ (1, 10, DeviceStateManager.CHAR_CURRENT_TEMPERATURE),
+ (1, 11, DeviceStateManager.CHAR_TARGET_TEMPERATURE)
+ ]
+ }
+ api_instance.accessories_cache = []
+
+ # Mock current state
+ mock_state = {
+ 'current_temperature': 21.5,
+ 'target_temperature': 22.0,
+ 'humidity': 55
+ }
+
+ with patch.object(api_instance.state_manager, 'get_current_state', return_value=mock_state), \
+ patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True):
+
+ await api_instance.setup_event_listeners()
+
+ # Should have populated last_values
+ assert (1, 10) in api_instance.change_tracker['last_values']
+ assert api_instance.change_tracker['last_values'][(1, 10)] == 21.5
+ assert (1, 11) in api_instance.change_tracker['last_values']
+ assert api_instance.change_tracker['last_values'][(1, 11)] == 22.0
+
+
+ @pytest.mark.asyncio
+ async def test_setup_event_listeners_calls_persistent_events(self, api_instance, mock_pairing):
+ """Test that setup_persistent_events is called."""
+ api_instance.pairing = mock_pairing
+ api_instance.device_to_characteristics = {}
+ api_instance.accessories_cache = []
+
+ with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True) \
+ as mock_setup:
+ await api_instance.setup_event_listeners()
+
+ mock_setup.assert_called_once()
+
+
+ @pytest.mark.asyncio
+ async def test_setup_event_listeners_fallback_to_polling(self, api_instance, mock_pairing):
+ """Test fallback to polling when events are not available."""
+ api_instance.pairing = mock_pairing
+ api_instance.device_to_characteristics = {}
+ api_instance.accessories_cache = []
+
+ with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=False), \
+ patch.object(api_instance, 'setup_polling_system', new_callable=AsyncMock) as mock_polling:
+
+ await api_instance.setup_event_listeners()
+
+ mock_polling.assert_called_once()
+
+
+ @pytest.mark.asyncio
+ async def test_setup_event_listeners_skips_polling_when_events_active(self, api_instance, mock_pairing):
+ """Test that polling is skipped when events are active."""
+ api_instance.pairing = mock_pairing
+ api_instance.device_to_characteristics = {}
+ api_instance.accessories_cache = []
+
+ with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True), \
+ patch.object(api_instance, 'setup_polling_system', new_callable=AsyncMock) as mock_polling:
+
+ await api_instance.setup_event_listeners()
+
+ mock_polling.assert_not_called()
+
+
+class TestTadoLocalAPISetupPersistentEvents:
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_no_event_char(self, api_instance, mock_pairing):
+ """Test setup_persistent_events when no event characteristics exist."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['pr']} # No 'ev'
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ result = await api_instance.setup_persistent_events()
+
+ assert result is False
+ mock_pairing.subscribe.assert_not_called()
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_success(self, api_instance, mock_pairing):
+ """Test successful setup of persistent events."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {
+ 'iid': 10,
+ 'type': '00000011-0000-1000-8000-0026BB765291',
+ 'perms': ['pr', 'ev'] # Event notification supported
+ },
+ {
+ 'iid': 11,
+ 'type': '00000035-0000-1000-8000-0026BB765291',
+ 'perms': ['pr', 'pw', 'ev']
+ }
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ result = await api_instance.setup_persistent_events()
+
+ assert result is True
+ mock_pairing.subscribe.assert_called_once()
+
+ # Check subscribed characteristics
+ call_args = mock_pairing.subscribe.call_args[0][0]
+ assert (1, 10) in call_args
+ assert (1, 11) in call_args
+ assert len(api_instance.subscribed_characteristics) == 2
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_registers_dispatcher(self, api_instance, mock_pairing):
+ """Test that event callback is registered with dispatcher."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ await api_instance.setup_persistent_events()
+
+ # Check that dispatcher_connect was called
+ mock_pairing.dispatcher_connect.assert_called_once()
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_populates_char_maps(self, api_instance, mock_pairing):
+ """Test that characteristic maps are populated."""
+ mock_pairing.dispatcher_connect = Mock() # ensure sync
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ await api_instance.setup_persistent_events()
+
+ # Check characteristic_map was populated
+ assert (1, 10) in api_instance.characteristic_map
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_tracks_event_char(self, api_instance, mock_pairing):
+ """Test that event characteristics are tracked."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']},
+ {'iid': 11, 'type': '00000035-0000-1000-8000-0026BB765291', 'perms': ['pr']} # No 'ev'
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ await api_instance.setup_persistent_events()
+
+ # Only event-capable characteristics should be tracked
+ assert (1, 10) in api_instance.change_tracker['event_characteristics']
+ assert (1, 11) not in api_instance.change_tracker['event_characteristics']
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_handles_multiple_acc(self, api_instance, mock_pairing):
+ """Test setup with multiple accessories."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}
+ ]
+ }]
+ },
+ {
+ 'aid': 2,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 20, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ result = await api_instance.setup_persistent_events()
+
+ assert result is True
+ call_args = mock_pairing.subscribe.call_args[0][0]
+ assert (1, 10) in call_args
+ assert (2, 20) in call_args
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_handles_subscript_err(self, api_instance, mock_pairing):
+ """Test handling of subscription errors."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ mock_pairing.subscribe.side_effect = Exception("Subscription failed")
+
+ result = await api_instance.setup_persistent_events()
+
+ assert result is False
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_callback_creates_task(self, api_instance, mock_pairing):
+ """Test that event callback is registered and callable."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ await api_instance.setup_persistent_events()
+
+ # Verify the callback was registered
+ mock_pairing.dispatcher_connect.assert_called_once()
+
+ # Verify the callback is callable
+ callback = mock_pairing.dispatcher_connect.call_args[0][0]
+ assert callable(callback)
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_empty_accessories_cache(self, api_instance, mock_pairing):
+ """Test setup with empty accessories cache."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = []
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ result = await api_instance.setup_persistent_events()
+
+ assert result is False
+ mock_pairing.subscribe.assert_not_called()
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_mixed_permissions(self, api_instance, mock_pairing):
+ """Test that only characteristics with 'ev' permission are subscribed."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': 'type1', 'perms': ['pr', 'ev']}, # Event
+ {'iid': 11, 'type': 'type2', 'perms': ['pr']}, # No event
+ {'iid': 12, 'type': 'type3', 'perms': ['pw', 'ev']}, # Event
+ {'iid': 13, 'type': 'type4', 'perms': ['pw']} # No event
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ await api_instance.setup_persistent_events()
+
+ call_args = mock_pairing.subscribe.call_args[0][0]
+ assert (1, 10) in call_args
+ assert (1, 11) not in call_args
+ assert (1, 12) in call_args
+ assert (1, 13) not in call_args
+
+
+ @pytest.mark.asyncio
+ async def test_setup_persistent_events_stores_subscribed_char(self, api_instance, mock_pairing):
+ """Test that subscribed_characteristics is stored for cleanup."""
+ api_instance.pairing = mock_pairing
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'services': [{
+ 'characteristics': [
+ {'iid': 10, 'type': 'type1', 'perms': ['ev']},
+ {'iid': 11, 'type': 'type2', 'perms': ['ev']}
+ ]
+ }]
+ }
+ ]
+ api_instance.change_tracker = {
+ 'event_characteristics': set(),
+ 'last_values': {}
+ }
+
+ await api_instance.setup_persistent_events()
+
+ assert hasattr(api_instance, 'subscribed_characteristics')
+ assert len(api_instance.subscribed_characteristics) == 2
+ assert (1, 10) in api_instance.subscribed_characteristics
+ assert (1, 11) in api_instance.subscribed_characteristics
+
+
+class TestTadoLocalAPIDataStructures:
+ @pytest.mark.asyncio
+ async def test_characteristic_maps(self, api_instance):
+ """Test characteristic mapping dictionaries."""
+ # Test characteristic_map structure (aid, iid) -> char_type
+ api_instance.characteristic_map[(1, 10)] = "temperature"
+ assert api_instance.characteristic_map[(1, 10)] == "temperature"
+
+ # Test characteristic_iid_map structure (aid, char_type) -> iid
+ api_instance.characteristic_iid_map[(1, "temperature")] = 10
+ assert api_instance.characteristic_iid_map[(1, "temperature")] == 10
+
+ # Test device_to_characteristics structure
+ api_instance.device_to_characteristics[1] = [(1, 10, "temperature")]
+ assert len(api_instance.device_to_characteristics[1]) == 1
+
+ @pytest.mark.asyncio
+ async def test_accessories_id_mapping(self, api_instance):
+ """Test accessories ID mapping."""
+ api_instance.accessories_id[1] = "device_serial_123"
+ assert api_instance.accessories_id[1] == "device_serial_123"
+
+ @pytest.mark.asyncio
+ async def test_device_states_tracking(self, api_instance):
+ """Test device states are properly tracked."""
+ assert isinstance(api_instance.device_states, dict)
+ assert isinstance(api_instance.last_zone_states, dict)
+
+ # Test that device_states uses defaultdict
+ test_value = api_instance.device_states['test_device']
+ assert isinstance(test_value, dict)
+
+ @pytest.mark.asyncio
+ async def test_get_iid_from_characteristics(self, api_instance):
+ """Test get_iid_from_characteristics returns IID for known mapping."""
+ api_instance.characteristic_iid_map[(1, "temperature")] = 10
+ assert api_instance.get_iid_from_characteristics(1, "temperature") == 10
+
+ @pytest.mark.asyncio
+ async def test_get_iid_from_characteristics_unknown(self, api_instance):
+ """Test get_iid_from_characteristics returns None for unknown mapping."""
+ assert api_instance.get_iid_from_characteristics(99, "unknown") is None
+
+
+class TestTadoLocalHandleChange:
+ def _setup_handle_change(self, api_instance):
+ """Helper to initialize common handle_change dependencies."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.change_tracker = {
+ 'events_received': 0,
+ 'polling_changes': 0,
+ 'last_values': {},
+ 'event_characteristics': set(),
+ }
+ api_instance.accessories_id = {1: "dev-1"}
+ api_instance.accessories_cache = [
+ {
+ 'aid': 1,
+ 'id': "dev-1",
+ 'services': [{
+ 'characteristics': [ # CurrentTemperature
+ {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291'}
+ ]
+ }]
+ }
+ ]
+ api_instance.state_manager.get_device_info.return_value = {
+ 'zone_name': 'Living',
+ 'name': 'Thermostat',
+ 'is_zone_leader': False # avoid window detection path
+ }
+ api_instance.state_manager.update_device_characteristic.return_value \
+ = ("current_temperature", 20.0, 22.5)
+ api_instance.broadcast_state_change = AsyncMock()
+ api_instance._handle_window_open_detection = Mock()
+
+ @pytest.mark.asyncio
+ async def test_handle_change_updates_state(self, api_instance):
+ """Test that handle_change updates state manager with new value."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+
+ await api_instance.handle_change(1, 10, {"value": 22.5})
+
+ api_instance.state_manager.update_device_characteristic.assert_called_once()
+ args = api_instance.state_manager.update_device_characteristic.call_args[0]
+ assert args[0] == "dev-1"
+ assert args[1] == '00000011-0000-1000-8000-0026BB765291'.lower()
+ assert args[2] == 22.5
+ api_instance.broadcast_state_change.assert_called_once_with("dev-1", "Living")
+
+ @pytest.mark.asyncio
+ async def test_handle_change_no_update_on_same_value(self, api_instance):
+ """Test that handle_change does not update state manager if value is unchanged."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+
+ await api_instance.handle_change(1, 10, {"value": 22.5})
+ api_instance.state_manager.update_device_characteristic.reset_mock()
+ api_instance.broadcast_state_change.reset_mock()
+
+ await api_instance.handle_change(1, 10, {"value": 22.5})
+
+ api_instance.state_manager.update_device_characteristic.assert_not_called()
+ api_instance.broadcast_state_change.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_handle_change_ignores_none_value(self, api_instance):
+ """Test that handle_change ignores None values."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+
+ await api_instance.handle_change(1, 10, {"value": None})
+
+ api_instance.state_manager.update_device_characteristic.assert_not_called()
+ api_instance.broadcast_state_change.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_handle_change_no_aid(self, api_instance):
+ """Test that handle_change does not have an aid."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+
+ await api_instance.handle_change(None, 10, {"value": 22.5})
+
+ api_instance.state_manager.update_device_characteristic.assert_not_called()
+ api_instance.broadcast_state_change.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_handle_change_no_iid(self, api_instance):
+ """Test that handle_change does not have an iid."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+
+ await api_instance.handle_change(1, None, {"value": 22.5})
+
+ api_instance.state_manager.update_device_characteristic.assert_not_called()
+ api_instance.broadcast_state_change.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_handle_change_caches_characteristic_from_accessory(self, api_instance):
+ """Test that handle_change fills characteristic_map when missing."""
+ self._setup_handle_change(api_instance)
+ assert (1, 10) not in api_instance.characteristic_map
+
+ await api_instance.handle_change(1, 10, {"value": 22.5})
+
+ assert (1, 10) in api_instance.characteristic_map
+
+ @pytest.mark.asyncio
+ async def test_handle_change_tracks_event_counters(self, api_instance):
+ """Test that event counters are updated."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+
+ await api_instance.handle_change(1, 10, {"value": 22.5}, source="EVENT")
+
+ assert api_instance.change_tracker['events_received'] == 1
+ assert api_instance.change_tracker['polling_changes'] == 0
+
+ @pytest.mark.asyncio
+ async def test_handle_change_tracks_polling_counters(self, api_instance):
+ """Test that polling counters are updated."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+
+ await api_instance.handle_change(1, 10, {"value": 22.5}, source="POLL")
+
+ api_instance._handle_window_open_detection.assert_not_called()
+ assert api_instance.change_tracker['events_received'] == 0
+ assert api_instance.change_tracker['polling_changes'] == 1
+
+ @pytest.mark.asyncio
+ async def test_handle_change_calls_window_detection(self, api_instance):
+ """Test that window open detection is triggered for temperature changes."""
+ self._setup_handle_change(api_instance)
+ api_instance.characteristic_map[(1, 10)] = "CurrentTemperature"
+ api_instance.state_manager.get_device_info.return_value = {
+ 'zone_name': 'Living',
+ 'name': 'Thermostat',
+ 'is_zone_leader': True
+ }
+
+ await api_instance.handle_change(1, 10, {"value": 22.5})
+
+ assert api_instance.change_tracker['polling_changes'] == 1
+ api_instance._handle_window_open_detection.assert_called_once_with(
+ 'dev-1',
+ {'zone_name': 'Living', 'name': 'Thermostat', 'is_zone_leader': True},
+ '00000011-0000-1000-8000-0026bb765291'
+ )
+
+class TestTadoLocalAPIHandleWindowOpenDetection:
+ def _setup_window_detection(self, api_instance):
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living", "window_open_time": 15, "window_rest_time": 15}
+ char_type = DeviceStateManager.CHAR_CURRENT_TEMPERATURE
+ return device_id, device_info, char_type
+
+ def test_window_detection_ignores_non_temperature_char(self, api_instance):
+ device_id, device_info, _ = self._setup_window_detection(api_instance)
+
+ api_instance._handle_window_open_detection(device_id, device_info, "targettemperature")
+
+ api_instance.state_manager.get_current_state.assert_not_called()
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ def test_window_detection_returns_when_no_leader_state(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+ api_instance.state_manager.get_current_state.return_value = None
+
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.get_device_history_info.assert_not_called()
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ def test_window_detection_returns_when_no_history(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+ api_instance.state_manager.get_current_state.return_value = {"window": 0, "cur_heating": 1}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 0,
+ "earliest_entry": None,
+ "latest_entry": None,
+ }
+
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ def test_window_detection_returns_when_short_on_history(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+ api_instance.state_manager.get_current_state.return_value = {"window": 0, "cur_heating": 1}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 1,
+ "earliest_entry": (22.5, 0, 1940),
+ "latest_entry": (22.5, 0, 1940), # same reading
+ }
+
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ def test_window_detection_closes_open_window_after_timeout(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+ api_instance.state_manager.get_current_state.return_value = {"window": 1, "cur_heating": 1}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 2,
+ "earliest_entry": (22.0, 1, 1000),
+ "latest_entry": (21.0, 1, 1000),
+ }
+
+ with patch.object(api_instance, "_cancel_window_close_timer") as mock_cancel, \
+ patch("time.time", return_value=2000):
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 2)
+ mock_cancel.assert_called_once_with(device_id)
+
+ def test_window_detection_opens_window_on_heating_temp_drop(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+ api_instance.state_manager.get_current_state.return_value = {"window": 0, "cur_heating": 1}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 2,
+ "earliest_entry": (22.5, 0, 1940),
+ "latest_entry": (21.0, 0, 1990), # drop = 1.5
+ }
+
+ with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \
+ patch("time.time", return_value=2000):
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 1)
+ mock_schedule.assert_called_once_with(device_id, 15, device_info)
+
+ def test_window_detection_keeps_window_closed_on_small_drop(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+ api_instance.state_manager.get_current_state.return_value = {"window": 0, "cur_heating": 1}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 2,
+ "earliest_entry": (22.0, 0, 1940),
+ "latest_entry": (21.3, 0, 1990), # drop = 0.7
+ }
+
+ with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \
+ patch("time.time", return_value=2000):
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 0)
+ mock_schedule.assert_not_called()
+
+ def test_window_detection_cooling_mode_sets_open_and_schedules_timer(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+
+ # cooling mode
+ api_instance.state_manager.get_current_state.return_value = {"window": 0, "cur_heating": 2}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 2,
+ "earliest_entry": (21.0, 0, 1940),
+ "latest_entry": (22.5, 0, 1990), # temp_change = 1.5
+ }
+
+ with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \
+ patch("time.time", return_value=2000):
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 1)
+ mock_schedule.assert_called_once_with(device_id, 15, device_info)
+
+ def test_window_detection_cooling_mode_small_rise_keeps_closed(self, api_instance):
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+
+ # cooling mode
+ api_instance.state_manager.get_current_state.return_value = {"window": 0, "cur_heating": 2}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 2,
+ "earliest_entry": (21.0, 0, 1940),
+ "latest_entry": (21.3, 0, 1990), # temp_change = 0.3
+ }
+
+ with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \
+ patch("time.time", return_value=2000):
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 0)
+ mock_schedule.assert_not_called()
+
+ def test_window_opens_after_timer_expires(self, api_instance):
+ """Test window opens when temp drop threshold is met after timer expires."""
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+
+ # Initialize window_close_timers dict
+ api_instance.window_close_timers = {}
+
+ api_instance.state_manager.get_current_state.return_value = {"window": 0, "cur_heating": 1}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 2,
+ "earliest_entry": (22.5, 0, 1940),
+ "latest_entry": (21.0, 0, 1990), # drop = 1.5
+ }
+
+ with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \
+ patch("time.time", return_value=2000):
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 1)
+ mock_schedule.assert_called_once_with(device_id, 15, device_info)
+
+ @pytest.mark.asyncio
+ async def test_window_closes_after_timer_expiry(self, api_instance):
+ """Test window closes after timer expires by calling the callback."""
+ device_id, device_info, char_type = self._setup_window_detection(api_instance)
+
+ api_instance.window_close_timers = {}
+ api_instance.state_manager.get_current_state.return_value = {"window": 1, "cur_heating": 1}
+ api_instance.state_manager.get_device_history_info.return_value = {
+ "history_count": 2,
+ "earliest_entry": (22.5, 0, 1940),
+ "latest_entry": (21.0, 0, 1990),
+ }
+
+ # First call: window opens and schedules timer
+ with patch("time.time", return_value=2000):
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ api_instance.state_manager.update_device_window_status.reset_mock()
+
+ # Simulate timer expiry: window was open for > 15 mins
+ with patch("time.time", return_value=3000): # 1000 seconds later = ~16 mins
+ api_instance._handle_window_open_detection(device_id, device_info, char_type)
+
+ # Window should be set to rest state (2)
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 2)
+
+class TestTadoLocalAPIScheduleWindowCloseTimer:
+ @pytest.mark.asyncio
+ async def test_schedule_window_close_timer_creates_and_stores_task(self, api_instance):
+ """Test that _schedule_window_close_timer creates and stores an asyncio task."""
+ api_instance.window_close_timers = {}
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+ delay = 1 # 1 minute
+ device_info = {"zone_name": "Living"}
+
+ api_instance._schedule_window_close_timer(device_id, delay, device_info)
+
+ assert device_id in api_instance.window_close_timers
+ task = api_instance.window_close_timers[device_id]
+ assert asyncio.isfuture(task)
+ assert not task.done()
+
+ @pytest.mark.asyncio
+ async def test_schedule_window_close_timer_cancels_existing_task(self, api_instance):
+ """Test that scheduling a new timer cancels the existing one."""
+ api_instance.window_close_timers = {}
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+
+ # Schedule first timer
+ api_instance._schedule_window_close_timer(device_id, 10, {"zone_name": "Living"})
+ first_task = api_instance.window_close_timers[device_id]
+
+ # Schedule second timer - should cancel first
+ api_instance._schedule_window_close_timer(device_id, 10, {"zone_name": "Living"})
+ second_task = api_instance.window_close_timers[device_id]
+
+ # Give the cancellation time to propagate
+ await asyncio.sleep(0.1)
+
+ # First task should be cancelled or done
+ assert first_task.cancelled() or first_task.done()
+ assert not second_task.done()
+ assert first_task is not second_task
+
+ @pytest.mark.asyncio
+ async def test_schedule_window_close_timer_returns_early_when_shut_down(self, api_instance):
+ """Test that _schedule_window_close_timer returns early when is_shutting_down is True."""
+ api_instance.window_close_timers = {}
+ api_instance.is_shutting_down = True
+ device_id = "dev-1"
+
+ api_instance._schedule_window_close_timer(device_id, 10, {"zone_name": "Living"})
+
+ # Should not create a task
+ assert device_id not in api_instance.window_close_timers
+
+ @pytest.mark.asyncio
+ async def test_schedule_window_close_timer_adds_done_callback(self, api_instance):
+ """Test that the done callback is registered and cleans up after timer completes."""
+ api_instance.window_close_timers = {}
+ api_instance.is_shutting_down = False
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {"window": 1}
+ device_id = "dev-1"
+ delay = 1 # 1 minute (in real use, but we'll mock the handler)
+
+ with patch.object(api_instance,
+ "_window_close_handler", new_callable=AsyncMock) as mock_handler:
+ mock_handler.return_value = None
+ api_instance._schedule_window_close_timer(device_id, delay, {"zone_name": "Living"})
+
+ task = api_instance.window_close_timers[device_id]
+
+ # Wait for task to complete
+ try:
+ await asyncio.wait_for(task, timeout=2)
+ except asyncio.TimeoutError:
+ pass
+
+ # Task should be cleaned up after completion
+ assert device_id not in api_instance.window_close_timers \
+ or api_instance.window_close_timers[device_id].done()
+
+ @pytest.mark.asyncio
+ async def test_schedule_window_close_timer_multiple_devices(self, api_instance):
+ """Test scheduling timers for multiple devices independently."""
+ api_instance.window_close_timers = {}
+ api_instance.is_shutting_down = False
+
+ device_ids = ["dev-1", "dev-2", "dev-3"]
+
+ for device_id in device_ids:
+ api_instance._schedule_window_close_timer(device_id, 10, {"zone_name": "Living"})
+
+ # All devices should have timers
+ assert len(api_instance.window_close_timers) == 3
+ for device_id in device_ids:
+ assert device_id in api_instance.window_close_timers
+ assert asyncio.isfuture(api_instance.window_close_timers[device_id])
+
+class TestTadoLocalAPICancelWindowCloseTimer:
+ @pytest.mark.asyncio
+ async def test_cancel_window_close_timer_removes_and_cancels_task(self, api_instance):
+ """Test that _cancel_window_close_timer removes and cancels the task."""
+ api_instance.window_close_timers = {}
+ device_id = "dev-1"
+
+ # Create and store a task
+ async def dummy_task():
+ await asyncio.sleep(10)
+
+ task = asyncio.create_task(dummy_task())
+ api_instance.window_close_timers[device_id] = task
+
+ # Cancel the timer
+ api_instance._cancel_window_close_timer(device_id)
+
+ # Give cancellation time to propagate
+ await asyncio.sleep(0.1)
+
+ # Task should be removed and cancelled
+ assert device_id not in api_instance.window_close_timers
+ assert task.cancelled()
+
+ @pytest.mark.asyncio
+ async def test_cancel_window_close_timer_noop_when_no_timer(self, api_instance):
+ """Test that _cancel_window_close_timer handles missing timer gracefully."""
+ api_instance.window_close_timers = {}
+ device_id = "dev-1"
+
+ # Should not raise exception
+ api_instance._cancel_window_close_timer(device_id)
+
+ # Should be empty
+ assert device_id not in api_instance.window_close_timers
+
+ @pytest.mark.asyncio
+ async def test_cancel_window_close_timer_ignores_completed_task(self, api_instance):
+ """Test that _cancel_window_close_timer doesn't try to cancel already done task."""
+ api_instance.window_close_timers = {}
+ device_id = "dev-1"
+
+ # Create a completed task
+ async def dummy_task():
+ pass
+
+ task = asyncio.create_task(dummy_task())
+ await task # Wait for completion
+ api_instance.window_close_timers[device_id] = task
+
+ # Cancel the timer
+ api_instance._cancel_window_close_timer(device_id)
+
+ # Task should be removed
+ assert device_id not in api_instance.window_close_timers
+ # task.cancel() should not be called on completed task (handled by the method)
+
+ @pytest.mark.asyncio
+ async def test_cancel_window_close_timer_removes_from_dict(self, api_instance):
+ """Test that device is removed from window_close_timers dict."""
+ api_instance.window_close_timers = {}
+ device_id = "dev-1"
+
+ # Create and store a task
+ async def dummy_task():
+ await asyncio.sleep(10)
+
+ task = asyncio.create_task(dummy_task())
+ api_instance.window_close_timers[device_id] = task
+
+ # Verify task is stored
+ assert device_id in api_instance.window_close_timers
+
+ # Cancel the timer
+ api_instance._cancel_window_close_timer(device_id)
+
+ # Task should be removed from dict
+ assert device_id not in api_instance.window_close_timers
+
+ @pytest.mark.asyncio
+ async def test_cancel_window_close_timer_multiple_devices(self, api_instance):
+ """Test canceling timers for multiple devices independently."""
+ api_instance.window_close_timers = {}
+
+ async def dummy_task():
+ await asyncio.sleep(10)
+
+ # Create timers for multiple devices
+ device_ids = ["dev-1", "dev-2", "dev-3"]
+ for device_id in device_ids:
+ task = asyncio.create_task(dummy_task())
+ api_instance.window_close_timers[device_id] = task
+
+ # Cancel timer for one device
+ api_instance._cancel_window_close_timer("dev-1")
+
+ # Give cancellation time to propagate
+ await asyncio.sleep(0.1)
+
+ # Only dev-1 should be removed
+ assert "dev-1" not in api_instance.window_close_timers
+ assert "dev-2" in api_instance.window_close_timers
+ assert "dev-3" in api_instance.window_close_timers
+
+
+class TestTadoLocalAPIWindowCloseHandler:
+ @pytest.mark.asyncio
+ async def test_window_close_handler_waits_and_closes_window(self, api_instance):
+ """Test that _window_close_handler waits for delay and closes window."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living"}
+ closing_delay = 1 # 1 minute = 60 seconds
+
+ api_instance.state_manager.get_current_state.return_value = {"window": 1}
+
+ with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, \
+ patch("time.time", return_value=1000):
+ await api_instance._window_close_handler(device_id, device_info, closing_delay)
+
+ # Should sleep for 60 seconds (1 minute * 60)
+ mock_sleep.assert_called_once_with(60)
+ # Should update window status
+ api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 2)
+
+ @pytest.mark.asyncio
+ async def test_window_close_handler_returns_early_if_shutting_down(self, api_instance):
+ """Test that handler returns early if is_shutting_down is True."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.is_shutting_down = True
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living"}
+
+ with patch("asyncio.sleep", new_callable=AsyncMock):
+ await api_instance._window_close_handler(device_id, device_info, 1)
+
+ # Should not update window status
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_window_close_handler_does_not_close_if_window_already_closed(self, api_instance):
+ """Test that handler does not close window if it's already closed."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living"}
+
+ # Window is already closed (0)
+ api_instance.state_manager.get_current_state.return_value = {"window": 0}
+
+ with patch("asyncio.sleep", new_callable=AsyncMock):
+ await api_instance._window_close_handler(device_id, device_info, 1)
+
+ # Should not update window status
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_window_close_handler_returns_if_no_current_state(self, api_instance):
+ """Test that handler returns gracefully if no current state exists."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living"}
+
+ api_instance.state_manager.get_current_state.return_value = None
+
+ with patch("asyncio.sleep", new_callable=AsyncMock):
+ await api_instance._window_close_handler(device_id, device_info, 1)
+
+ # Should not update window status
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_window_close_handler_handles_cancellation(self, api_instance):
+ """Test that handler gracefully handles CancelledError."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living"}
+
+ async def mock_sleep_cancelled(duration):
+ raise asyncio.CancelledError()
+
+ with patch("asyncio.sleep", side_effect=mock_sleep_cancelled):
+ await api_instance._window_close_handler(device_id, device_info, 1)
+
+ # Should not update window status (task was cancelled)
+ api_instance.state_manager.update_device_window_status.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_window_close_handler_handles_exception(self, api_instance):
+ """Test that handler catches and logs exceptions."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living"}
+
+ api_instance.state_manager.get_current_state.side_effect = Exception("Connection error")
+
+ with patch("asyncio.sleep", new_callable=AsyncMock):
+ # Should not raise
+ await api_instance._window_close_handler(device_id, device_info, 1)
+
+ @pytest.mark.asyncio
+ async def test_window_close_handler_respects_closing_delay(self, api_instance):
+ """Test that handler uses correct closing delay."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+ device_info = {"zone_name": "Living"}
+
+ api_instance.state_manager.get_current_state.return_value = {"window": 1}
+
+ closing_delay = 30 # 30 minutes
+
+ with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
+ await api_instance._window_close_handler(device_id, device_info, closing_delay)
+
+ # Should sleep for 1800 seconds (30 minutes * 60)
+ mock_sleep.assert_called_once_with(1800)
+
+
+class TestTadoLocalAPIWindowCloseTimerStop:
+ def test_window_close_timer_stop_removes_task(self, api_instance):
+ """Test that _window_close_timer_stop removes the task from dict."""
+ api_instance.window_close_timers = {}
+ device_id = "dev-1"
+
+ # Create a mock task
+ mock_task = Mock(spec=asyncio.Task)
+ api_instance.window_close_timers[device_id] = mock_task
+
+ api_instance._window_close_timer_stop(device_id, mock_task)
+
+ # Task should be removed
+ assert device_id not in api_instance.window_close_timers
+
+ def test_window_close_timer_stop_noop_when_task_mismatch(self, api_instance):
+ """Test that _window_close_timer_stop does nothing if task doesn't match."""
+ api_instance.window_close_timers = {}
+ device_id = "dev-1"
+
+ # Create two different mock tasks
+ mock_task1 = Mock(spec=asyncio.Task)
+ mock_task2 = Mock(spec=asyncio.Task)
+ api_instance.window_close_timers[device_id] = mock_task1
+
+ # Call with different task
+ api_instance._window_close_timer_stop(device_id, mock_task2)
+
+ # Task should still be in dict (not removed)
+ assert device_id in api_instance.window_close_timers
+ assert api_instance.window_close_timers[device_id] is mock_task1
+
+ def test_window_close_timer_stop_noop_when_no_timer(self, api_instance):
+ """Test that _window_close_timer_stop handles missing timer gracefully."""
+ api_instance.window_close_timers = {}
+ device_id = "dev-1"
+
+ mock_task = Mock(spec=asyncio.Task)
+
+ # Should not raise exception
+ api_instance._window_close_timer_stop(device_id, mock_task)
+
+ # Dict should remain empty
+ assert device_id not in api_instance.window_close_timers
+
+ def test_window_close_timer_stop_multiple_devices(self, api_instance):
+ """Test cleanup of timers for multiple devices independently."""
+ api_instance.window_close_timers = {}
+
+ # Setup multiple devices with tasks
+ mock_task1 = Mock(spec=asyncio.Task)
+ mock_task2 = Mock(spec=asyncio.Task)
+ mock_task3 = Mock(spec=asyncio.Task)
+
+ api_instance.window_close_timers["dev-1"] = mock_task1
+ api_instance.window_close_timers["dev-2"] = mock_task2
+ api_instance.window_close_timers["dev-3"] = mock_task3
+
+ # Stop timer for dev-1
+ api_instance._window_close_timer_stop("dev-1", mock_task1)
+
+ # Only dev-1 should be removed
+ assert "dev-1" not in api_instance.window_close_timers
+ assert "dev-2" in api_instance.window_close_timers
+ assert "dev-3" in api_instance.window_close_timers
+
+ @pytest.mark.asyncio
+ async def test_window_close_timer_stop_called_as_done_callback(self, api_instance):
+ """Test that _window_close_timer_stop is registered and called as done callback."""
+ api_instance.window_close_timers = {}
+ api_instance.is_shutting_down = False
+ device_id = "dev-1"
+
+ async def dummy_handler():
+ pass
+
+ # Create a real task
+ task = asyncio.create_task(dummy_handler())
+ api_instance.window_close_timers[device_id] = task
+
+ # Add done callback
+ task.add_done_callback(lambda t: api_instance._window_close_timer_stop(device_id, t))
+
+ # Wait for task to complete
+ await task
+ await asyncio.sleep(0.1) # Give callback time to execute
+
+ # Task should be cleaned up
+ assert device_id not in api_instance.window_close_timers
+
+class TestTadoLocalAPIBroadcastEvent:
+ @pytest.mark.asyncio
+ async def test_broadcast_event_sends_to_all_listeners(self, api_instance):
+ """Test that broadcast_event sends to all connected listeners."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ # Create mock listeners
+ mock_listener1 = AsyncMock()
+ mock_listener2 = AsyncMock()
+ api_instance.event_listeners = [mock_listener1, mock_listener2]
+
+ event_data = {"type": "device", "device": "dev-1", "value": 22.5}
+
+ await api_instance.broadcast_event(event_data)
+
+ # Both listeners should receive the event
+ mock_listener1.put.assert_called_once()
+ mock_listener2.put.assert_called_once()
+
+ # Verify the message format
+ call_args = mock_listener1.put.call_args[0][0]
+ assert "data: " in call_args
+ assert "dev-1" in call_args
+ assert "22.5" in call_args
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event_zone_events_to_both_listener_types(self, api_instance):
+ """Test that zone events go to both all-events and zone-only listeners."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ mock_all_listener = AsyncMock()
+ mock_zone_listener = AsyncMock()
+ api_instance.event_listeners = [mock_all_listener]
+ api_instance.zone_event_listeners = [mock_zone_listener]
+
+ event_data = {"type": "zone", "zone": "Living", "target_temp": 21.0}
+
+ await api_instance.broadcast_event(event_data)
+
+ # Both listener types should receive zone events
+ mock_all_listener.put.assert_called_once()
+ mock_zone_listener.put.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event_device_events_only_to_all_listeners(self, api_instance):
+ """Test that device events only go to all-events listeners."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ mock_all_listener = AsyncMock()
+ mock_zone_listener = AsyncMock()
+ api_instance.event_listeners = [mock_all_listener]
+ api_instance.zone_event_listeners = [mock_zone_listener]
+
+ event_data = {"type": "device", "device": "dev-1", "value": 22.5}
+
+ await api_instance.broadcast_event(event_data)
+
+ # Only all-events listener should receive device events
+ mock_all_listener.put.assert_called_once()
+ mock_zone_listener.put.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event_removes_disconnected_listeners(self, api_instance):
+ """Test that disconnected listeners are removed."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ mock_listener1 = AsyncMock()
+ mock_listener2 = AsyncMock()
+
+ # Listener 1 fails, listener 2 succeeds
+ mock_listener1.put.side_effect = Exception("Disconnected")
+ mock_listener2.put.return_value = None
+
+ api_instance.event_listeners = [mock_listener1, mock_listener2]
+
+ event_data = {"type": "device", "device": "dev-1"}
+
+ await api_instance.broadcast_event(event_data)
+
+ # Disconnected listener should be removed
+ assert mock_listener1 not in api_instance.event_listeners
+ assert mock_listener2 in api_instance.event_listeners
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event_removes_from_both_lists(self, api_instance):
+ """Test that disconnected listeners are removed from both listener lists."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ mock_listener = AsyncMock()
+ mock_listener.put.side_effect = Exception("Disconnected")
+
+ # Add to both lists
+ api_instance.event_listeners = [mock_listener]
+ api_instance.zone_event_listeners = [mock_listener]
+
+ event_data = {"type": "zone", "zone": "Living"}
+
+ await api_instance.broadcast_event(event_data)
+
+ # Should be removed from both lists
+ assert mock_listener not in api_instance.event_listeners
+ assert mock_listener not in api_instance.zone_event_listeners
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event_handles_broadcast_exception(self, api_instance):
+ """Test that broadcast_event handles exceptions gracefully."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ with patch("json.dumps", side_effect=Exception("JSON error")):
+ # Should not raise
+ await api_instance.broadcast_event({"type": "device"})
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event_json_serialization(self, api_instance):
+ """Test that event data is properly JSON serialized."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ mock_listener = AsyncMock()
+ api_instance.event_listeners = [mock_listener]
+
+ event_data = {
+ "type": "device",
+ "device": "dev-1",
+ "value": 22.5,
+ "timestamp": 1234567890
+ }
+
+ await api_instance.broadcast_event(event_data)
+
+ # Get the sent message
+ sent_message = mock_listener.put.call_args[0][0]
+
+ # Verify SSE format
+ assert sent_message.startswith("data: ")
+ assert sent_message.endswith("\n\n")
+
+ # Extract and verify JSON
+ json_str = sent_message.replace("data: ", "").strip()
+ parsed = json.loads(json_str)
+ assert parsed["type"] == "device"
+ assert parsed["device"] == "dev-1"
+ assert parsed["value"] == 22.5
+
+ @pytest.mark.asyncio
+ async def test_broadcast_event_to_empty_listeners(self, api_instance):
+ """Test broadcast_event with no listeners."""
+ api_instance.event_listeners = []
+ api_instance.zone_event_listeners = []
+
+ event_data = {"type": "device", "device": "dev-1"}
+
+ # Should not raise
+ await api_instance.broadcast_event(event_data)
+
+
+class TestTadoLocalAPICelsiusToFahrenheit:
+ def test_celsius_to_fahrenheit_valid_temperature(self, api_instance):
+ """Test conversion of valid Celsius temperature."""
+ result = api_instance._celsius_to_fahrenheit(0)
+ assert result == 32.0
+
+ result = api_instance._celsius_to_fahrenheit(100)
+ assert result == 212.0
+
+ result = api_instance._celsius_to_fahrenheit(20)
+ assert result == 68.0
+
+ def test_celsius_to_fahrenheit_negative_temperature(self, api_instance):
+ """Test conversion of negative Celsius temperatures."""
+ result = api_instance._celsius_to_fahrenheit(-40)
+ assert result == -40.0
+
+ def test_celsius_to_fahrenheit_decimal_temperature(self, api_instance):
+ """Test conversion with decimal Celsius values."""
+ result = api_instance._celsius_to_fahrenheit(22.5)
+ assert result == 72.5
+
+ def test_celsius_to_fahrenheit_returns_none_for_none_input(self, api_instance):
+ """Test that None input returns None."""
+ result = api_instance._celsius_to_fahrenheit(None)
+ assert result is None
+
+ def test_celsius_to_fahrenheit_rounding(self, api_instance):
+ """Test that result is rounded to 1 decimal place."""
+ result = api_instance._celsius_to_fahrenheit(20.123)
+ assert result == 68.2 # 20.123 * 9/5 + 32 = 68.2214, rounded to 68.2
+
+class TestTadoLocalAPIBuildDeviceState:
+ def test_build_device_state_with_valid_data(self, api_instance):
+ """Test building device state with valid temperature and state data."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ 'humidity': 45,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 1,
+ 'valve_position': 75,
+ 'window': 0
+ }
+ api_instance.state_manager.device_info_cache = {
+ 'dev-1': {'battery_state': 'NORMAL'}
+ }
+
+ result = api_instance._build_device_state('dev-1')
+
+ assert result['cur_temp_c'] == 20.0
+ assert result['cur_temp_f'] == 68.0
+ assert result['target_temp_c'] == 21.5
+ assert result['target_temp_f'] == 70.7
+ assert result['hum_perc'] == 45
+ assert result['mode'] == 1
+ assert result['cur_heating'] == 1
+ assert result['valve_position'] == 75
+ assert result['battery_low'] is False
+ assert result['window'] == 0
+
+ def test_build_device_state_battery_low(self, api_instance):
+ """Test that battery_low is True when battery_state is not NORMAL."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ }
+ api_instance.state_manager.device_info_cache = {
+ 'dev-1': {'battery_state': 'LOW'}
+ }
+
+ result = api_instance._build_device_state('dev-1')
+
+ assert result['battery_low'] is True
+
+ def test_build_device_state_battery_unknown(self, api_instance):
+ """Test that battery_low is True when battery_state is UNKNOWN."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ }
+ api_instance.state_manager.device_info_cache = {
+ 'dev-1': {'battery_state': 'UNKNOWN'}
+ }
+
+ result = api_instance._build_device_state('dev-1')
+
+ assert result['battery_low'] is True
+
+ def test_build_device_state_no_battery_info(self, api_instance):
+ """Test that battery_low is False when no battery info available."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ }
+ api_instance.state_manager.device_info_cache = {
+ 'dev-1': {}
+ }
+
+ result = api_instance._build_device_state('dev-1')
+
+ assert result['battery_low'] is False
+
+ def test_build_device_state_none_temperatures(self, api_instance):
+ """Test handling of None temperatures."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': None,
+ 'target_temperature': None,
+ 'humidity': 45,
+ }
+ api_instance.state_manager.device_info_cache = {'dev-1': {}}
+
+ result = api_instance._build_device_state('dev-1')
+
+ assert result['cur_temp_c'] is None
+ assert result['cur_temp_f'] is None
+ assert result['target_temp_c'] is None
+ assert result['target_temp_f'] is None
+
+ def test_build_device_state_heating_off(self, api_instance):
+ """Test that cur_heating is 0 when not heating."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_heating_cooling_state': 0,
+ 'current_temperature': 20.0,
+ }
+ api_instance.state_manager.device_info_cache = {'dev-1': {}}
+
+ result = api_instance._build_device_state('dev-1')
+
+ assert result['cur_heating'] == 0
+
+ def test_build_device_state_default_mode(self, api_instance):
+ """Test default mode value when not specified."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ }
+ api_instance.state_manager.device_info_cache = {'dev-1': {}}
+
+ result = api_instance._build_device_state('dev-1')
+
+ assert result['mode'] == 0
+
+
+class TestTadoLocalAPIBroadcastStateChange:
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_sends_device_event(self, api_instance):
+ """Test that broadcast_state_change sends device event."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {
+ 'serial_number': 'ABC123',
+ 'zone_id': None,
+ }
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ }
+ api_instance.state_manager.device_info_cache = {'dev-1': {}}
+ api_instance.broadcast_event = AsyncMock()
+ api_instance.last_zone_states = {}
+
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ api_instance.broadcast_event.assert_called_once()
+ call_args = api_instance.broadcast_event.call_args[0][0]
+ assert call_args['type'] == 'device'
+ assert call_args['device_id'] == 'dev-1'
+ assert call_args['serial'] == 'ABC123'
+ assert call_args['zone_name'] == 'Living'
+
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_returns_early_no_device_info(self, api_instance):
+ """Test that broadcast_state_change returns early if device info not found."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = None
+ api_instance.broadcast_event = AsyncMock()
+
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ api_instance.broadcast_event.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_includes_zone_event(self, api_instance):
+ """Test that zone state event is broadcast when device is in zone."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {
+ 'serial_number': 'ABC123',
+ 'zone_id': 'zone-1',
+ }
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 1,
+ 'window': 0,
+ }
+ api_instance.state_manager.device_info_cache = {'dev-1': {}}
+ api_instance.state_manager.zone_cache = {
+ 'zone-1': {
+ 'name': 'Living',
+ 'leader_device_id': 'dev-1',
+ 'is_circuit_driver': False,
+ }
+ }
+ api_instance.broadcast_event = AsyncMock()
+ api_instance.last_zone_states = {}
+
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ # Should broadcast both device and zone events
+ assert api_instance.broadcast_event.call_count == 2
+
+ device_call = api_instance.broadcast_event.call_args_list[0][0][0]
+ zone_call = api_instance.broadcast_event.call_args_list[1][0][0]
+
+ assert device_call['type'] == 'device'
+ assert zone_call['type'] == 'zone'
+ assert zone_call['zone_id'] == 'zone-1'
+
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_zone_not_changed(self, api_instance):
+ """Test that zone event is not broadcast if state hasn't changed."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {
+ 'serial_number': 'ABC123',
+ 'zone_id': 'zone-1',
+ }
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 1,
+ 'window': 0,
+ }
+ api_instance.state_manager.device_info_cache = {'dev-1': {}}
+ api_instance.state_manager.zone_cache = {
+ 'zone-1': {
+ 'name': 'Living',
+ 'leader_device_id': 'dev-1',
+ 'is_circuit_driver': False,
+ }
+ }
+ api_instance.broadcast_event = AsyncMock()
+
+ # Set last_zone_states to current state (no change)
+ zone_state = {
+ 'cur_temp_c': 20.0,
+ 'cur_temp_f': 68.0,
+ 'hum_perc': None,
+ 'target_temp_c': 21.5,
+ 'target_temp_f': 70.7,
+ 'mode': 1,
+ 'cur_heating': 1,
+ 'window_open': False,
+ }
+ api_instance.last_zone_states = {'zone-1': zone_state}
+
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ # Only device event should be broadcast
+ assert api_instance.broadcast_event.call_count == 1
+ call_args = api_instance.broadcast_event.call_args[0][0]
+ assert call_args['type'] == 'device'
+
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_circuit_driver_logic(self, api_instance):
+ """Test zone state calculation with circuit driver."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {
+ 'serial_number': 'ABC123',
+ 'zone_id': 'zone-1',
+ }
+ api_instance.state_manager.get_current_state.side_effect = [
+ # Device state
+ {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 0,
+ 'window': 0,
+ },
+ # Leader state (circuit driver)
+ {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 0,
+ 'window': 0,
+ },
+ # Radiator valve state
+ {
+ 'current_temperature': 19.5,
+ 'target_temperature': 21.5,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 1,
+ 'window': 0,
+ }
+ ]
+ api_instance.state_manager.device_info_cache = {
+ 'dev-1': {'zone_id': 'zone-1', 'is_circuit_driver': True},
+ 'dev-2': {'zone_id': 'zone-1', 'is_circuit_driver': False}
+ }
+ api_instance.state_manager.zone_cache = {
+ 'zone-1': {
+ 'name': 'Living',
+ 'leader_device_id': 'dev-1',
+ 'is_circuit_driver': True,
+ }
+ }
+ api_instance.broadcast_event = AsyncMock()
+ api_instance.last_zone_states = {}
+
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ # Should broadcast both device and zone events
+ assert api_instance.broadcast_event.call_count == 2
+ zone_call = api_instance.broadcast_event.call_args_list[1][0][0]
+ # Zone cur_heating should be 1 (from radiator valve)
+ assert zone_call['state']['cur_heating'] == 1
+
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_circuit_driver_logic_other(self, api_instance):
+ """Test zone state calculation with circuit driver."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {
+ 'serial_number': 'ABC123',
+ 'zone_id': 'zone-1',
+ }
+ api_instance.state_manager.get_current_state.side_effect = [
+ # Device state
+ {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 0,
+ 'window': 0,
+ },
+ # Leader state (circuit driver)
+ {
+ 'current_temperature': 20.0,
+ 'target_temperature': 21.5,
+ 'target_heating_cooling_state': 1,
+ 'current_heating_cooling_state': 0,
+ 'window': 0,
+ }
+ ]
+ api_instance.state_manager.device_info_cache = {
+ 'dev-1': {'zone_id': 'zone-1', 'is_circuit_driver': True}
+ }
+ api_instance.state_manager.zone_cache = {
+ 'zone-1': {
+ 'name': 'Living',
+ 'leader_device_id': 'dev-1',
+ 'is_circuit_driver': True,
+ }
+ }
+ api_instance.broadcast_event = AsyncMock()
+ api_instance.last_zone_states = {}
+
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ # Should broadcast both device and zone events
+ assert api_instance.broadcast_event.call_count == 2
+ zone_call = api_instance.broadcast_event.call_args_list[1][0][0]
+ # Zone cur_heating should be 0 (from circuit driver)
+ assert zone_call['state']['cur_heating'] == 0
+
+
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_handles_exception(self, api_instance):
+ """Test that exception during broadcast is handled gracefully."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.side_effect = Exception("DB error")
+
+ # Should not raise
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ @pytest.mark.asyncio
+ async def test_broadcast_state_change_timestamp_included(self, api_instance):
+ """Test that timestamp is included in broadcast events."""
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {
+ 'serial_number': 'ABC123',
+ 'zone_id': None,
+ }
+ api_instance.state_manager.get_current_state.return_value = {
+ 'current_temperature': 20.0,
+ }
+ api_instance.state_manager.device_info_cache = {'dev-1': {}}
+ api_instance.broadcast_event = AsyncMock()
+ api_instance.last_zone_states = {}
+
+ with patch("time.time", return_value=1234567890.0):
+ await api_instance.broadcast_state_change('dev-1', 'Living')
+
+ call_args = api_instance.broadcast_event.call_args[0][0]
+ assert call_args['timestamp'] == 1234567890.0
+
+class TestTadoLocalAPIPollingSystem:
+ @pytest.mark.asyncio
+ async def test_setup_polling_system_collects_chars_and_starts_task(self, api_instance):
+ api_instance.accessories_cache = [
+ {
+ "aid": 1,
+ "services": [
+ {"characteristics": [
+ {"iid": 10, "perms": ["ev", "pr"]}, # include
+ {"iid": 11, "perms": ["pr"]}, # skip
+ {"iid": 12, "perms": ["ev", "pr"]}, # include
+ ]}
+ ],
+ }
+ ]
+ api_instance.background_tasks = []
+
+ fake_task = Mock()
+
+ def _fake_create_task(coro):
+ # consume coroutine to avoid: "coroutine ... was never awaited"
+ coro.close()
+ return fake_task
+
+ with patch("asyncio.create_task", side_effect=_fake_create_task) as mock_create_task:
+ await api_instance.setup_polling_system()
+
+ assert api_instance.poll_chars == [(1, 10), (1, 12)]
+ assert api_instance.monitored_characteristics == [(1, 10), (1, 12)]
+ mock_create_task.assert_called_once()
+ assert fake_task in api_instance.background_tasks
+
+ @pytest.mark.asyncio
+ async def test_setup_polling_system_no_matching_chars(self, api_instance):
+ api_instance.accessories_cache = [
+ {
+ "aid": 1,
+ "services": [{"characteristics": [{"iid": 10, "perms": ["pr"]}]}],
+ }
+ ]
+ api_instance.background_tasks = []
+
+ with patch("asyncio.create_task") as mock_create_task:
+ await api_instance.setup_polling_system()
+
+ assert api_instance.poll_chars == []
+ mock_create_task.assert_not_called()
+ assert api_instance.background_tasks == []
+
+ @pytest.mark.asyncio
+ async def test_setup_polling_system_handles_exception(self, api_instance):
+ # malformed data -> KeyError on accessory["services"]
+ api_instance.accessories_cache = [{"aid": 1}]
+ api_instance.background_tasks = []
+
+ with patch("asyncio.create_task") as mock_create_task:
+ await api_instance.setup_polling_system()
+
+ mock_create_task.assert_not_called()
+
+
+class TestTadoLocalAPIBackgroundPollingLoop:
+ @pytest.mark.asyncio
+ async def test_background_polling_loop_runs_fast_and_slow_poll(self, api_instance):
+ api_instance.is_shutting_down = False
+ api_instance.pairing = True
+ api_instance.monitored_characteristics = [(1, 10), (1, 11)]
+ api_instance.characteristic_map[(1, 10)] = "CurrentHumidity" # priority
+ api_instance.characteristic_map[(1, 11)] = "CurrentTemperature"
+ api_instance._poll_characteristics = AsyncMock()
+
+ async def sleep_once(_seconds):
+ api_instance.is_shutting_down = True
+
+ with patch("asyncio.sleep", side_effect=sleep_once), \
+ patch("time.time", return_value=130):
+ await api_instance.background_polling_loop()
+
+ # FAST-POLL should be called for humidity char
+ api_instance._poll_characteristics.assert_any_call([(1, 10)], "FAST-POLL")
+ # POLLING should be called for all monitored chars
+ api_instance._poll_characteristics.assert_any_call([(1, 10), (1, 11)], "POLLING")
+
+ @pytest.mark.asyncio
+ async def test_background_polling_loop_skips_when_not_paired(self, api_instance):
+ api_instance.is_shutting_down = False
+ api_instance.pairing = False
+ api_instance.monitored_characteristics = [(1, 10)]
+ api_instance.characteristic_map[(1, 10)] = "CurrentHumidity"
+ api_instance._poll_characteristics = AsyncMock()
+
+ async def sleep_once(_seconds):
+ api_instance.is_shutting_down = True
+
+ with patch("asyncio.sleep", side_effect=sleep_once), \
+ patch("time.time", return_value=130):
+ await api_instance.background_polling_loop()
+
+ api_instance._poll_characteristics.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_background_polling_loop_retries_after_error(self, api_instance):
+ api_instance.is_shutting_down = False
+ api_instance.pairing = True
+ api_instance.monitored_characteristics = [(1, 11)]
+ api_instance.characteristic_map[(1, 11)] = "CurrentTemperature"
+ api_instance._poll_characteristics = AsyncMock(side_effect=Exception("poll failed"))
+
+ sleep_calls = []
+
+ async def sleep_side_effect(seconds):
+ sleep_calls.append(seconds)
+ # first sleep is loop tick (10), second should be retry delay (5)
+ if seconds == 5:
+ api_instance.is_shutting_down = True
+
+ with patch("asyncio.sleep", side_effect=sleep_side_effect), \
+ patch("time.time", return_value=130):
+ await api_instance.background_polling_loop()
+
+ assert 10 in sleep_calls
+ assert 5 in sleep_calls
+
+class TestTadoLocalAPIPollCharacteristics:
+ @pytest.mark.asyncio
+ async def test_poll_characteristics_batches_and_calls_handle_change(self, api_instance):
+ """Validates batching + unified handle_change calls."""
+ char_list = [(1, i) for i in range(1, 18)] # 17 -> 2 batches (15 + 2)
+
+ async def get_chars(batch):
+ return {(aid, iid): {"value": iid * 1.0} for aid, iid in batch}
+
+ api_instance.pairing = Mock()
+ api_instance.pairing.get_characteristics = AsyncMock(side_effect=get_chars)
+ api_instance.handle_change = AsyncMock()
+
+ await api_instance._poll_characteristics(char_list, source="FAST-POLL")
+
+ assert api_instance.pairing.get_characteristics.await_count == 2
+ assert api_instance.handle_change.await_count == 17
+ api_instance.handle_change.assert_any_await(1, 1, {"value": 1.0}, "FAST-POLL")
+ api_instance.handle_change.assert_any_await(1, 17, {"value": 17.0}, "FAST-POLL")
+
+ @pytest.mark.asyncio
+ async def test_poll_characteristics_skips_missing_results(self, api_instance):
+ """Only returned keys should be forwarded to handle_change."""
+ char_list = [(1, 1), (1, 2), (1, 3)]
+
+ api_instance.pairing = Mock()
+ api_instance.pairing.get_characteristics = AsyncMock(
+ return_value={(1, 1): {"value": 10}, (1, 3): {"value": 30}}
+ )
+ api_instance.handle_change = AsyncMock()
+
+ await api_instance._poll_characteristics(char_list)
+
+ assert api_instance.handle_change.await_count == 2
+ api_instance.handle_change.assert_any_await(1, 1, {"value": 10}, "POLLING")
+ api_instance.handle_change.assert_any_await(1, 3, {"value": 30}, "POLLING")
+
+ @pytest.mark.asyncio
+ async def test_poll_characteristics_continues_after_batch_error(self, api_instance):
+ """If one batch fails, next batches should still run."""
+ char_list = [(1, i) for i in range(1, 21)] # 20 -> 2 batches
+
+ second_batch = {(1, i): {"value": i} for i in range(16, 21)}
+ api_instance.pairing = Mock()
+ api_instance.pairing.get_characteristics = AsyncMock(
+ side_effect=[Exception("batch fail"), second_batch]
+ )
+ api_instance.handle_change = AsyncMock()
+
+ await api_instance._poll_characteristics(char_list)
+
+ # Only second batch processed (5 entries)
+ assert api_instance.handle_change.await_count == 5
+ api_instance.handle_change.assert_any_await(1, 16, {"value": 16}, "POLLING")
+
+
+class TestTadoLocalAPIHandleHomekitEvent:
+ @pytest.mark.asyncio
+ async def test_handle_homekit_event_updates_state_and_notifies_listeners(self, api_instance):
+ api_instance.device_states = defaultdict(dict)
+ q1 = AsyncMock()
+ q2 = AsyncMock()
+ api_instance.event_listeners = [q1, q2]
+
+ event = {"aid": 1, "iid": 10, "value": 22.5}
+ await api_instance.handle_homekit_event(event)
+
+ assert api_instance.device_states["1"]["10"]["value"] == 22.5
+ assert "timestamp" in api_instance.device_states["1"]["10"]
+ q1.put.assert_awaited_once_with(event)
+ q2.put.assert_awaited_once_with(event)
+
+ @pytest.mark.asyncio
+ async def test_handle_homekit_event_ignores_invalid_payload(self, api_instance):
+ api_instance.device_states = defaultdict(dict)
+ api_instance.event_listeners = [AsyncMock()]
+
+ await api_instance.handle_homekit_event({"aid": 1, "iid": 10, "value": None})
+
+ assert api_instance.device_states == {}
+
+ @pytest.mark.asyncio
+ async def test_handle_homekit_event_queue_error_is_swallowed(self, api_instance):
+ api_instance.device_states = defaultdict(dict)
+ bad_q = AsyncMock()
+ good_q = AsyncMock()
+ bad_q.put.side_effect = Exception("queue closed")
+ api_instance.event_listeners = [bad_q, good_q]
+
+ event = {"aid": 1, "iid": 10, "value": 19.0}
+ await api_instance.handle_homekit_event(event)
+
+ # State still updated and other listeners still notified
+ assert api_instance.device_states["1"]["10"]["value"] == 19.0
+ good_q.put.assert_awaited_once_with(event)
+
+ @pytest.mark.asyncio
+ async def test_handle_homekit_event_handles_unexpected_exception(self, api_instance):
+ """No exception should escape."""
+ api_instance.device_states = None # will trigger exception on assignment
+ api_instance.event_listeners = []
+
+ await api_instance.handle_homekit_event({"aid": 1, "iid": 10, "value": 20.0})
+
+
+class TestTadoLocalAPISetDeviceCharacteristics:
+ @pytest.mark.asyncio
+ async def test_set_device_char_raises_when_not_connected(self, api_instance):
+ api_instance.pairing = None
+
+ with pytest.raises(ValueError, match="Bridge not connected"):
+ await api_instance.set_device_characteristics(1, {"target_temperature": 21.0})
+
+ @pytest.mark.asyncio
+ async def test_set_device_char_raises_when_device_not_found(self, api_instance):
+ api_instance.pairing = Mock()
+ api_instance.state_manager = Mock()
+ api_instance.state_manager.get_device_info = Mock(return_value=None)
+
+ with pytest.raises(ValueError, match="Device 1 not found"):
+ await api_instance.set_device_characteristics(1, {"target_temperature": 21.0})
+
+ @pytest.mark.asyncio
+ async def test_set_device_char_reloads_cache_and_raises_if_no_aid(self, api_instance):
+ api_instance.pairing = Mock()
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.side_effect = [
+ {"id": 1, "aid": None},
+ {"id": 1, "aid": None},
+ ]
+ api_instance.state_manager._load_device_cache = Mock()
+ api_instance.accessories_cache = []
+
+ with pytest.raises(ValueError, match="has no HomeKit accessory ID"):
+ await api_instance.set_device_characteristics(1, {"target_temperature": 21.0})
+
+ api_instance.state_manager._load_device_cache.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_set_device_char_raises_when_no_valid_chars(self, api_instance):
+ api_instance.pairing = Mock()
+ api_instance.pairing.put_characteristics = AsyncMock()
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {"id": 1, "aid": 100}
+ api_instance.accessories_cache = [
+ {"aid": 100, "services": [{"characteristics": [{"iid": 10, "type": "some-other-type"}]}]}
+ ]
+
+ with pytest.raises(ValueError, match="No valid characteristics to set"):
+ await api_instance.set_device_characteristics(1, {"target_temperature": 21.0})
+
+ api_instance.pairing.put_characteristics.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_set_device_char_success_single_characteristic(self, api_instance):
+ from tado_local.state import DeviceStateManager
+
+ api_instance.pairing = Mock()
+ api_instance.pairing.put_characteristics = AsyncMock()
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {"id": 1, "aid": 100}
+ api_instance.accessories_cache = [
+ {
+ "aid": 100,
+ "services": [
+ {"characteristics": [
+ {"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE}
+ ]}
+ ],
+ }
+ ]
+
+ ok = await api_instance.set_device_characteristics(1, {"target_temperature": 21.0})
+
+ assert ok is True
+ api_instance.pairing.put_characteristics.assert_awaited_once_with([(100, 10, 21.0)])
+
+ @pytest.mark.asyncio
+ async def test_set_device_char_success_multiple_characteristics(self, api_instance):
+ from tado_local.state import DeviceStateManager
+
+ api_instance.pairing = Mock()
+ api_instance.pairing.put_characteristics = AsyncMock()
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {"id": 1, "aid": 100}
+ api_instance.accessories_cache = [
+ {
+ "aid": 100,
+ "services": [
+ {"characteristics": [
+ {"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE},
+ {"iid": 11, "type": DeviceStateManager.CHAR_TARGET_HEATING_COOLING},
+ ]}
+ ],
+ }
+ ]
+
+ ok = await api_instance.set_device_characteristics(
+ 1,
+ {
+ "target_temperature": 20.5,
+ "target_heating_cooling_state": 1,
+ },
+ )
+
+ assert ok is True
+ api_instance.pairing.put_characteristics.assert_awaited_once_with(
+ [(100, 10, 20.5), (100, 11, 1)]
+ )
+
+ @pytest.mark.asyncio
+ async def test_set_device_char_ignores_unknown_char_and_sets_valid(self, api_instance):
+ from tado_local.state import DeviceStateManager
+
+ api_instance.pairing = Mock()
+ api_instance.pairing.put_characteristics = AsyncMock()
+ api_instance.state_manager = Mock(spec=DeviceStateManager)
+ api_instance.state_manager.get_device_info.return_value = {"id": 1, "aid": 100}
+ api_instance.accessories_cache = [
+ {
+ "aid": 100,
+ "services": [
+ {"characteristics": [
+ {"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE}
+ ]}
+ ],
+ }
+ ]
+
+ ok = await api_instance.set_device_characteristics(
+ 1,
+ {
+ "unknown_char": 123,
+ "target_temperature": 19.0,
+ },
+ )
+
+ assert ok is True
+ api_instance.pairing.put_characteristics.assert_awaited_once_with([(100, 10, 19.0)])
diff --git a/tests/test_migration.py b/tests/test_migration.py
index df95734..737c3fd 100644
--- a/tests/test_migration.py
+++ b/tests/test_migration.py
@@ -32,7 +32,7 @@ def test_migration_adds_uuid_and_sets_user_version(tmp_path):
conn = sqlite3.connect(db_file)
cur = conn.execute("PRAGMA user_version")
ver = cur.fetchone()[0]
- assert ver == 2
+ assert ver == 3
# Check uuid column exists and populated
cur = conn.execute("PRAGMA table_info(zones)")
diff --git a/tests/test_routes.py b/tests/test_routes.py
index c9ca8c1..665c0d8 100644
--- a/tests/test_routes.py
+++ b/tests/test_routes.py
@@ -22,21 +22,21 @@ def test_db():
# Insert sample data
cursor.execute("INSERT INTO tado_homes (tado_home_id, name, timezone, temperature_unit) VALUES (1, 'My Home', 'Europe/Amsterdam', 'CELSIUS')")
- cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id) VALUES (100, 1, 'Living Room', 'HEATING', 1, 1)")
- cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id) VALUES (101, 1, 'Bedroom', 'HOT_WATER', 2, 2)")
- cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id) VALUES (102, 1, 'Kitchen', 'HEATING', NULL, 3)")
-
- cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader) VALUES ('SN001', 1, 1, 100, 'thermostat', 'Living Room Thermostat', 'RU01', 'Tado', '1.45', 'NORMAL', 1)")
- cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader) VALUES ('SN002', 2, 2, 101, 'bridge', 'Bedroom Bridge', 'RU01', 'Tado', '1.45', 'NORMAL', 1)")
- cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader) VALUES ('SN003', 3, 3, 102, 'thermostat', 'Kitchen Thermostat', 'RB01', 'Tado', '2.10', 'LOW', 0)")
- cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader) VALUES ('SN004', 4, 1, 100, 'radiator_thermostat', 'Living Room Radiator', 'RV01', 'Tado', '1.20', 'LOW', 0)")
-
- cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level) VALUES (1, '20260129100000', 21.5, 20.0, 1, 1, 45, 100)")
- cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level) VALUES (2, '20260129100500', 19.0, 18.0, 1, 1, 50, 95)")
- cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level) VALUES (3, '20260129100700', 22.0, 21.0, 0, 0, 40, 100)")
- cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level) VALUES (2, '20260129100700', 19.5, 18.5, 0, 1, 60, 95)")
- cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level) VALUES (4, '20260129101000', 20.5, 20.0, 1, 0, 45, 60)")
- cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level) VALUES (2, '20260129110500', 22.0, 21.0, 1, 1, 55, 75)")
+ cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id, window_open_time, window_rest_time) VALUES (100, 1, 'Living Room', 'HEATING', 1, 1, 33, 66)")
+ cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id, window_open_time, window_rest_time) VALUES (101, 1, 'Bedroom', 'HOT_WATER', 2, 2, 10, 25)")
+ cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id, window_open_time, window_rest_time) VALUES (102, 1, 'Kitchen', 'HEATING', NULL, 3, 20, 30)")
+
+ cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN001', 1, 1, 100, 'thermostat', 'Living Room Thermostat', 'RU01', 'Tado', '1.45', 'NORMAL', 1, 1)")
+ cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN002', 2, 2, 101, 'bridge', 'Bedroom Bridge', 'RU01', 'Tado', '1.45', 'NORMAL', 1, 1)")
+ cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN003', 3, 3, 102, 'thermostat', 'Kitchen Thermostat', 'RB01', 'Tado', '2.10', 'LOW', 0, 1)")
+ cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN004', 4, 1, 100, 'radiator_thermostat', 'Living Room Radiator', 'RV01', 'Tado', '1.20', 'LOW', 0, 0)")
+
+ cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (1, '20260129100000', 21.5, 20.0, 1, 1, 45, 100, 1)")
+ cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (2, '20260129100500', 19.0, 18.0, 1, 1, 50, 95, 0)")
+ cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (3, '20260129100700', 22.0, 21.0, 0, 0, 40, 100, 0)")
+ cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (2, '20260129100700', 19.5, 18.5, 0, 1, 60, 95, 0)")
+ cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (4, '20260129101000', 20.5, 20.0, 1, 0, 45, 60, 1)")
+ cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (2, '20260129110500', 22.0, 21.0, 1, 1, 55, 75, 0)")
conn.commit()
@@ -181,6 +181,143 @@ def test_get_status_return_correct_values(self, client, state_manager, mock_api)
assert data["cloud_api"]['authenticated'] is False
assert data["cloud_api"]['enabled'] is False
+ def test_get_status_with_cloud_api_available(self, client, mock_api, monkeypatch):
+ """Test that GET /status includes cloud API details when available."""
+ import tado_local.routes as routes
+
+ class FakeRateLimit:
+ granted_calls = True
+
+ def to_dict(self):
+ return {"limit": 100, "remaining": 90}
+
+ class FakeCloudApi:
+ def __init__(self):
+ self.home_id = 123
+ self.token_expires_at = 1700003600.0
+ self.rate_limit = FakeRateLimit()
+ self.is_authenticating = False
+ self.auth_verification_uri = None
+ self.auth_user_code = None
+ self.auth_expires_at = None
+
+ def is_authenticated(self):
+ return True
+
+ monkeypatch.setattr(routes.time, "time", lambda: 1700000000.0)
+ mock_api.cloud_api = FakeCloudApi()
+
+ response = client.get("/status")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["cloud_api"]["enabled"] is True
+ assert data["cloud_api"]["authenticated"] is True
+ assert data["cloud_api"]["home_id"] == 123
+ assert data["cloud_api"]["token_expires_at"] == 1700003600.0
+ assert data["cloud_api"]["token_expires_in"] == 3600
+ assert data["cloud_api"]["rate_limit"] == {"limit": 100, "remaining": 90}
+
+ def test_get_status_with_cloud_api_authenticating(self, client, mock_api, monkeypatch):
+ """Test that GET /status reports authentication details when in progress."""
+ import tado_local.routes as routes
+
+ class FakeCloudApi:
+ def __init__(self):
+ self.home_id = None
+ self.token_expires_at = None
+ self.rate_limit = None
+ self.is_authenticating = True
+ self.auth_verification_uri = "https://example.com/verify"
+ self.auth_user_code = "ABCD-1234"
+ self.auth_expires_at = 1700000300.0
+
+ def is_authenticated(self):
+ return False
+
+ monkeypatch.setattr(routes.time, "time", lambda: 1700000000.0)
+ mock_api.cloud_api = FakeCloudApi()
+
+ response = client.get("/status")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["cloud_api"]["enabled"] is True
+ assert data["cloud_api"]["authenticated"] is False
+ assert data["cloud_api"]["authentication_required"] is True
+ assert data["cloud_api"]["verification_uri"] == "https://example.com/verify"
+ assert data["cloud_api"]["user_code"] == "ABCD-1234"
+ assert data["cloud_api"]["auth_expires_at"] == 1700000300.0
+ assert data["cloud_api"]["auth_expires_in"] == 300
+
+ def test_get_status_with_cloud_api_not_authenticated(self, client, mock_api):
+ """Test that GET /status marks auth required when not authenticated."""
+ class FakeCloudApi:
+ def __init__(self):
+ self.home_id = None
+ self.token_expires_at = None
+ self.rate_limit = None
+ self.is_authenticating = False
+ self.auth_verification_uri = None
+ self.auth_user_code = None
+ self.auth_expires_at = None
+
+ def is_authenticated(self):
+ return False
+
+ mock_api.cloud_api = FakeCloudApi()
+
+ response = client.get("/status")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["cloud_api"]["enabled"] is True
+ assert data["cloud_api"]["authenticated"] is False
+ assert data["cloud_api"]["authentication_required"] is True
+ assert data["cloud_api"]["message"] == "Authentication will start automatically"
+
+ def test_get_status_with_cloud_api_disabled(self, client, mock_api):
+ """Test that GET /status reports cloud API as disabled when not set."""
+ mock_api.cloud_api = None
+
+ response = client.get("/status")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["cloud_api"]["enabled"] is False
+ assert data["cloud_api"]["authenticated"] is False
+ assert "home_id" not in data["cloud_api"]
+ assert "token_expires_at" not in data["cloud_api"]
+ assert "authentication_required" not in data["cloud_api"]
+
+ def test_get_status_bridge_not_connected(self, client, mock_api):
+ """Test that GET /status reports bridge not connected when API is unavailable."""
+ mock_api.pairing = None
+
+ response = client.get("/status")
+
+ assert response.status_code == 503
+ data = response.json()
+ assert data["detail"] == "Bridge not connected"
+
+ def test_get_status_connection_exeptions(self, client, mock_api):
+ """Test that GET /status handles exceptions gracefully."""
+
+ mock_api.pairing.list_accessories_and_characteristics.side_effect = Exception("Connection error")
+
+ response = client.get("/status")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ assert data["status"] == "error"
+ assert data["bridge_connected"] is False
+ assert data["error"] == "Connection error"
+
class TestGetAccessories:
"""Test suite for GET /accessories endpoint."""
@@ -229,8 +366,23 @@ def test_get_accessory_by_aid(self, client, mock_api):
# ensure this is the accessory with aid 1
assert acc["aid"] == 1 or acc.get("id") == 1
- def test_get_accessory_with_enhanced_is_false(self, client, mock_api):
+ def test_get_accessory_by_aid_with_enhanced_is_false(self, client, mock_api):
"""Test GET /accessories/{aid}?enhanced=false returns accessory without enhancements."""
+ response = client.get("/accessories/1?enhanced=false")
+ assert response.status_code == 200
+ data = response.json()
+ assert "enhanced" in data
+ assert data["enhanced"] is False
+ assert "note" not in data
+
+ acc = data.get("accessory") if isinstance(data, dict) and "accessory" in data else data
+
+ # Check that no extra fields are present (like 'type_name')
+ for service in acc.get("services", []):
+ assert "type_name" not in service
+
+ def test_get_accessory_with_enhanced_is_false(self, client, mock_api):
+ """Test GET /accessories?enhanced=false returns accessory without enhancements."""
response = client.get("/accessories?enhanced=false")
assert response.status_code == 200
data = response.json()
@@ -307,6 +459,8 @@ def test_get_zones_ordered_by_order_id(self, client, state_manager):
assert zones[2]["name"] == "Kitchen"
assert zones[2]["order_id"] == 3
assert zones[2]["home_id"] is None
+ assert zones[2]['window_open_time'] == 20
+ assert zones[2]['window_rest_time'] == 30
assert "state" in zones[2]
assert zones[2]['state']['cur_temp_c'] == 22.0
@@ -316,7 +470,7 @@ def test_get_zones_ordered_by_order_id(self, client, state_manager):
assert zones[2]['state']['target_temp_f'] == 69.8
assert zones[2]['state']['mode'] == 0
assert zones[2]['state']['cur_heating'] == 0
-
+ assert zones[2]['state']['window_open'] is False
assert zones[2]["device_count"] == 1
@@ -365,6 +519,51 @@ def test_get_zones_response_is_json(self, client, state_manager):
data = response.json()
assert isinstance(data, dict)
+ def test_get_zones_with_missing_state(self, client, state_manager, monkeypatch):
+ """Test that zones with missing state data still return correctly."""
+ monkeypatch.setattr(state_manager, "get_state_with_optimistic", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr(state_manager, "get_current_state", lambda *_args, **_kwargs: None)
+ # Use existing zones but force state lookups to return None
+ response = client.get("/zones")
+
+ assert response.status_code == 200
+ data = response.json()
+ zones = data["zones"]
+
+ kitchen_zone = next((z for z in zones if z["name"] == "Kitchen"), None)
+ assert kitchen_zone is not None
+ assert "state" in kitchen_zone
+ assert kitchen_zone["state"]["cur_temp_c"] is None
+ assert kitchen_zone["state"]["cur_temp_f"] is None
+ assert kitchen_zone["state"]["hum_perc"] is None
+ assert kitchen_zone["state"]["target_temp_c"] is None
+ assert kitchen_zone["state"]["target_temp_f"] is None
+ assert kitchen_zone["state"]["mode"] == 0
+ assert kitchen_zone["state"]["cur_heating"] == 0
+ assert kitchen_zone["state"]["window_open"] is None
+
+ def test_get_zones_with_cloud_api_found(self, client, state_manager, mock_api):
+ """Test that zones include cloud API data when available."""
+ class FakeCloudApi:
+ def is_authenticated(self):
+ return True
+ async def get_home_info(self):
+ return {"id": 123, "name": "Home", "cloud_data": "example"}
+
+ mock_api.cloud_api = FakeCloudApi()
+
+ response = client.get("/zones")
+ assert response.status_code == 200
+ data = response.json()
+ homes = data["homes"]
+
+ home = next((h for h in homes if h["name"] == "Home"), None)
+ assert home is not None
+ assert "id" in home
+ assert home["id"] == 123
+ assert "name" in home
+ assert home["name"] == "Home"
+
def test_get_zone_by_id(self, client, state_manager):
"""Test GET /zones/{zone_id} endpoint."""
response = client.get("/zones/1")
@@ -374,6 +573,9 @@ def test_get_zone_by_id(self, client, state_manager):
assert "zone" in data
assert data["zone"]["zone_id"] == 1
assert data["zone"]["name"] == "Living Room"
+ assert data["zone"]['window_open_time'] == 33
+ assert data["zone"]['window_rest_time'] == 66
+
assert "state"in data["zone"]
assert data["zone"]['state']['cur_temp_c'] == 21.5
assert data["zone"]['state']['cur_temp_f'] == 70.7
@@ -382,6 +584,7 @@ def test_get_zone_by_id(self, client, state_manager):
assert data["zone"]['state']['target_temp_f'] == 68.0
assert data["zone"]['state']['mode'] == 1
assert data["zone"]['state']['cur_heating'] == 1
+ assert data["zone"]['state']['window_open'] is True
def test_get_zone_nonexistent(self, client, state_manager):
"""Test GET /zones/{zone_id} with nonexistent zone."""
@@ -400,6 +603,236 @@ def test_get_zones_all_have_zone_ids(self, client, state_manager):
assert zone["zone_id"] is not None
assert isinstance(zone["zone_id"], int)
+ def test_get_zone_with_missing_state(self, client, state_manager, monkeypatch):
+ """Test that zones with missing state data still return correctly."""
+ monkeypatch.setattr(state_manager, "get_state_with_optimistic", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr(state_manager, "get_current_state", lambda *_args, **_kwargs: None)
+ # Use existing zones but force state lookups to return None
+ response = client.get("/zones/1")
+
+ assert response.status_code == 200
+ data = response.json()
+
+ kitchen_zone = data["zone"]
+ assert kitchen_zone is not None
+ assert "state" in kitchen_zone
+ assert kitchen_zone["state"]["cur_temp_c"] is None
+ assert kitchen_zone["state"]["cur_temp_f"] is None
+ assert kitchen_zone["state"]["hum_perc"] is None
+ assert kitchen_zone["state"]["target_temp_c"] is None
+ assert kitchen_zone["state"]["target_temp_f"] is None
+ assert kitchen_zone["state"]["mode"] == 0
+ assert kitchen_zone["state"]["cur_heating"] == 0
+ assert kitchen_zone["state"]["window_open"] is None
+
+ def test_get_zone_with_cloud_api_found(self, client, state_manager, mock_api):
+ """Test that zones include cloud API data when available."""
+ class FakeCloudApi:
+ def is_authenticated(self):
+ return True
+ async def get_home_info(self):
+ return {"id": 123, "name": "Home", "cloud_data": "example"}
+
+ mock_api.cloud_api = FakeCloudApi()
+
+ response = client.get("/zones/1")
+ assert response.status_code == 200
+ data = response.json()
+
+ home = data["home"]
+
+ assert home is not None
+ assert "id" in home
+ assert home["id"] == 123
+ assert "name" in home
+ assert home["name"] == "Home"
+
+ def test_get_zone_history(self, client, state_manager):
+ """Test GET /zones/{zone_id}/history endpoint."""
+ response = client.get("/zones/2/history")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "history" in data
+ assert isinstance(data["history"], list)
+ assert len(data["history"]) > 0
+
+ entry = data["history"][0]
+ assert "state" in entry
+ state = entry["state"]
+
+ assert "cur_heating" in state
+ assert "cur_temp_c" in state
+ assert "target_temp_c" in state
+ assert "cur_heating" in state
+ assert "hum_perc" in state
+ assert "battery_low" in state
+
+ assert state["cur_heating"] == 1
+ assert state["cur_temp_c"] == 22.0
+ assert state["target_temp_c"] == 21.0
+ assert state["cur_heating"] == 1
+ assert state["hum_perc"] == 55.0
+ assert state["battery_low"] is False
+
+ def test_get_zone_history_nonexistent_zone(self, client, state_manager):
+ """Test GET /zones/{zone_id}/history with nonexistent zone."""
+ response = client.get("/zones/999/history")
+
+ assert response.status_code == 404
+
+ def test_get_zone_history_limited_entries(self, client, state_manager):
+ """Test GET /zones/{zone_id}/history with limit parameter."""
+ response = client.get("/zones/2/history?limit=1")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "history" in data
+ assert isinstance(data["history"], list)
+ assert len(data["history"]) == 1
+
+ def test_get_zone_history_invalid_limit(self, client, state_manager):
+ """Test GET /zones/{zone_id}/history with invalid limit parameter."""
+ response = client.get("/zones/2/history?limit=abc")
+
+ assert response.status_code == 422 # Unprocessable Entity due to invalid parameter type
+ data = response.json()
+ assert "detail" in data
+ assert "input should...as an integer" in data["detail"][0]["msg"].lower() \
+ or "input should be a valid integer" in data["detail"][0]["msg"].lower()
+
+ def test_get_zone_history_limit_offset(self, client, state_manager):
+ """Test GET /zones/{zone_id}/history with limit and offset parameters."""
+ response = client.get("/zones/2/history?limit=1&offset=1")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "history" in data
+ assert isinstance(data["history"], list)
+ assert len(data["history"]) == 1
+
+ entry = data["history"][0]
+ assert "state" in entry
+ state = entry["state"]
+
+ assert state["cur_heating"] == 0
+ assert state["cur_temp_c"] == 19.5
+ assert state["target_temp_c"] == 18.5
+ assert state["cur_heating"] == 0
+ assert state["hum_perc"] == 60.0
+ assert state["battery_low"] is False
+
+ def test_post_create_zone(self, client, state_manager):
+ """Test POST /zones to create a new zone."""
+
+ response = client.post("/zones?leader_device_id=10&name=Bathroom")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "zone_id" in data
+ assert data["zone_id"] == 4
+ assert "name" in data
+ assert data["name"] == "Bathroom"
+
+ # Verify it was actually created in the database
+ conn = sqlite3.connect(state_manager.db_path)
+ cursor = conn.execute(
+ "SELECT name, leader_device_id FROM zones WHERE zone_id = ?",
+ (data["zone_id"],)
+ )
+ row = cursor.fetchone()
+ conn.close()
+
+ assert row is not None
+ assert row[0] == "Bathroom"
+ assert row[1] == 10
+
+ def test_post_create_zone_missing_name(self, client, state_manager):
+ """Test POST /zones with missing name parameter."""
+ response = client.post("/zones?leader_device_id=10")
+
+ assert response.status_code == 422 # Unprocessable Entity due to missing required parameter
+ data = response.json()
+ assert "detail" in data
+ assert data["detail"][0]["msg"].lower() == "field required"
+
+ def test_post_create_zone_invalid_leader_device_id(self, client, state_manager):
+ """Test POST /zones with invalid leader_device_id parameter."""
+ response = client.post("/zones?leader_device_id=abc&name=Bathroom")
+
+ assert response.status_code == 422 # Unprocessable Entity due to invalid parameter type
+ data = response.json()
+
+ assert "detail" in data
+ assert data["detail"][0]["msg"].lower() == "input should...as an integer" \
+ or "input should be a valid integer" in data["detail"][0]["msg"].lower()
+
+ def test_post_create_zone_no_leader_device_id(self, client, state_manager):
+ """Test POST /zones without leader_device_id parameter."""
+ response = client.post("/zones?name=Bathroom")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "zone_id" in data
+ assert data["zone_id"] == 4
+ assert "name" in data
+ assert data["name"] == "Bathroom"
+
+ def test_put_zone_id_update_name(self, client, state_manager):
+ """Test PUT /zones/{zone_id} to update zone name with mismatched zone_id in path and body."""
+ response = client.put("/zones/1?name=New Name&leader_device_id=999&order_id=22")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "zone_id" in data
+ assert data["zone_id"] == 1
+ assert "updated" in data
+ assert data["updated"] is True
+
+ # check database to ensure zone_id was not to 999
+ conn = sqlite3.connect(state_manager.db_path)
+ cursor = conn.execute(
+ "SELECT zone_id, name, leader_device_id, order_id FROM zones WHERE zone_id = ?",
+ (1,)
+ )
+ row = cursor.fetchone()
+ conn.close()
+
+ assert row is not None
+ assert row[0] == 1 # zone_id should remain 1, not updated to 999
+ assert row[1] == "New Name" # name should be updated to "New Name"
+ assert row[2] == 999 # leader_device_id should be updated to 999
+ assert row[3] == 22 # order_id should be updated to 22
+
+ def test_put_zone_update_name_no_parameters(self, client, state_manager):
+ """Test PUT /zones/{zone_id} to update zone name with no parameters."""
+ response = client.put("/zones/1")
+
+ assert response.status_code == 400
+ data = response.json()
+ assert "detail" in data
+ assert data["detail"] == "No updates provided"
+
+ def test_put_zone_update_name_wrong_zone_id(self, client, state_manager):
+ """Test PUT /zones/{zone_id} to update zone name with non-existent zone_id."""
+ response = client.put("/zones/999?name=New Living Room")
+
+ assert response.status_code == 404
+ data = response.json()
+ assert "detail" in data
+ assert data["detail"] == "Zone 999 not found or no changes made"
+
+ # Verify it was not updated in the database
+ conn = sqlite3.connect(state_manager.db_path)
+ cursor = conn.execute(
+ "SELECT * FROM zones WHERE zone_id = ?",
+ (999,)
+ )
+ row = cursor.fetchone()
+ conn.close()
+ assert row is None
+
+
class TestGetThermostats:
"""Test suite for GET /thermostats endpoint."""
@@ -624,7 +1057,7 @@ def test_get_devices_includes_correct_metadata(self, client, state_manager):
assert device["model"] == 'RU01'
assert device["firmware_version"] == '1.45'
assert device["is_zone_leader"] is True
- assert device["is_circuit_driver"] is False
+ assert device["is_circuit_driver"] is True
def test_get_devices_correct_state_data(self, client, state_manager):
"""Test that device state data is correct from state history."""
@@ -828,8 +1261,6 @@ def test_get_device_history_limit_and_offset(self, client, state_manager):
assert state['valve_position'] is None
assert state['battery_low'] is False
-
-
def test_get_device_history_limit_too_large(self, client, state_manager):
"""Test GET /devices/{device_id}/history with limit and offset."""
response = client.get("/devices/4/history?limit=2&offset=0")
@@ -859,6 +1290,20 @@ def test_get_device_history_limit_exceeds_count(self, client, state_manager):
assert "count" in data
assert data["count"] == 1
+ def test_put_device_in_zone(self, client, state_manager):
+ """Test PUT /devices/{device_id} to move device to a different zone."""
+
+ response = client.get("/devices/1")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["zone_id"] == 1 # Initially in zone 1
+
+ response = client.put("/devices/1/zone?zone_id=2")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "zone_id" in data
+ assert data["zone_id"] == 2 # Updated to zone 2
class TestSetZoneBridgeCommands:
"""Test suite for set_zone route bridge command generation."""
@@ -968,6 +1413,23 @@ def test_set_zone_temperature_resume_schedule_bridge_call(self, client, mock_api
# Temperature should NOT be set when using -1
assert 'target_temperature' not in chars
+ def test_set_zone_temperature_resume_schedule_bridge_call_2(self, client, mock_api):
+ """Test that temperature=-1 generates heating enable without temperature."""
+ response = client.post("/zones/3/set?temperature=-1&heating_enabled=true")
+
+ assert response.status_code == 200
+
+ # Verify bridge command was called
+ mock_api.set_device_characteristics.assert_called_once()
+ call_args = mock_api.set_device_characteristics.call_args
+
+ chars = call_args[0][1]
+ assert 'target_heating_cooling_state' in chars
+ assert chars['target_heating_cooling_state'] == 1
+ # Temperature should NOT be set when using -1
+ assert 'target_temperature' not in chars
+
+
def test_set_zone_calls_leader_device(self, client, mock_api):
"""Test that zone control calls the zone's leader device."""
response = client.post("/zones/1/set?temperature=20")
@@ -1177,6 +1639,22 @@ def test_set_thermostat_calls_bridge(self, client, mock_api):
assert 'target_temperature' in chars
assert chars['target_temperature'] == 23
+ def test_set_thermostat_calls_bridge_to_zero(self, client, mock_api):
+ """Test that thermostat temperature 0 command reaches bridge."""
+ response = client.post("/thermostats/1/set?temperature=0")
+
+ assert response.status_code == 200
+
+ # Verify bridge command was called
+ mock_api.set_device_characteristics.assert_called_once()
+ call_args = mock_api.set_device_characteristics.call_args
+
+ chars = call_args[0][1]
+
+ assert 'target_heating_cooling_state' in chars
+ assert chars['target_heating_cooling_state'] == 0
+
+
def test_set_thermostat_heating_enabled_bridge_call(self, client, mock_api):
"""Test that thermostat heating enable reaches bridge."""
response = client.post("/thermostats/1/set?heating_enabled=true")
@@ -1282,3 +1760,326 @@ def test_set_device_nonexistent_device_no_bridge_call(self, client, mock_api):
assert response.status_code == 404
# Bridge should NOT have been called
mock_api.set_device_characteristics.assert_not_called()
+
+
+class TestBaseAPIHandling:
+ def test_get_root_returns_welcome_page(self, client):
+ """Test GET / returns welcome page"""
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "" in response.text.lower()
+ assert "
Tado Local " in response.text
+
+ def test_get_root_returns_api_info_when_index_missing(self, monkeypatch):
+ """Test GET / returns API info when index.html is missing."""
+ import tado_local.routes as routes
+ from fastapi.testclient import TestClient
+
+ original_exists = routes.Path.exists
+
+ def fake_exists(path):
+ if path.name == "index.html":
+ return False
+ return original_exists(path)
+
+ monkeypatch.setattr(routes.Path, "exists", fake_exists)
+
+ app = routes.create_app()
+ routes.register_routes(app, lambda: None)
+ client = TestClient(app)
+
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "application/json" in response.headers["content-type"]
+ data = response.json()
+ assert data["service"] == "Tado Local"
+ assert data["api_info"] == "/api"
+ assert "Web UI not found" in data["note"]
+
+
+ def test_get_favicon_returns_icon(self, client):
+ """Test GET /favicon.ico returns the favicon."""
+ response = client.get("/favicon.ico")
+
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "image/svg+xml"
+ assert response.content.startswith(b"
\r\n") or \
+ response.content.endswith(b" \n")
+
+ def test_get_robots_txt_returns_disallow_all(self, client):
+ """Test GET /robots.txt returns disallow all."""
+ response = client.get("/robots.txt")
+
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "text/plain; charset=utf-8"
+ # to satify Windows vs Linux line ending differences in the robots.txt file
+ assert "User-agent: *\r\nDisallow: /\r\n" in response.text or \
+ "User-agent: *\nDisallow: /\n" in response.text
+
+
+ def test_get_robots_txt_fallback_when_missing(self, monkeypatch):
+ """Test GET /robots.txt returns fallback when file is missing."""
+ import tado_local.routes as routes
+ from fastapi.testclient import TestClient
+
+ original_exists = routes.Path.exists
+
+ def fake_exists(path):
+ if path.name == "robots.txt":
+ return False
+ return original_exists(path)
+
+ monkeypatch.setattr(routes.Path, "exists", fake_exists)
+
+ app = routes.create_app()
+ routes.register_routes(app, lambda: None)
+ client = TestClient(app)
+
+ response = client.get("/robots.txt")
+
+ assert response.status_code == 200
+ assert "text/plain" in response.headers["content-type"]
+ assert "User-agent: *\nDisallow: /\n" in response.text
+
+ def test_get_api_structure_returns_valid_json(self, client):
+ """Test GET /api-structure returns valid JSON."""
+ response = client.get("/api")
+
+ assert response.status_code == 200
+ assert "application/json" in response.headers["content-type"]
+ data = response.json()
+ assert isinstance(data, dict)
+ assert "service" in data
+ assert data['service'] == "Tado Local"
+ assert "endpoints" in data
+ assert isinstance(data["endpoints"], dict)
+
+ endpoints = data["endpoints"]
+ assert "devices" in endpoints
+ assert endpoints["devices"] == "/devices"
+ assert "zones" in endpoints
+ assert endpoints["zones"] == "/zones"
+ assert "thermostats" in endpoints
+ assert endpoints["thermostats"] == "/thermostats"
+ assert "events" in endpoints
+ assert endpoints["events"] == "/events"
+ assert "accessories" in endpoints
+ assert endpoints["accessories"] == "/accessories"
+ assert "refresh" in endpoints
+ assert endpoints["refresh"] == "/refresh"
+ assert "refresh_cloud" in endpoints
+ assert endpoints["refresh_cloud"] == "/refresh/cloud"
+
+ def test_get_api_docs_returns_html(self, client):
+ """Test GET /docs returns HTML documentation."""
+ response = client.get("/docs")
+
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "" in response.text.lower()
+
+ def test_invalid_route_returns_404(self, client):
+ """Test that an invalid route returns 404."""
+ response = client.get("/invalid-route")
+
+ assert response.status_code == 404
+ data = response.json()
+ assert "detail" in data
+ assert data["detail"] == "Not Found"
+
+ def test_invalid_method_returns_405(self, client):
+ """Test that an invalid method on a valid route returns 405."""
+ response = client.put("/devices")
+
+ assert response.status_code == 405
+ data = response.json()
+ assert "detail" in data
+ assert "Method Not Allowed" in data["detail"]
+
+ def test_api_refresh_endpoint_exists(self, client):
+ """Test that the API refresh endpoint exists and returns success."""
+ response = client.post("/refresh")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, list)
+ assert "fetched_at" in data[0]
+
+ def test_api_refresh_cloud_endpoint_exists(self, client):
+ """Test that the API refresh endpoint exists and returns success."""
+ response = client.post("/refresh/cloud")
+
+ assert response.status_code == 503
+ data = response.json()
+ assert "detail" in data
+ assert "Cloud API not available" in data["detail"]
+
+ def test_api_refresh_cloud_with_mock_data(self, client, mock_api, monkeypatch):
+ """Test that /refresh/cloud returns success with mock cloud data."""
+ import tado_local.sync as sync_module
+
+ class FakeCloudApi:
+ def __init__(self):
+ self.get_zone_states = AsyncMock(return_value=[{"zone": 1}])
+ self.get_device_list = AsyncMock(return_value=[{"device": "a"}, {"device": "b"}])
+
+ def is_authenticated(self):
+ return True
+
+ class FakeSync:
+ def __init__(self, db_path):
+ self.db_path = db_path
+
+ async def sync_all(self, *args, **kwargs):
+ return True
+
+ monkeypatch.setattr(sync_module, "TadoCloudSync", FakeSync)
+ mock_api.cloud_api = FakeCloudApi()
+
+ response = client.post("/refresh/cloud?battery_only=true")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert data["devices_synced"] == 2
+ assert data["refreshed"] == ["battery_status", "device_status"]
+
+ def test_api_refresh_cloud_with_full_refresh(self, client, mock_api, monkeypatch):
+ """Test that /refresh/cloud returns success with full refresh data."""
+ import tado_local.sync as sync_module
+
+ class FakeCloudApi:
+ def __init__(self):
+ self.get_home_info = AsyncMock(return_value={"name": "My Home"})
+ self.get_zones = AsyncMock(return_value=[{"id": 1}, {"id": 2}])
+ self.get_zone_states = AsyncMock(return_value=[{"zone": 1}])
+ self.get_device_list = AsyncMock(return_value=[{"device": "a"}])
+
+ def is_authenticated(self):
+ return True
+
+ class FakeSync:
+ def __init__(self, db_path):
+ self.db_path = db_path
+
+ async def sync_all(self, *args, **kwargs):
+ return True
+
+ monkeypatch.setattr(sync_module, "TadoCloudSync", FakeSync)
+ mock_api.cloud_api = FakeCloudApi()
+
+ response = client.post("/refresh/cloud")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert data["home_name"] == "My Home"
+ assert data["zones_synced"] == 2
+ assert data["devices_synced"] == 1
+ assert data["refreshed"] == ["home_info", "zones", "battery_status", "device_status"]
+
+ def test_get_api_key_validates_env_keys(self, monkeypatch):
+ """Test that get_api_key enforces keys from environment."""
+ import importlib
+ from fastapi.testclient import TestClient
+ import tado_local.routes as routes
+
+ monkeypatch.setenv("TADO_API_KEYS", "valid-key other-key")
+ routes = importlib.reload(routes)
+
+ app = routes.create_app()
+ routes.register_routes(app, lambda: None)
+ client = TestClient(app)
+
+ response = client.get("/api")
+ assert response.status_code == 401
+
+ response = client.get("/api", headers={"Authorization": "Bearer bad-key"})
+ assert response.status_code == 401
+
+ response = client.get("/api", headers={"Authorization": "Bearer valid-key"})
+ assert response.status_code == 200
+
+ response = client.get("/api", headers={"Authorization": "Bearer other-key"})
+ assert response.status_code == 200
+
+ monkeypatch.delenv("TADO_API_KEYS", raising=False)
+ importlib.reload(routes)
+
+ def test_well_known_route_exists(self, client):
+ """Test that .well-known route exists."""
+ response = client.get("/.well-known/test")
+ assert response.status_code == 404
+
+class TestOpenWindowSettings:
+ """Test suite for open window settings in device state."""
+
+ def test_zone_set_window_timeouts(self, client, state_manager):
+ """Test that zone open window timeouts settable."""
+ response = client.get("/zones/1")
+ assert response.status_code == 200
+ databefore = response.json()
+ assert "window_open_time" in databefore['zone']
+ assert databefore['zone']['window_open_time'] == 33
+ assert "window_rest_time" in databefore['zone']
+ assert databefore['zone']['window_rest_time'] == 66
+
+ response = client.post("/zones/1/windowtimeouts?window_open_time=100&window_rest_time=200")
+ assert response.status_code == 200
+
+ response = client.get("/zones/1")
+ assert response.status_code == 200
+ dataafter = response.json()
+
+ assert "window_open_time" in dataafter['zone']
+ assert dataafter['zone']['window_open_time'] == 100
+ assert "window_rest_time" in dataafter['zone']
+ assert dataafter['zone']['window_rest_time'] == 200
+
+ def test_zone_set_window_open_timeout(self, client, state_manager):
+ """Test that zone open window timeouts settable."""
+ response = client.post("/zones/1/windowtimeouts?window_open_time=100")
+ assert response.status_code == 200
+
+ response = client.get("/zones/1")
+ assert response.status_code == 200
+ data = response.json()
+
+ assert "window_open_time" in data['zone']
+ assert data['zone']['window_open_time'] == 100
+ assert "window_rest_time" in data['zone']
+ assert data['zone']['window_rest_time'] == 66
+
+ def test_zone_set_window_rest_timeout(self, client, state_manager):
+ """Test that zone open window timeouts settable."""
+
+ response = client.post("/zones/1/windowtimeouts?window_rest_time=200")
+ assert response.status_code == 200
+
+ response = client.get("/zones/1")
+ assert response.status_code == 200
+ data = response.json()
+
+ assert "window_open_time" in data['zone']
+ assert data['zone']['window_open_time'] == 33
+ assert "window_rest_time" in data['zone']
+ assert data['zone']['window_rest_time'] == 200
+
+ def test_zone_set_window_timeouts_invalid(self, client, state_manager):
+ """Test that invalid window timeout values are rejected."""
+ response = client.post("/zones/1/windowtimeouts?window_open_time=-10&window_rest_time=200")
+ assert response.status_code == 400
+
+ response = client.post("/zones/1/windowtimeouts?window_open_time=100&window_rest_time=-20")
+ assert response.status_code == 400
+
+ response = client.post("/zones/1/windowtimeouts?window_open_time=abc&window_rest_time=200")
+ assert response.status_code == 422
+
+ response = client.post("/zones/1/windowtimeouts?window_open_time=100&window_rest_time=xyz")
+ assert response.status_code == 422
diff --git a/tests/test_state.py b/tests/test_state.py
new file mode 100644
index 0000000..ef92fc6
--- /dev/null
+++ b/tests/test_state.py
@@ -0,0 +1,1310 @@
+import pytest
+import sqlite3
+import tempfile
+import os
+import datetime
+from pathlib import Path
+import time
+from unittest.mock import patch
+from tado_local.state import DeviceStateManager
+from tado_local.database import ensure_schema_and_migrate
+
+
+@pytest.fixture
+def temp_db():
+ """Create a temporary SQLite test database with TadoLocal schema."""
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.db') as tmp:
+ db_path = tmp.name
+
+ # Create schema
+ ensure_schema_and_migrate(db_path)
+
+ conn = sqlite3.connect(db_path)
+ conn.commit()
+ conn.close()
+
+ yield db_path
+
+ if os.path.exists(db_path):
+ Path(db_path).unlink()
+
+
+@pytest.fixture
+def state_manager(temp_db):
+ """Create a DeviceStateManager instance for testing."""
+ manager = DeviceStateManager(temp_db)
+ return manager
+
+
+@pytest.fixture
+def state_manager_with_db_devices(temp_db):
+ """Create a DeviceStateManager with mock device data in the database."""
+ def _get_test_timestamp_bucket(timestamp: float) -> str:
+ """Convert timestamp to 10-second bucket (format: YYYYMMDDHHMMSSx where x is 0-5)."""
+ dt = datetime.datetime.fromtimestamp(timestamp)
+ # Round down to 10-second interval
+ second = (dt.second // 10) * 10
+ return dt.strftime(f'%Y%m%d%H%M{second:02d}')
+
+ conn = sqlite3.connect(temp_db)
+
+ # Insert mock devices into database
+ conn.execute("""
+ INSERT INTO devices (device_id, serial_number, aid, device_type, name, model, manufacturer, zone_id, is_zone_leader)
+ VALUES
+ (1, 'RU0208A26ABC123', 221, 'thermostat', 'Living Room Thermostat', 'Smart Thermostat', 'Tado', 1, 1),
+ (2, 'VA0210A26ABC456', 222, 'radiator_valve', 'Bedroom Radiator Valve', 'Smart Radiator Valve', 'Tado', 2, 0),
+ (3, 'IB01170626ABC789', 223, 'internet_bridge', 'Internet Bridge', 'Internet Bridge', 'Tado', NULL, 0)
+ """)
+
+ # Insert mock zones
+ conn.execute("""
+ INSERT INTO zones (zone_id, name, leader_device_id, order_id, tado_zone_id)
+ VALUES
+ (1, 'Living Room', 1, 1, 1),
+ (2, 'Bedroom', 2, 2, 2)
+ """)
+
+ # Seed history records (sample database) - 61 seconds apart
+ base_ts = 1_700_000_000.0
+ bucket_0 = _get_test_timestamp_bucket(base_ts)
+ bucket_1 = _get_test_timestamp_bucket(base_ts + 61)
+ bucket_2 = _get_test_timestamp_bucket(base_ts + 122)
+
+ conn.execute("""
+ INSERT INTO device_state_history (
+ device_id, timestamp_bucket,
+ current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ humidity, window, window_lastupdate, updated_at
+ ) VALUES
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, (
+ 1, bucket_0, 20.0, 21.0, 1, 1, 48, 0, None, bucket_0,
+ 1, bucket_1, 21.5, 21.0, 1, 1, 50, 0, None, bucket_1,
+ 1, bucket_2, 23.5, 21.0, 1, 1, 55, 0, None, bucket_2, # latest for device 2
+ 2, bucket_2, 19.0, 20.0, 0, 0, 55, 1, float(base_ts), bucket_2,
+ 3, bucket_0, None, None, None, None, None, None, None, bucket_0,
+ ))
+
+ # Update devices with zone relationships
+ conn.execute("UPDATE devices SET zone_id = 1 WHERE device_id = 1")
+ conn.execute("UPDATE devices SET zone_id = 2 WHERE device_id = 2")
+
+
+ conn.commit()
+ conn.close()
+
+ # Create manager (loads caches and latest state from database)
+ manager = DeviceStateManager(temp_db)
+ return manager
+
+
+class TestGetDeviceInfo:
+ def test_get_device_info_returns_cached_data(self, state_manager_with_db_devices):
+ """Test that get_device_info returns cached device information."""
+ result = state_manager_with_db_devices.get_device_info(1)
+
+ assert result['serial_number'] == 'RU0208A26ABC123'
+ assert result['name'] == 'Living Room Thermostat'
+ assert result['device_type'] == 'thermostat'
+ assert result['is_zone_leader'] is True
+
+ def test_get_device_info_radiator_valve(self, state_manager_with_db_devices):
+ """Test retrieving radiator valve device info."""
+ result = state_manager_with_db_devices.get_device_info(2)
+
+ assert result['device_type'] == 'radiator_valve'
+ assert result['serial_number'] == 'VA0210A26ABC456'
+
+ def test_get_device_info_internet_bridge(self, state_manager_with_db_devices):
+ """Test retrieving internet bridge device info."""
+ result = state_manager_with_db_devices.get_device_info(3)
+
+ assert result['device_type'] == 'internet_bridge'
+ assert result['zone_id'] is None
+ assert result['is_zone_leader'] is False
+
+ def test_get_device_info_returns_empty_dict_for_unknown_device(self, state_manager):
+ """Test that unknown device IDs return empty dict."""
+ result = state_manager.get_device_info(999)
+ assert result == {}
+
+
+class TestGetDeviceIdByAid:
+ def test_get_device_id_by_aid_found(self, state_manager_with_db_devices):
+ """Test retrieving device_id from HomeKit aid."""
+ result = state_manager_with_db_devices.get_device_id_by_aid(221)
+ assert result == 1
+
+ def test_get_device_id_by_aid_radiator_valve(self, state_manager_with_db_devices):
+ """Test retrieving radiator valve by aid."""
+ result = state_manager_with_db_devices.get_device_id_by_aid(222)
+ assert result == 2
+
+ def test_get_device_id_by_aid_not_found(self, state_manager):
+ """Test that unknown aid returns None."""
+ result = state_manager.get_device_id_by_aid(999)
+ assert result is None
+
+
+class TestDeviceInfoCacheFromDatabase:
+ def test_device_info_cache_loaded_from_database(self, state_manager_with_db_devices):
+ """Test that device_info_cache is populated from database."""
+ assert len(state_manager_with_db_devices.device_info_cache) >= 3
+
+ # Verify thermostat device
+ device_1 = state_manager_with_db_devices.get_device_info(1)
+ assert device_1['serial_number'] == 'RU0208A26ABC123'
+ assert device_1['device_type'] == 'thermostat'
+ assert device_1['aid'] == 221
+
+ def test_device_id_cache_loaded_from_database(self, state_manager_with_db_devices):
+ """Test that device_id_cache is populated from serial numbers."""
+ assert 'RU0208A26ABC123' in state_manager_with_db_devices.device_id_cache
+ assert state_manager_with_db_devices.device_id_cache['RU0208A26ABC123'] == 1
+
+ assert 'VA0210A26ABC456' in state_manager_with_db_devices.device_id_cache
+ assert state_manager_with_db_devices.device_id_cache['VA0210A26ABC456'] == 2
+
+ def test_aid_to_device_id_cache_loaded_from_database(self, state_manager_with_db_devices):
+ """Test that aid_to_device_id mapping is populated."""
+ assert state_manager_with_db_devices.get_device_id_by_aid(221) == 1
+ assert state_manager_with_db_devices.get_device_id_by_aid(222) == 2
+ assert state_manager_with_db_devices.get_device_id_by_aid(223) == 3
+
+ def test_zone_info_in_device_cache(self, state_manager_with_db_devices):
+ """Test that zone information is loaded into device cache."""
+ device_1 = state_manager_with_db_devices.get_device_info(1)
+ assert device_1['zone_name'] == 'Living Room'
+ assert device_1['zone_id'] == 1
+
+ device_2 = state_manager_with_db_devices.get_device_info(2)
+ assert device_2['zone_name'] == 'Bedroom'
+
+ def test_internet_bridge_no_zone(self, state_manager_with_db_devices):
+ """Test that internet bridge has no zone assignment."""
+ device_3 = state_manager_with_db_devices.get_device_info(3)
+ assert device_3['zone_id'] is None
+ assert device_3['zone_name'] is None
+
+
+class TestGetAllDevices:
+ def test_get_all_devices_from_database(self, state_manager_with_db_devices):
+ """Test retrieving all devices with database data."""
+ devices = state_manager_with_db_devices.get_all_devices()
+
+ assert len(devices) >= 3
+
+ # Verify device 1
+ device_1 = next((d for d in devices if d['device_id'] == 1), None)
+ assert device_1 is not None
+ assert device_1['serial_number'] == 'RU0208A26ABC123'
+ assert device_1['device_type'] == 'thermostat'
+
+ # Verify device 2
+ device_2 = next((d for d in devices if d['device_id'] == 2), None)
+ assert device_2 is not None
+ assert device_2['device_type'] == 'radiator_valve'
+
+ # Verify device 3
+ device_3 = next((d for d in devices if d['device_id'] == 3), None)
+ assert device_3 is not None
+ assert device_3['device_type'] == 'internet_bridge'
+
+ def test_get_all_devices_has_zone_names(self, state_manager_with_db_devices):
+ """Test that all devices include zone information."""
+ devices = state_manager_with_db_devices.get_all_devices()
+
+ for device in devices:
+ assert 'zone_name' in device
+ assert 'zone_id' in device
+ if device['device_id'] in [1, 2]:
+ assert device['zone_name'] is not None
+ elif device['device_id'] == 3:
+ assert device['zone_name'] is None
+
+
+class TestUpdateDeviceCharacteristic:
+ def test_update_device_characteristic_creates_state_if_not_exists(self, state_manager):
+ """Test that device state is created if it doesn't exist."""
+ state_manager.update_device_characteristic(
+ 1,
+ state_manager.CHAR_CURRENT_TEMPERATURE,
+ 20.5,
+ time.time()
+ )
+
+ assert 1 in state_manager.current_state
+ assert state_manager.current_state[1]['current_temperature'] == 20.5
+ assert state_manager.last_saved_bucket.get(1) is not None
+
+ def test_update_device_characteristic_no_change_returns_none(self, state_manager):
+ """Test that no change returns None."""
+ state_manager.current_state[1] = {'current_temperature': 20.5}
+
+ result = state_manager.update_device_characteristic(
+ 1,
+ state_manager.CHAR_CURRENT_TEMPERATURE,
+ 20.5,
+ time.time()
+ )
+
+ assert result == (None, None, None)
+
+ def test_update_device_characteristic_detects_change(self, state_manager):
+ """Test that temperature change is detected."""
+ state_manager.current_state[1] = {'current_temperature': 20.5}
+ state_manager.last_saved_bucket[1] = '20260217193500'
+
+ field_name, old_value, new_value = state_manager.update_device_characteristic(
+ 1,
+ state_manager.CHAR_CURRENT_TEMPERATURE,
+ 21.5,
+ time.time()
+ )
+
+ assert field_name == 'current_temperature'
+ assert old_value == 20.5
+ assert new_value == 21.5
+
+ def test_update_device_characteristic_unknown_type_returns_none(self, state_manager):
+ """Test that unknown characteristic type returns None."""
+ result = state_manager.update_device_characteristic(
+ 1,
+ 'unknown-uuid-0000-0000-0000-000000000000',
+ 42,
+ time.time()
+ )
+
+ assert result == (None, None, None)
+
+ def test_update_device_characteristic_saves_to_history(self, state_manager):
+ """Test that characteristic changes are saved to history."""
+ state_manager.current_state[1] = {'current_temperature': 20.0}
+ state_manager.last_saved_bucket[1] = state_manager._get_timestamp_bucket(time.time() - 100)
+
+ state_manager.update_device_characteristic(
+ 1,
+ state_manager.CHAR_CURRENT_TEMPERATURE,
+ 21.0,
+ time.time()
+ )
+
+ # Verify history was saved
+ conn = sqlite3.connect(state_manager.db_path)
+ cursor = conn.execute(
+ "SELECT current_temperature FROM device_state_history WHERE device_id = ?",
+ (1,)
+ )
+ row = cursor.fetchone()
+ conn.close()
+
+ assert row is not None
+ assert row[0] == 21.0
+
+ def test_update_characteristic_with_db_device(self, state_manager_with_db_devices):
+ """Test updating characteristic for device loaded from database."""
+ device_id = 1
+
+ result = state_manager_with_db_devices.update_device_characteristic(
+ device_id,
+ state_manager_with_db_devices.CHAR_CURRENT_TEMPERATURE,
+ 22.5,
+ time.time()
+ )
+
+ assert result[0] == 'current_temperature'
+ assert result[2] == 22.5
+
+ # Verify device info is still accessible
+ device_info = state_manager_with_db_devices.get_device_info(device_id)
+ assert device_info['serial_number'] == 'RU0208A26ABC123'
+
+ def test_multiple_device_updates(self, state_manager_with_db_devices):
+ """Test updating multiple devices from database."""
+ manager = state_manager_with_db_devices
+
+ # Update device 1
+ manager.update_device_characteristic(
+ 1,
+ manager.CHAR_CURRENT_TEMPERATURE,
+ 20.0,
+ time.time()
+ )
+
+ # Update device 2
+ manager.update_device_characteristic(
+ 2,
+ manager.CHAR_CURRENT_HUMIDITY,
+ 50,
+ time.time()
+ )
+
+ # Verify both states
+ state_1 = manager.get_current_state(1)
+ assert state_1['current_temperature'] == 20.0
+
+ state_2 = manager.get_current_state(2)
+ assert state_2['humidity'] == 50
+
+ def test_get_current_state_none(self, state_manager):
+ """Test that node device type returns all states."""
+ state_manager.current_state[1] = {'current_temperature': 20.0}
+ state_manager.current_state[2] = {'current_temperature': 22.0}
+
+ state = state_manager.get_current_state(1)
+ assert state == {'current_temperature': 20.0}
+
+ state = state_manager.get_current_state(None)
+ assert state == {1: {'current_temperature': 20.0}, 2: {'current_temperature': 22.0}}
+
+class TestUpdateDeviceWindowStatus:
+ def test_update_device_window_status_open(self, state_manager):
+ """Test updating window status to open."""
+ state_manager.current_state[1] = {}
+
+ state_manager.update_device_window_status(1, 1)
+
+ assert state_manager.current_state[1]['window'] == 1
+ assert 'window_lastupdate' in state_manager.current_state[1]
+
+ def test_update_device_window_status_no_change(self, state_manager):
+ """Test that no change doesn't update timestamp."""
+ state_manager.current_state[1] = {'window': 0, 'window_lastupdate': 100.0}
+
+ state_manager.update_device_window_status(1, 0)
+
+ assert state_manager.current_state[1]['window_lastupdate'] == 100.0
+
+ def test_update_device_window_status_closed(self, state_manager):
+ """Test updating window status to closed."""
+ state_manager.current_state[1] = {'window': 1}
+
+ state_manager.update_device_window_status(1, 0)
+
+ assert state_manager.current_state[1]['window'] == 0
+
+ def test_update_device_window_status_saves_to_history(self, state_manager):
+ """Test that window status is saved to history."""
+ state_manager.current_state[1] = {'window': 0}
+ state_manager.last_saved_bucket[1] = state_manager._get_timestamp_bucket(time.time() - 100)
+
+ state_manager.update_device_window_status(1, 1)
+
+ # Verify history was saved
+ conn = sqlite3.connect(state_manager.db_path)
+ cursor = conn.execute(
+ "SELECT window FROM device_state_history WHERE device_id = ?",
+ (1,)
+ )
+ row = cursor.fetchone()
+ conn.close()
+
+ assert row is not None
+ assert row[0] == 1
+
+
+class TestGetDeviceHistory:
+ def test_get_device_history_empty(self, state_manager):
+ """Test retrieving history for device with no records."""
+ result = state_manager.get_device_history(999)
+
+ assert result == []
+
+ def test_get_device_history_with_limit(self, state_manager_with_db_devices):
+ """Test that limit parameter works."""
+ result = state_manager_with_db_devices.get_device_history(1, limit=2)
+
+ assert isinstance(result, list)
+ assert len(result) == 2
+ assert result[0]['state']['cur_temp_c'] == 23.5 # newstest that latest record is returned first
+
+ def test_get_device_history_with_limit_and_offset(self, state_manager_with_db_devices):
+ """Test that limit and offset parameter works."""
+ result = state_manager_with_db_devices.get_device_history(1, limit=2, offset=1)
+
+ assert isinstance(result, list)
+ assert len(result) == 2
+ assert result[0]['state']['cur_temp_c'] == 21.5 # newstest that offset skips is returns the latest first
+
+ def test_get_device_history_standardized_format(self, state_manager):
+ """Test that history has standardized format."""
+ state_manager.current_state[1] = {
+ 'current_temperature': 20.5,
+ 'target_temperature': 21.0,
+ 'current_heating_cooling_state': 1,
+ 'target_heating_cooling_state': 1,
+ 'humidity': 55
+ }
+ state_manager._save_to_history(1, time.time())
+
+ result = state_manager.get_device_history(1, limit=1)
+
+ assert len(result) == 1
+ record = result[0]
+ assert 'state' in record
+ assert 'timestamp' in record
+ assert 'cur_temp_c' in record['state']
+ assert 'cur_temp_f' in record['state']
+ assert record['state']['cur_temp_c'] == 20.5
+ assert abs(record['state']['cur_temp_f'] - 68.9) < 0.1
+
+ def test_get_device_history_with_wrong_device_id(self, state_manager):
+ """Test that history has standardized format."""
+ state_manager.current_state[1] = {
+ 'current_temperature': 20.5,
+ 'target_temperature': 21.0,
+ 'current_heating_cooling_state': 1,
+ 'target_heating_cooling_state': 1,
+ 'humidity': 55
+ }
+ state_manager._save_to_history(999, time.time())
+
+ result = state_manager.get_device_history(1, limit=1)
+
+ assert len(result) == 0
+
+ def test_get_device_history_start_time_validation(self, state_manager_with_db_devices):
+ """Validate that start_time filters out older records."""
+ base_ts = 1_700_000_000.0 # matches fixture seed
+ result = state_manager_with_db_devices.get_device_history(1, start_time=base_ts+20)
+
+ assert isinstance(result, list)
+ assert len(result) == 2
+ returned_temps = [r.get("state", {}).get("cur_temp_c") for r in result]
+ assert 20.0 not in returned_temps # first seeded record filtered out
+
+ def test_get_device_history_end_time_validation(self, state_manager_with_db_devices):
+ """Validate that end_time filters out newer records."""
+ base_ts = 1_700_000_000.0 # matches fixture seed
+ result = state_manager_with_db_devices.get_device_history(1, end_time=base_ts+80)
+
+ assert isinstance(result, list)
+ assert len(result) == 2
+ returned_temps = [r.get("state", {}).get("cur_temp_c") for r in result]
+ assert 23.5 not in returned_temps # second seeded record filtered out
+
+ def test_get_device_history_start_end_time_window_validation(self, state_manager_with_db_devices):
+ """Validate combined start_time/end_time returns only records in range."""
+ base_ts = 1_700_000_000.0 # matches fixture seed
+ # Include second record (~+61s), exclude first (~+0s)
+ result = state_manager_with_db_devices.get_device_history(
+ 1,
+ start_time=base_ts + 30,
+ end_time=base_ts + 90
+ )
+
+ assert isinstance(result, list)
+ returned_temps = [r.get("state", {}).get("cur_temp_c") for r in result]
+ assert 21.5 in returned_temps
+ assert 20.0 not in returned_temps
+
+ def test_get_device_history_invalid_time_range_validation(self, state_manager_with_db_devices):
+ """Validate behavior when start_time is greater than end_time."""
+ base_ts = 1_700_000_000.0
+ result = state_manager_with_db_devices.get_device_history(
+ 1,
+ start_time=base_ts + 200,
+ end_time=base_ts + 100
+ )
+
+ assert result == [] or isinstance(result, list)
+
+
+class TestCharacteristicMapping:
+ def test_characteristic_mapping_temperature(self, state_manager):
+ """Test that temperature characteristics are mapped correctly."""
+ state_manager.current_state[1] = {}
+ state_manager.last_saved_bucket[1] = state_manager._get_timestamp_bucket(time.time() - 100)
+
+ state_manager.update_device_characteristic(
+ 1,
+ state_manager.CHAR_CURRENT_TEMPERATURE,
+ 20.5,
+ time.time()
+ )
+
+ assert state_manager.current_state[1]['current_temperature'] == 20.5
+
+ def test_characteristic_mapping_heating_cooling(self, state_manager):
+ """Test that heating/cooling state is mapped correctly."""
+ state_manager.current_state[1] = {}
+ state_manager.last_saved_bucket[1] = state_manager._get_timestamp_bucket(time.time() - 100)
+
+ state_manager.update_device_characteristic(
+ 1,
+ state_manager.CHAR_CURRENT_HEATING_COOLING,
+ 1,
+ time.time()
+ )
+
+ assert state_manager.current_state[1]['current_heating_cooling_state'] == 1
+
+ def test_characteristic_mapping_humidity(self, state_manager):
+ """Test that humidity is mapped correctly."""
+ state_manager.current_state[1] = {}
+ state_manager.last_saved_bucket[1] = state_manager._get_timestamp_bucket(time.time() - 100)
+
+ state_manager.update_device_characteristic(
+ 1,
+ state_manager.CHAR_CURRENT_HUMIDITY,
+ 255,
+ time.time()
+ )
+
+ assert state_manager.current_state[1]['humidity'] == 255
+
+class TestDeviceStateLoadLatestFromDatabase:
+ def test_load_latest_state_from_database(self, state_manager_with_db_devices):
+ """Test that latest state is loaded from database on initialization."""
+ # Insert a state record into the database
+ conn = sqlite3.connect(state_manager_with_db_devices.db_path)
+ bucket = state_manager_with_db_devices._get_timestamp_bucket(time.time())
+ conn.execute("""
+ INSERT INTO device_state_history (device_id, current_temperature, timestamp_bucket)
+ VALUES (1, 22.5, ?)
+ """, (bucket,))
+ conn.commit()
+ conn.close()
+
+ # Create a new manager instance to load from database
+ new_manager = DeviceStateManager(state_manager_with_db_devices.db_path)
+
+ # Verify that the latest state is loaded into current_state
+ assert 1 in new_manager.current_state
+ assert new_manager.current_state[1]['current_temperature'] == 22.5
+
+class TestLoadLatestStateFromDatabase:
+ def test_load_latest_state_empty_database(self, state_manager):
+ """Test loading state when database is empty."""
+ state_manager._load_latest_state_from_db()
+
+ assert len(state_manager.current_state) == 0
+ assert len(state_manager.last_saved_bucket) == 0
+ assert len(state_manager.bucket_state_snapshot) == 0
+
+ def test_load_latest_state_single_device(self, state_manager):
+ """Test loading latest state for a single device."""
+ # Insert a device state record
+ conn = sqlite3.connect(state_manager.db_path)
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217193000', 20.5, 21.0, 1, 1, 5.0, 35.0, 0, 100, 0, 55, 50, 1, 0, 0, NULL)
+ """)
+ conn.commit()
+ conn.close()
+
+ state_manager._load_latest_state_from_db()
+
+ assert 1 in state_manager.current_state
+ assert state_manager.current_state[1]['current_temperature'] == 20.5
+ assert state_manager.current_state[1]['target_temperature'] == 21.0
+ assert state_manager.last_saved_bucket[1] == '20260217193000'
+
+ def test_load_latest_state_multiple_devices(self, state_manager):
+ """Test loading latest state for multiple devices."""
+ conn = sqlite3.connect(state_manager.db_path)
+
+ # Insert state for device 1
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217193000', 20.5, 21.0, 1, 1, 5.0, 35.0, 0, 100, 0, 55, 50, 1, 0, 0, NULL)
+ """)
+
+ # Insert state for device 2
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (2, '20260217193500', 19.0, 20.0, 0, 0, 5.0, 35.0, 0, 85, 0, 60, 50, 0, 50, 1, ?)
+ """, (time.time(),))
+
+ conn.commit()
+ conn.close()
+
+ state_manager._load_latest_state_from_db()
+
+ assert len(state_manager.current_state) == 2
+ assert state_manager.current_state[1]['current_temperature'] == 20.5
+ assert state_manager.current_state[2]['current_temperature'] == 19.0
+ assert state_manager.current_state[2]['window'] == 1
+
+ def test_load_latest_state_gets_most_recent_bucket(self, state_manager):
+ """Test that only the most recent timestamp_bucket is loaded."""
+ conn = sqlite3.connect(state_manager.db_path)
+
+ # Insert older state
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217190000', 18.0, 19.0, 0, 0, 5.0, 35.0, 0, 100, 0, 50, 50, 0, 0, 0, NULL)
+ """)
+
+ # Insert newer state
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217195000', 22.0, 23.0, 1, 1, 5.0, 35.0, 0, 100, 0, 55, 50, 1, 0, 0, NULL)
+ """)
+
+ conn.commit()
+ conn.close()
+
+ state_manager._load_latest_state_from_db()
+
+ # Should load the most recent state (22.0, not 18.0)
+ assert state_manager.current_state[1]['current_temperature'] == 22.0
+ assert state_manager.current_state[1]['target_temperature'] == 23.0
+ assert state_manager.last_saved_bucket[1] == '20260217195000'
+
+ def test_load_latest_state_populates_snapshot(self, state_manager):
+ """Test that bucket_state_snapshot is set to match current_state."""
+ conn = sqlite3.connect(state_manager.db_path)
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217193000', 20.5, 21.0, 1, 1, 5.0, 35.0, 0, 100, 0, 55, 50, 1, 0, 0, NULL)
+ """)
+ conn.commit()
+ conn.close()
+
+ state_manager._load_latest_state_from_db()
+
+ assert 1 in state_manager.bucket_state_snapshot
+ assert state_manager.bucket_state_snapshot[1] == state_manager.current_state[1]
+
+ def test_load_latest_state_all_fields(self, state_manager):
+ """Test that all state fields are loaded correctly."""
+ conn = sqlite3.connect(state_manager.db_path)
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217193000', 20.5, 21.0, 1, 1, 10.0, 30.0, 1, 95, 1, 55, 45, 1, 75, 1, 1645098000.5)
+ """)
+ conn.commit()
+ conn.close()
+
+ state_manager._load_latest_state_from_db()
+
+ state = state_manager.current_state[1]
+ assert state['current_temperature'] == 20.5
+ assert state['target_temperature'] == 21.0
+ assert state['current_heating_cooling_state'] == 1
+ assert state['target_heating_cooling_state'] == 1
+ assert state['heating_threshold_temperature'] == 10.0
+ assert state['cooling_threshold_temperature'] == 30.0
+ assert state['temperature_display_units'] == 1
+ assert state['battery_level'] == 95
+ assert state['status_low_battery'] == 1
+ assert state['humidity'] == 55
+ assert state['target_humidity'] == 45
+ assert state['active_state'] == 1
+ assert state['valve_position'] == 75
+ assert state['window'] == 1
+ assert state['window_lastupdate'] == 1645098000.5
+
+ def test_load_latest_state_with_null_values(self, state_manager):
+ """Test loading state with NULL values."""
+ conn = sqlite3.connect(state_manager.db_path)
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217193000', 20.5, NULL, 1, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)
+ """)
+ conn.commit()
+ conn.close()
+
+ state_manager._load_latest_state_from_db()
+
+ state = state_manager.current_state[1]
+ assert state['current_temperature'] == 20.5
+ assert state['target_temperature'] is None
+ assert state['heating_threshold_temperature'] is None
+ assert state['window_lastupdate'] is None
+
+ def test_load_latest_state_initializes_caches(self, state_manager):
+ """Test that caches are properly initialized."""
+ conn = sqlite3.connect(state_manager.db_path)
+ for i in range(1, 4):
+ conn.execute(f"""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES ({i}, '2026021719300{i}', {18 + i}, {19 + i}, 0, 0, 5.0, 35.0, 0, 100, 0, 50, 50, 0, 0, 0, NULL)
+ """)
+ conn.commit()
+ conn.close()
+
+ state_manager._load_latest_state_from_db()
+
+ # Verify all three devices and their caches are initialized
+ assert len(state_manager.current_state) == 3
+ assert len(state_manager.last_saved_bucket) == 3
+ assert len(state_manager.bucket_state_snapshot) == 3
+
+ for device_id in [1, 2, 3]:
+ assert device_id in state_manager.current_state
+ assert device_id in state_manager.last_saved_bucket
+ assert device_id in state_manager.bucket_state_snapshot
+
+ def test_load_latest_state_with_db_devices(self, state_manager_with_db_devices):
+ """Test loading latest state for devices from database."""
+ manager = state_manager_with_db_devices
+
+ # Insert state for each device
+ conn = sqlite3.connect(manager.db_path)
+ conn.execute("""
+ INSERT INTO device_state_history
+ (device_id, timestamp_bucket, current_temperature, target_temperature,
+ current_heating_cooling_state, target_heating_cooling_state,
+ heating_threshold_temperature, cooling_threshold_temperature,
+ temperature_display_units, battery_level, status_low_battery,
+ humidity, target_humidity, active_state, valve_position, window, window_lastupdate)
+ VALUES (1, '20260217193000', 26.5, 21.0, 1, 1, 5.0, 35.0, 0, 100, 0, 55, 50, 1, 0, 0, NULL)
+ """)
+ conn.commit()
+ conn.close()
+
+ # Create a new manager to trigger load
+ new_manager = DeviceStateManager(manager.db_path)
+
+ # Verify device state was loaded
+ assert 1 in new_manager.current_state
+ assert new_manager.current_state[1]['current_temperature'] == 26.5
+
+ # Verify device info cache still works
+ device_info = new_manager.get_device_info(1)
+ assert device_info['serial_number'] == 'RU0208A26ABC123'
+
+
+class TestGetOrCreateDevice:
+ def test_returns_existing_device_when_serial_in_cache(self, state_manager_with_db_devices):
+ """Existing serial returns existing device_id."""
+ manager = state_manager_with_db_devices
+
+ existing_id = manager.device_id_cache['RU0208A26ABC123']
+ result = manager.get_or_create_device('RU0208A26ABC123', 1, {})
+
+ assert result == existing_id
+
+ def test_updates_aid_if_changed_for_existing_device(self, state_manager_with_db_devices):
+ """If aid changed, DB and caches are updated."""
+ manager = state_manager_with_db_devices
+ device_id = manager.device_id_cache['RU0208A26ABC123']
+
+ # old aid from fixture is 1; change to 221
+ result = manager.get_or_create_device('RU0208A26ABC123', 221, {})
+
+ assert result == device_id
+ assert manager.get_device_id_by_aid(221) == device_id
+ assert manager.device_info_cache[device_id]['aid'] == 221
+
+ conn = sqlite3.connect(manager.db_path)
+ row = conn.execute(
+ "SELECT aid FROM devices WHERE device_id = ?",
+ (device_id,)
+ ).fetchone()
+ conn.close()
+
+ assert row is not None
+ assert row[0] == 221
+
+ def test_creates_new_device_from_accessory_data(self, state_manager):
+ """Creates a new device and parses name/model/manufacturer/type."""
+ accessory_data = {
+ "services": [
+ {
+ "type": "0000003e-0000-1000-8000-0026bb765291", # AccessoryInformation
+ "characteristics": [
+ {"type": "00000023-0000-1000-8000-0026bb765291", "value": "Living Room Thermostat"},
+ {"type": "00000021-0000-1000-8000-0026bb765291", "value": "Smart Thermostat X"},
+ {"type": "00000020-0000-1000-8000-0026bb765291", "value": "Tado"},
+
+ ],
+ },
+ {
+ "type": "0000004a-0000-1000-8000-0026bb765291", # Thermostat service
+ "characteristics": [],
+ },
+ ]
+ }
+
+ device_id = state_manager.get_or_create_device("RU9999TEST123", 555, accessory_data)
+
+ assert isinstance(device_id, int)
+ assert device_id > 0
+ assert state_manager.device_id_cache["RU9999TEST123"] == device_id
+ assert state_manager.get_device_id_by_aid(555) == device_id
+
+ info = state_manager.get_device_info(device_id)
+ assert info["serial_number"] == "RU9999TEST123"
+ assert info["aid"] == 555
+ assert info["name"] == "Living Room Thermostat"
+ assert info["device_type"] == "thermostat"
+
+ conn = sqlite3.connect(state_manager.db_path)
+ row = conn.execute(
+ "SELECT serial_number, aid, device_type, name, model, manufacturer FROM devices WHERE device_id = ?",
+ (device_id,)
+ ).fetchone()
+ conn.close()
+
+ assert row == ("RU9999TEST123", 555, "thermostat", "Living Room Thermostat", "Smart Thermostat X", "Tado")
+
+ def test_detects_device_type_from_serial_prefix_when_service_unknown(self, state_manager):
+ """Falls back to serial prefix mapping when no known service type."""
+ device_id = state_manager.get_or_create_device(
+ "VA0210A26XYZ999",
+ 777,
+ {"services": []},
+ )
+
+ info = state_manager.get_device_info(device_id)
+ assert info["device_type"] == "radiator_valve"
+
+ def test_does_not_add_aid_mapping_when_aid_is_falsy(self, state_manager):
+ """When aid is 0/None, aid_to_device_id should not be populated."""
+ device_id = state_manager.get_or_create_device(
+ "IB01170626NOAID",
+ 0,
+ {"services": []},
+ )
+
+ assert device_id > 0
+ assert state_manager.get_device_id_by_aid(0) is None
+ assert state_manager.get_device_info(device_id)["device_type"] == "internet_bridge"
+
+ def test_creates_temperature_sensor_from_service_type(self, state_manager):
+ """Service type 0000008a maps to temperature_sensor."""
+ accessory_data = {
+ "services": [
+ {
+ "type": "0000003e-0000-1000-8000-0026bb765291",
+ "characteristics": [
+ {"type": "00000023-0000-1000-8000-0026bb765291", "value": "Temp Sensor"},
+ ],
+ },
+ {
+ "type": "0000008a-0000-1000-8000-0026bb765291",
+ "characteristics": [],
+ },
+ ]
+ }
+
+ device_id = state_manager.get_or_create_device("TS0001ABC", 901, accessory_data)
+ info = state_manager.get_device_info(device_id)
+
+ assert info["device_type"] == "temperature_sensor"
+ assert info["name"] == "Temp Sensor"
+ assert state_manager.get_device_id_by_aid(901) == device_id
+
+ def test_creates_humidity_sensor_from_service_type(self, state_manager):
+ """Service type 00000082 maps to humidity_sensor."""
+ accessory_data = {
+ "services": [
+ {
+ "type": "0000003e-0000-1000-8000-0026bb765291",
+ "characteristics": [
+ {"type": "00000023-0000-1000-8000-0026bb765291", "value": "Humidity Sensor"},
+ ],
+ },
+ {
+ "type": "00000082-0000-1000-8000-0026bb765291",
+ "characteristics": [],
+ },
+ ]
+ }
+
+ device_id = state_manager.get_or_create_device("HS0001ABC", 902, accessory_data)
+ info = state_manager.get_device_info(device_id)
+
+ assert info["device_type"] == "humidity_sensor"
+ assert info["name"] == "Humidity Sensor"
+ assert state_manager.get_device_id_by_aid(902) == device_id
+
+ def test_creates_thermostat_from_ru_serial_prefix(self, state_manager):
+ """Unknown service type falls back to RU -> thermostat."""
+ accessory_data = {
+ "services": [
+ {
+ "type": "0000003e-0000-1000-8000-0026bb765291",
+ "characteristics": [
+ {"type": "00000023-0000-1000-8000-0026bb765291", "value": "Room Unit"},
+ ],
+ }
+ ]
+ }
+
+ device_id = state_manager.get_or_create_device("RU0208A26ABC777", 904, accessory_data)
+ info = state_manager.get_device_info(device_id)
+
+ assert info["device_type"] == "thermostat"
+ assert info["name"] == "Room Unit"
+ assert state_manager.get_device_id_by_aid(904) == device_id
+
+ def test_creates_wireless_receiver_from_wr_serial_prefix(self, state_manager):
+ """Unknown service type falls back to WR -> wireless_receiver."""
+ accessory_data = {
+ "services": [
+ {
+ "type": "0000003e-0000-1000-8000-0026bb765291",
+ "characteristics": [
+ {"type": "00000023-0000-1000-8000-0026bb765291", "value": "Wireless Receiver"},
+ ],
+ }
+ ]
+ }
+
+ device_id = state_manager.get_or_create_device("WR0108A26XYZ777", 905, accessory_data)
+ info = state_manager.get_device_info(device_id)
+
+ assert info["device_type"] == "wireless_receiver"
+ assert info["name"] == "Wireless Receiver"
+ assert state_manager.get_device_id_by_aid(905) == device_id
+
+
+class TestHasStateChanged:
+ def test_has_state_changed_returns_true_when_no_snapshot(self, state_manager):
+ """No snapshot for device should be treated as changed."""
+ state_manager.current_state[1] = {"current_temperature": 20.0}
+
+ assert state_manager._has_state_changed(1) is True
+
+ def test_has_state_changed_returns_false_when_state_matches_snapshot(self, state_manager):
+ """Matching current state and snapshot should return False."""
+ state_manager.current_state[1] = {
+ "current_temperature": 20.0,
+ "target_temperature": 21.0,
+ "humidity": 50,
+ "window": 0,
+ }
+ state_manager.bucket_state_snapshot[1] = {
+ "current_temperature": 20.0,
+ "target_temperature": 21.0,
+ "humidity": 50,
+ "window": 0,
+ }
+
+ assert state_manager._has_state_changed(1) is False
+
+ def test_has_state_changed_returns_true_when_data_field_differs(self, state_manager):
+ """Any tracked data field difference should return True."""
+ state_manager.current_state[1] = {
+ "current_temperature": 21.5,
+ "target_temperature": 21.0,
+ "humidity": 50,
+ "window": 0,
+ }
+ state_manager.bucket_state_snapshot[1] = {
+ "current_temperature": 20.0,
+ "target_temperature": 21.0,
+ "humidity": 50,
+ "window": 0,
+ }
+
+ assert state_manager._has_state_changed(1) is True
+
+ def test_has_state_changed_ignores_metadata_fields(self, state_manager):
+ """Fields not in data_fields (like window_lastupdate/last_update) are ignored."""
+ state_manager.current_state[1] = {
+ "current_temperature": 20.0,
+ "target_temperature": 21.0,
+ "humidity": 50,
+ "window": 0,
+ "window_lastupdate": 2000.0,
+ "last_update": 3000.0,
+ }
+ state_manager.bucket_state_snapshot[1] = {
+ "current_temperature": 20.0,
+ "target_temperature": 21.0,
+ "humidity": 50,
+ "window": 0,
+ "window_lastupdate": 1000.0,
+ "last_update": 1500.0,
+ }
+
+ assert state_manager._has_state_changed(1) is False
+
+
+class TestClearOptimisticState:
+ def test_clear_optimistic_state_noop_when_device_not_present(self, state_manager):
+ """Should do nothing when device has no optimistic state."""
+ state_manager.current_state[1] = {"target_temperature": 21.0}
+
+ with patch("tado_local.state.logger") as mock_logger:
+ state_manager.clear_optimistic_state(999)
+
+ assert 999 not in state_manager.optimistic_state
+ assert 999 not in state_manager.optimistic_timestamps
+ mock_logger.info.assert_not_called()
+ mock_logger.debug.assert_not_called()
+
+ def test_clear_optimistic_state_clears_entries_without_mismatch_log(self, state_manager):
+ """Should clear optimistic caches and not log mismatch when values match."""
+ device_id = 1
+ state_manager.optimistic_state[device_id] = {
+ "target_temperature": 21.0,
+ "window": 0,
+ }
+ state_manager.optimistic_timestamps[device_id] = 1700000000.0
+ state_manager.current_state[device_id] = {
+ "target_temperature": 21.0,
+ "window": 0,
+ }
+
+ with patch("tado_local.state.logger") as mock_logger:
+ state_manager.clear_optimistic_state(device_id)
+
+ assert device_id not in state_manager.optimistic_state
+ assert device_id not in state_manager.optimistic_timestamps
+ mock_logger.info.assert_not_called()
+ mock_logger.debug.assert_called_once()
+
+ def test_clear_optimistic_state_logs_mismatch_and_clears(self, state_manager):
+ """Should log mismatch when actual state differs from optimistic prediction."""
+ device_id = 2
+ state_manager.optimistic_state[device_id] = {
+ "target_temperature": 22.0,
+ "window": 1,
+ }
+ state_manager.optimistic_timestamps[device_id] = 1700000001.0
+ state_manager.current_state[device_id] = {
+ "target_temperature": 20.0, # mismatch
+ "window": 0, # mismatch
+ }
+
+ with patch("tado_local.state.logger") as mock_logger:
+ state_manager.clear_optimistic_state(device_id)
+
+ assert device_id not in state_manager.optimistic_state
+ assert device_id not in state_manager.optimistic_timestamps
+
+ mock_logger.info.assert_called_once()
+ msg = mock_logger.info.call_args[0][0]
+ assert "Optimistic state was overridden by device" in msg
+ assert "target_temperature: predicted=22.0, actual=20.0" in msg
+ assert "window: predicted=1, actual=0" in msg
+ mock_logger.debug.assert_called_once()
+
+ def test_clear_optimistic_state_ignores_none_actual_values_for_mismatch(self, state_manager):
+ """None actual values should not count as mismatches."""
+ device_id = 3
+ state_manager.optimistic_state[device_id] = {
+ "target_temperature": 21.5,
+ "humidity": 55,
+ }
+ state_manager.optimistic_timestamps[device_id] = 1700000002.0
+ state_manager.current_state[device_id] = {
+ "target_temperature": None, # ignored by mismatch logic
+ "humidity": 55, # equal
+ }
+
+ with patch("tado_local.state.logger") as mock_logger:
+ state_manager.clear_optimistic_state(device_id)
+
+ assert device_id not in state_manager.optimistic_state
+ assert device_id not in state_manager.optimistic_timestamps
+ mock_logger.info.assert_not_called()
+ mock_logger.debug.assert_called_once()
+
+
+class TestGetStateWithOptimistic:
+ def test_returns_real_state_when_no_optimistic(self, state_manager):
+ state_manager.current_state[1] = {
+ "current_temperature": 20.0,
+ "target_temperature": 21.0,
+ }
+
+ result = state_manager.get_state_with_optimistic(1)
+
+ assert result["current_temperature"] == 20.0
+ assert result["target_temperature"] == 21.0
+
+ def test_applies_optimistic_overrides_when_not_expired(self, state_manager):
+ device_id = 1
+ state_manager.optimistic_timeout = 30
+ state_manager.current_state[device_id] = {
+ "current_temperature": 20.0,
+ "target_temperature": 21.0,
+ "window": 0,
+ }
+ state_manager.optimistic_state[device_id] = {
+ "target_temperature": 23.0,
+ "window": 1,
+ }
+ state_manager.optimistic_timestamps[device_id] = 1000.0
+
+ with patch("tado_local.state.time.time", return_value=1010.0):
+ result = state_manager.get_state_with_optimistic(device_id)
+
+ assert result["current_temperature"] == 20.0
+ assert result["target_temperature"] == 23.0 # overridden
+ assert result["window"] == 1 # overridden
+
+ def test_expired_optimistic_state_is_cleared(self, state_manager):
+ device_id = 2
+ state_manager.optimistic_timeout = 30
+ state_manager.current_state[device_id] = {"target_temperature": 20.0}
+ state_manager.optimistic_state[device_id] = {"target_temperature": 25.0}
+ state_manager.optimistic_timestamps[device_id] = 1000.0
+
+ with patch("tado_local.state.time.time", return_value=1040.0):
+ result = state_manager.get_state_with_optimistic(device_id)
+
+ # expired -> real state returned, optimistic removed
+ assert result["target_temperature"] == 20.0
+ assert device_id not in state_manager.optimistic_state
+ assert device_id not in state_manager.optimistic_timestamps
+
+ def test_returns_optimistic_only_when_real_state_missing(self, state_manager):
+ device_id = 3
+ state_manager.optimistic_timeout = 30
+ state_manager.optimistic_state[device_id] = {"target_temperature": 19.5}
+ state_manager.optimistic_timestamps[device_id] = 1000.0
+
+ with patch("tado_local.state.time.time", return_value=1005.0):
+ result = state_manager.get_state_with_optimistic(device_id)
+
+ assert result["target_temperature"] == 19.5
+
+ def test_result_is_copy_not_mutating_current_state(self, state_manager):
+ device_id = 4
+ state_manager.current_state[device_id] = {"target_temperature": 21.0}
+
+ result = state_manager.get_state_with_optimistic(device_id)
+ result["target_temperature"] = 99.0
+
+ assert state_manager.current_state[device_id]["target_temperature"] == 21.0
+
+
+class TestGetAllDevicesAdditional:
+ def test_get_all_devices_casts_bool_fields(self, state_manager_with_db_devices):
+ """Validate bool conversion for is_zone_leader and is_circuit_driver."""
+ # Force one TRUE value for is_circuit_driver to validate conversion
+ conn = sqlite3.connect(state_manager_with_db_devices.db_path)
+ conn.execute("UPDATE devices SET is_circuit_driver = 1 WHERE device_id = 2")
+ conn.commit()
+ conn.close()
+
+ devices = state_manager_with_db_devices.get_all_devices()
+
+ d1 = next(d for d in devices if d["device_id"] == 1)
+ d2 = next(d for d in devices if d["device_id"] == 2)
+ d3 = next(d for d in devices if d["device_id"] == 3)
+
+ assert isinstance(d1["is_zone_leader"], bool)
+ assert isinstance(d1["is_circuit_driver"], bool)
+ assert isinstance(d2["is_zone_leader"], bool)
+ assert isinstance(d2["is_circuit_driver"], bool)
+ assert isinstance(d3["is_zone_leader"], bool)
+ assert isinstance(d3["is_circuit_driver"], bool)
+
+ assert d1["is_zone_leader"] is True
+ assert d2["is_circuit_driver"] is True
+ assert d3["is_zone_leader"] is False # 0/NULL -> False
+
+
+class TestGetDeviceHistoryInfo:
+ def test_get_device_history_info_empty(self, state_manager):
+ result = state_manager.get_device_history_info(device_id=999, age=60)
+
+ assert result["history_count"] == 0
+ assert result["latest_entry"] is None
+ assert result["earliest_entry"] is None
+
+ def test_get_device_history_info_with_age_filter_and_order(self, state_manager):
+ conn = sqlite3.connect(state_manager.db_path)
+ conn.execute("""
+ INSERT INTO device_state_history (
+ device_id, timestamp_bucket, current_temperature, window, window_lastupdate, updated_at
+ ) VALUES
+ (1, '20260217193000', 20.0, 0, NULL, datetime('now', '-3 minutes')),
+ (1, '20260217193110', 21.5, 1, 1700000000.0, datetime('now', '-1 minutes')),
+ (1, '20260217180000', 10.0, 0, NULL, datetime('now', '-120 minutes'))
+ """)
+ conn.commit()
+ conn.close()
+
+ result = state_manager.get_device_history_info(device_id=1, age=10)
+
+ assert result["history_count"] == 2
+ # Ordered DESC by updated_at
+ assert result["latest_entry"][0] == 21.5
+ assert result["earliest_entry"][0] == 20.0
+
+
+class TestUpdateDeviceWindowStatusAdditional:
+ def test_update_device_window_status_calls_save_and_logs_on_change(self, state_manager):
+ state_manager.current_state[1] = {"window": 0}
+
+ with patch.object(state_manager, "_save_to_history") as mock_save, \
+ patch("tado_local.state.time.time", side_effect=[1700000000.0, 1700000001.0]), \
+ patch("tado_local.state.logger") as mock_logger:
+ state_manager.update_device_window_status(1, 2)
+
+ assert state_manager.current_state[1]["window"] == 2
+ assert state_manager.current_state[1]["window_lastupdate"] == 1700000000.0
+ mock_save.assert_called_once_with(1, 1700000001.0)
+ mock_logger.info.assert_called_once()
+
+ def test_update_device_window_status_does_not_save_when_unchanged(self, state_manager):
+ state_manager.current_state[1] = {"window": 1, "window_lastupdate": 123.0}
+
+ with patch.object(state_manager, "_save_to_history") as mock_save, \
+ patch("tado_local.state.logger") as mock_logger:
+ state_manager.update_device_window_status(1, 1)
+
+ assert state_manager.current_state[1]["window_lastupdate"] == 123.0
+ mock_save.assert_not_called()
+ mock_logger.info.assert_not_called()
+
+ def test_update_device_window_status_does_not_save_when_new_id(self, state_manager):
+ state_manager.current_state[1] = {"window": 1, "window_lastupdate": 123.0}
+
+ with patch.object(state_manager, "_save_to_history") as mock_save:
+ state_manager.update_device_window_status(99, 0)
+
+ print(state_manager.current_state)
+ assert state_manager.current_state[99]["window"] ==0
+ mock_save.assert_called_once()
diff --git a/tests/test_sync.py b/tests/test_sync.py
new file mode 100644
index 0000000..5f7ad88
--- /dev/null
+++ b/tests/test_sync.py
@@ -0,0 +1,317 @@
+import sqlite3
+from unittest.mock import MagicMock, patch
+import pytest
+
+from tado_local.database import ensure_schema_and_migrate
+from tado_local.sync import TadoCloudSync
+
+
+@pytest.fixture
+def temp_db(tmp_path):
+ db_path = tmp_path / "test_sync.db"
+ ensure_schema_and_migrate(str(db_path))
+ return str(db_path)
+
+
+@pytest.fixture
+def syncer(temp_db):
+ return TadoCloudSync(temp_db)
+
+
+class TestSyncHome:
+ def test_normalize_device_type(self, syncer):
+ """ Test that device types are normalized correctly. """
+ from tado_local.sync import normalize_device_type
+
+ type = normalize_device_type("VA02")
+ assert type == "radiator_valve"
+
+ type = normalize_device_type("IB01")
+ assert type == "internet_bridge"
+
+ type = normalize_device_type("SU02")
+ assert type == "smart_ac_control"
+
+ # Unknown types should return the original string
+ type = normalize_device_type("mock-name")
+ assert type == "mock-name"
+
+ # Nove value should return "unknown" and not raise an exception
+ try:
+ device_type = normalize_device_type(None)
+ except Exception as exc:
+ pytest.fail(f"normalize_device_type('None') raised unexpectedly: {exc}")
+ assert device_type == "unknown"
+
+
+ def test_sync_home_inserts_row(self, syncer):
+ home_data = {
+ "id": 123,
+ "name": "My Home",
+ "dateTimeZone": "Europe/Amsterdam",
+ "temperatureUnit": "CELSIUS",
+ }
+
+ assert syncer.sync_home(home_data) is True
+
+ conn = sqlite3.connect(syncer.db_path)
+ row = conn.execute(
+ "SELECT tado_home_id, name, timezone, temperature_unit FROM tado_homes WHERE tado_home_id = ?",
+ (123,),
+ ).fetchone()
+ conn.close()
+
+ assert row == (123, "My Home", "Europe/Amsterdam", "CELSIUS")
+
+ def test_sync_home_returns_false_on_error(self, syncer):
+ with patch("tado_local.sync.sqlite3.connect", side_effect=Exception("db down")):
+ assert syncer.sync_home({"id": 1, "name": "x"}) is False
+
+
+class TestSyncZones:
+ def test_sync_zones_creates_zone_devices_and_skips_hot_water(self, syncer):
+ zones_data = [
+ {
+ "id": 10,
+ "name": "Living Room",
+ "type": "HEATING",
+ "devices": [
+ {
+ "serialNo": "RU123456789",
+ "deviceType": "THERMOSTAT",
+ "currentFwVersion": "1.0",
+ "batteryState": "GOOD",
+ "duties": ["ZONE_LEADER"],
+ }
+ ],
+ },
+ {
+ "id": 20,
+ "name": "Hot Water",
+ "type": "HOT_WATER",
+ "devices": [{"serialNo": "HW1", "deviceType": "X"}],
+ },
+ ]
+
+ assert syncer.sync_zones(zones_data, home_id=1) is True
+
+ conn = sqlite3.connect(syncer.db_path)
+ zone_count = conn.execute(
+ "SELECT COUNT(*) FROM zones WHERE tado_home_id = ?",
+ (1,),
+ ).fetchone()[0]
+ device = conn.execute(
+ "SELECT serial_number, is_zone_leader FROM devices WHERE serial_number = ?",
+ ("RU123456789",),
+ ).fetchone()
+ leader = conn.execute(
+ "SELECT leader_device_id FROM zones WHERE tado_home_id = ? AND tado_zone_id = ?",
+ (1, 10),
+ ).fetchone()
+ conn.close()
+
+ assert zone_count == 1
+ assert device is not None
+ assert device[0] == "RU123456789"
+ assert device[1] in (1, True)
+ assert leader is not None
+ assert leader[0] is not None
+
+ def test_sync_zones_updates_existing_and_removes_stale(self, syncer):
+ conn = sqlite3.connect(syncer.db_path)
+ conn.execute(
+ "INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, order_id, uuid) " \
+ "VALUES (1, 7, 'Old', 'HEATING', 9, 'u1')"
+ )
+ conn.execute(
+ "INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, order_id, uuid) " \
+ "VALUES (99, 7, 'Stale', 'HEATING', 1, 'u2')"
+ )
+ conn.commit()
+ conn.close()
+
+ zones_data = [{"id": 1, "name": "New Name", "type": "HEATING", "devices": []}]
+ assert syncer.sync_zones(zones_data, home_id=7) is True
+
+ conn = sqlite3.connect(syncer.db_path)
+ updated_name = conn.execute(
+ "SELECT name FROM zones WHERE tado_home_id = ? AND tado_zone_id = ?",
+ (7, 1),
+ ).fetchone()[0]
+ stale = conn.execute(
+ "SELECT COUNT(*) FROM zones WHERE tado_home_id = ? AND tado_zone_id = ?",
+ (7, 99),
+ ).fetchone()[0]
+ conn.close()
+
+ assert updated_name == "New Name"
+ assert stale == 0
+
+ def test_sync_zones_with_existing_devices(self, syncer):
+ conn = sqlite3.connect(syncer.db_path)
+ conn.execute(
+ "INSERT INTO devices (serial_number, name, device_type) VALUES ('RU001', 'dev', 'unknown')"
+ )
+ conn.commit()
+ conn.close()
+
+ zones_data = [
+ {
+ "id": 1,
+ "name": "Zone 1",
+ "type": "HEATING",
+ "devices": [
+ {
+ "serialNo": "RU001",
+ "deviceType": "THERMOSTAT",
+ "currentFwVersion": "1.0",
+ "batteryState": "GOOD",
+ "duties": ["ZONE_LEADER"],
+ }
+ ],
+ }
+ ]
+
+ assert syncer.sync_zones(zones_data, home_id=1) is True
+
+ conn = sqlite3.connect(syncer.db_path)
+ device = conn.execute(
+ "SELECT serial_number, name, device_type FROM devices WHERE serial_number = ?",
+ ("RU001",),
+ ).fetchone()
+ leader = conn.execute(
+ "SELECT leader_device_id FROM zones WHERE tado_home_id = ? AND tado_zone_id = ?",
+ (1, 1),
+ ).fetchone()
+ conn.close()
+
+ assert device is not None
+ assert device[0] == "RU001"
+ assert device[1] == "dev"
+ assert device[2] == "THERMOSTAT"
+ assert leader is not None
+ assert leader[0] is not None
+
+
+class TestSyncZoneStatesData:
+ def test_sync_zone_states_data_creates_humidity_tasks(self, syncer):
+ conn = sqlite3.connect(syncer.db_path)
+ conn.execute(
+ "INSERT INTO devices (serial_number, aid, tado_zone_id, name)" \
+ "VALUES ('RU001', 11, '1', 'dev1')"
+ )
+ conn.commit()
+ conn.close()
+
+ zone_states_data = {
+ "zoneStates": {
+ "1": {
+ "setting": {"type": "HEATING"},
+ "sensorDataPoints": {"humidity": {"percentage": 56}},
+ }
+ }
+ }
+
+ tado_api = MagicMock()
+ tado_api.get_iid_from_characteristics.return_value = 200
+
+ with patch("tado_local.sync.asyncio.create_task") as create_task:
+ assert syncer.sync_zone_states_data(zone_states_data, home_id=1, tado_api=tado_api) is True
+
+ tado_api.get_iid_from_characteristics.assert_called_once_with(11, "CurrentRelativeHumidity")
+ create_task.assert_called_once()
+
+ def test_sync_zone_states_data_skips_hot_water(self, syncer):
+ zone_states_data = {
+ "zoneStates": {
+ "1": {
+ "setting": {"type": "HOT_WATER"},
+ "sensorDataPoints": {"humidity": {"percentage": 40}},
+ }
+ }
+ }
+
+ tado_api = MagicMock()
+
+ with patch("tado_local.sync.asyncio.create_task") as create_task:
+ assert syncer.sync_zone_states_data(zone_states_data, home_id=1, tado_api=tado_api) is True
+
+ create_task.assert_not_called()
+
+
+class TestSyncDeviceList:
+ def test_sync_device_list_updates_existing_device(self, syncer):
+ conn = sqlite3.connect(syncer.db_path)
+ conn.execute(
+ "INSERT INTO devices (serial_number, name, device_type) " +
+ "VALUES ('RU001', 'dev', 'unknown')"
+ )
+ conn.commit()
+ conn.close()
+
+ payload = {
+ "entries": [
+ {
+ "device": {
+ "serialNo": "RU001",
+ "batteryState": "GOOD",
+ "currentFwVersion": "2.1",
+ "deviceType": "VA02",
+ },
+ "zone": {"discriminator": 9},
+ }
+ ]
+ }
+
+ assert syncer.sync_device_list(payload, home_id=1) is True
+
+ conn = sqlite3.connect(syncer.db_path)
+ row = conn.execute(
+ "SELECT battery_state, firmware_version, tado_zone_id, model " +
+ "FROM devices WHERE serial_number = 'RU001'"
+ ).fetchone()
+ conn.close()
+
+ assert row[0] == "GOOD"
+ assert row[1] == "2.1"
+ assert str(row[2]) == "9"
+ assert row[3] == "VA02"
+
+ def test_sync_device_list_ignores_missing_device_data(self, syncer):
+ payload = {"entries": [{"zone": {"discriminator": 1}}, {"device": {}}]}
+ assert syncer.sync_device_list(payload, home_id=1) is True
+
+
+class TestSyncAll:
+ @pytest.mark.asyncio
+ async def test_sync_all_returns_false_when_not_authenticated(self, syncer):
+ cloud_api = MagicMock()
+ cloud_api.is_authenticated.return_value = False
+
+ ok = await syncer.sync_all(cloud_api)
+ assert ok is False
+
+ @pytest.mark.asyncio
+ async def test_sync_all_with_prefetched_data(self, syncer):
+ cloud_api = MagicMock()
+ cloud_api.is_authenticated.return_value = True
+ cloud_api.home_id = 100
+ cloud_api.tado_api = MagicMock()
+
+ with patch.object(syncer, "sync_home", return_value=True) as p_home, \
+ patch.object(syncer, "sync_zones", return_value=True) as p_zones, \
+ patch.object(syncer, "sync_device_list", return_value=True) as p_devices:
+ ok = await syncer.sync_all(
+ cloud_api,
+ home_data={"id": 100, "name": "H"},
+ zones_data=[{"id": 1, "name": "Z", "type": "HEATING", "devices": []}],
+ zone_states_data={"zoneStates": {}},
+ devices_data={"entries": []},
+ )
+
+ assert ok is True
+ p_home.assert_called_once()
+ p_zones.assert_called_once()
+ p_devices.assert_called_once()
+
+