Coverage for tests/tests_rf/device/test_hvac_ventilator.py: 0%
240 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
1#!/usr/bin/env python3
2"""Unittests for the HvacVentilator class."""
4from collections.abc import Generator
5from unittest.mock import AsyncMock, MagicMock, patch
7import pytest
9from ramses_rf.const import DevType
10from ramses_rf.database import MessageIndex
11from ramses_rf.device.hvac import HvacVentilator
12from ramses_rf.gateway import Gateway
13from ramses_tx import Address
14from ramses_tx.const import Code
15from ramses_tx.schemas import DeviceIdT
17# Test data
18TEST_DEVICE_ID = "32:123456"
19TEST_PARAM_ID = "3F"
20TEST_PARAM_VALUE = 50
21TEST_MSG_ID = "1234"
22TEST_BOUND_DEVICE_ID = "37:123456"
23TEST_BOUND_DEVICE_TYPE = DevType.REM
26@pytest.fixture
27def mock_gateway() -> Generator[MagicMock, None, None]:
28 """Create a mock Gateway instance for testing."""
29 gateway = MagicMock(spec=Gateway)
30 gateway.send_cmd = AsyncMock()
31 gateway.dispatcher = MagicMock()
32 gateway.dispatcher.send = MagicMock()
34 # Add required attributes
35 gateway.config = MagicMock()
36 gateway.config.disable_discovery = False
37 gateway.config.enable_eavesdrop = False
38 gateway._loop = MagicMock()
39 gateway._loop.call_soon = MagicMock()
40 gateway._loop.call_later = MagicMock()
41 gateway._loop.time = MagicMock(return_value=0.0)
42 gateway._include = {}
43 # Add msg_db attribute accessed by the message store, activates the SQLite MessageIndex
44 gateway.msg_db = MessageIndex(maintain=False)
46 yield gateway
49@pytest.fixture
50def hvac_ventilator(mock_gateway: MagicMock) -> HvacVentilator:
51 """Create an HvacVentilator instance for testing."""
52 device_id = DeviceIdT(TEST_DEVICE_ID)
53 return HvacVentilator(mock_gateway, Address(device_id))
56class TestHvacVentilator:
57 """Test HvacVentilator class."""
59 def test_initialization(self, hvac_ventilator: HvacVentilator) -> None:
60 """Test that the ventilator initializes correctly."""
61 assert hvac_ventilator._supports_2411 is False
62 assert hvac_ventilator._initialized_callback is None
63 assert hvac_ventilator._param_update_callback is None
64 assert hvac_ventilator._hgi is None
65 assert hvac_ventilator._bound_devices == {}
67 if hvac_ventilator._gwy.msg_db:
68 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
70 def test_set_initialized_callback_clear(
71 self, hvac_ventilator: HvacVentilator
72 ) -> None:
73 """Test clearing the initialized callback."""
74 # Set a callback first
75 mock_callback = MagicMock()
76 hvac_ventilator.set_initialized_callback(mock_callback)
78 # Now clear it
79 hvac_ventilator.set_initialized_callback(None)
80 assert hvac_ventilator._initialized_callback is None
82 if hvac_ventilator._gwy.msg_db:
83 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
85 def test_set_initialized_callback_set(
86 self, hvac_ventilator: HvacVentilator
87 ) -> None:
88 """Test setting the initialized callback."""
89 # Test initial state
90 assert hvac_ventilator._initialized_callback is None
92 if hvac_ventilator._gwy.msg_db:
93 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
95 # Set the callback
96 mock_callback = MagicMock()
97 hvac_ventilator.set_initialized_callback(mock_callback)
99 assert hvac_ventilator._initialized_callback is mock_callback
101 def test_set_param_update_callback(self, hvac_ventilator: HvacVentilator) -> None:
102 """Test setting the parameter update callback."""
103 if hvac_ventilator._gwy.msg_db:
104 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
106 # Define a mock callback
107 mock_callback = MagicMock()
109 # Set the callback
110 hvac_ventilator.set_param_update_callback(mock_callback)
112 # Check that the callback was set
113 assert hvac_ventilator._param_update_callback is mock_callback
115 def test_handle_2411_message(self, hvac_ventilator: HvacVentilator) -> None:
116 """Test handling a 2411 message."""
117 # Create a mock message with all required attributes
118 msg = MagicMock()
119 msg.code = Code._2411
120 # Create proper mock objects for src and dst with id attribute
121 msg.src = MagicMock()
122 msg.src.id = TEST_DEVICE_ID
123 msg.dst = MagicMock()
124 msg.dst.id = TEST_DEVICE_ID
125 msg.verb = " I"
126 msg.payload = {"parameter": TEST_PARAM_ID, "value": TEST_PARAM_VALUE}
128 # Set up the message store
129 hvac_ventilator._params_2411 = {}
131 # Set up the param update callback
132 mock_callback = MagicMock()
133 hvac_ventilator.set_param_update_callback(mock_callback)
135 # Call the method
136 hvac_ventilator._handle_2411_message(msg)
138 # Check that supports_2411 was set to True
139 assert hvac_ventilator._supports_2411 is True
141 # Check that the message was stored correctly
142 assert TEST_PARAM_ID in hvac_ventilator._params_2411
143 assert hvac_ventilator._params_2411[TEST_PARAM_ID] == TEST_PARAM_VALUE
145 # Check that the callback was called with the correct parameters
146 mock_callback.assert_called_once_with(TEST_PARAM_ID, TEST_PARAM_VALUE)
148 if hvac_ventilator._gwy.msg_db:
149 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
151 @patch("ramses_rf.device.hvac.Command.get_fan_param")
152 async def test_setup_discovery_cmds(
153 self, mock_cmd: MagicMock, hvac_ventilator: HvacVentilator
154 ) -> None:
155 """Test that discovery commands are set up correctly."""
156 # Mock the command creation
157 mock_cmd.return_value = "MOCK_CMD"
159 # Use patch.object to properly mock the method
160 with patch.object(hvac_ventilator, "_add_discovery_cmd") as mock_add_cmd:
161 hvac_ventilator._setup_discovery_cmds()
163 # Check that _add_discovery_cmd was called at least once
164 assert mock_add_cmd.called
166 if hvac_ventilator._gwy.msg_db:
167 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
169 async def test_handle_msg_parameter_message(
170 self, hvac_ventilator: HvacVentilator
171 ) -> None:
172 """Test that parameter messages are handled correctly."""
173 # Create a mock message with all required attributes
174 msg = MagicMock()
175 msg.code = Code._2411
176 # Create proper mock objects for src and dst with id attribute
177 msg.src = MagicMock()
178 msg.src.id = TEST_DEVICE_ID
179 msg.dst = MagicMock()
180 msg.dst.id = TEST_DEVICE_ID
181 msg.verb = " I"
182 msg.payload = {
183 "parameter": TEST_PARAM_ID,
184 "value": TEST_PARAM_VALUE,
185 "_hgi": MagicMock(),
186 }
188 # Set up the message store # deprecated, TODO(eb): remove Q1 2026
189 if not hvac_ventilator._gwy.msg_db:
190 hvac_ventilator._msgs_ = {}
192 # Patch the _handle_2411_message method
193 with patch.object(hvac_ventilator, "_handle_2411_message") as mock_handle:
194 # Call the method
195 hvac_ventilator._handle_msg(msg)
197 # Check that _handle_2411_message was called
198 mock_handle.assert_called_once_with(msg)
200 if hvac_ventilator._gwy.msg_db:
201 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
203 async def test_handle_msg_non_parameter_message(
204 self, hvac_ventilator: HvacVentilator
205 ) -> None:
206 """Test that non-parameter messages are passed to the parent class."""
207 # Create a mock message with a non-parameter code and required attributes
208 msg = MagicMock()
209 msg.code = Code._31DA # Standard FAN status code
210 # Create proper mock objects for src and dst with id attribute
211 msg.src = MagicMock()
212 msg.src.id = TEST_DEVICE_ID
213 msg.dst = MagicMock()
214 msg.dst.id = TEST_DEVICE_ID
215 msg.verb = " I"
216 msg.payload = {"some_key": "some_value"}
218 # Set up the message store # deprecated, TODO(eb): remove Q1 2026
219 if not hvac_ventilator._gwy.msg_db:
220 hvac_ventilator._msgs_ = {}
222 # Patch the parent class's _handle_msg method
223 with patch(
224 "ramses_rf.device.hvac.FilterChange._handle_msg"
225 ) as mock_parent_handle:
226 # Call the method
227 hvac_ventilator._handle_msg(msg)
229 # Check that the parent's _handle_msg was called
230 mock_parent_handle.assert_called_once_with(msg)
232 # The parameter handler should not have been called
233 assert not hasattr(hvac_ventilator, "_handle_parameter_msg")
235 if hvac_ventilator._gwy.msg_db:
236 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
238 def test_add_bound_device(
239 self, hvac_ventilator: HvacVentilator, caplog: pytest.LogCaptureFixture
240 ) -> None:
241 """Test adding a bound device."""
242 # Ensure the logger is at the right level to capture the warning
243 import logging
245 logger = logging.getLogger("ramses_rf.device.hvac")
246 logger.setLevel(logging.WARNING)
248 # Clear any existing log handlers to avoid duplicates
249 for handler in logger.handlers[:]:
250 logger.removeHandler(handler)
252 # Add a bound device
253 hvac_ventilator.add_bound_device(TEST_BOUND_DEVICE_ID, TEST_BOUND_DEVICE_TYPE)
255 # Verify it was added
256 assert TEST_BOUND_DEVICE_ID in hvac_ventilator._bound_devices
257 assert (
258 hvac_ventilator._bound_devices[TEST_BOUND_DEVICE_ID]
259 == TEST_BOUND_DEVICE_TYPE
260 )
262 # Test with invalid device type - should log a warning but not raise
263 invalid_device_id = "00:123456"
265 # Clear the caplog and add a handler to capture the logs
266 caplog.clear()
267 with caplog.at_level(logging.WARNING, logger="ramses_rf.device.hvac"):
268 hvac_ventilator.add_bound_device(invalid_device_id, "INVALID_TYPE")
270 # Check if any record contains the expected message
271 expected_message = f"Cannot bind device {invalid_device_id} of type INVALID_TYPE to FAN {hvac_ventilator.id}: must be REM or DIS"
273 # Print debug info if the test fails
274 if not any(expected_message in record.message for record in caplog.records):
275 print("\nCaptured log records:")
276 for i, record in enumerate(caplog.records):
277 print(f" {i}: {record.levelname}: {record.message}")
279 # Check if the warning was logged
280 assert any(expected_message in record.message for record in caplog.records), (
281 f"Expected warning message not found in logs. Expected: {expected_message}"
282 )
284 if hvac_ventilator._gwy.msg_db:
285 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
287 def test_remove_bound_device(self, hvac_ventilator: HvacVentilator) -> None:
288 """Test removing a bound device."""
289 # Add then remove a device
290 hvac_ventilator.add_bound_device(TEST_BOUND_DEVICE_ID, TEST_BOUND_DEVICE_TYPE)
291 hvac_ventilator.remove_bound_device(TEST_BOUND_DEVICE_ID)
293 # Verify it was removed
294 assert TEST_BOUND_DEVICE_ID not in hvac_ventilator._bound_devices
296 # Removing non-existent device should not raise
297 hvac_ventilator.remove_bound_device("nonexistent:device")
299 if hvac_ventilator._gwy.msg_db:
300 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
302 def test_get_bound_rem(self, hvac_ventilator: HvacVentilator) -> None:
303 """Test getting a bound REM device."""
304 # Initially should return None
305 assert hvac_ventilator.get_bound_rem() is None
307 # Add a REM device
308 hvac_ventilator.add_bound_device(TEST_BOUND_DEVICE_ID, DevType.REM)
310 # Should return the REM device
311 assert hvac_ventilator.get_bound_rem() == TEST_BOUND_DEVICE_ID
313 # Add a DIS device, should still return the REM device
314 hvac_ventilator.add_bound_device("38:123456", DevType.DIS)
315 assert hvac_ventilator.get_bound_rem() == TEST_BOUND_DEVICE_ID
317 if hvac_ventilator._gwy.msg_db:
318 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
320 def test_get_fan_param_supported(self, hvac_ventilator: HvacVentilator) -> None:
321 """Test getting a supported fan parameter."""
322 # Set up the parameter in the device's parameter store
323 hvac_ventilator._params_2411[TEST_PARAM_ID] = TEST_PARAM_VALUE
325 # Mark as supporting 2411
326 hvac_ventilator._supports_2411 = True
328 # Test getting the parameter
329 value = hvac_ventilator.get_fan_param(TEST_PARAM_ID)
330 assert value == TEST_PARAM_VALUE
332 if hvac_ventilator._gwy.msg_db:
333 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
335 def test_get_fan_param_unsupported(
336 self, hvac_ventilator: HvacVentilator, caplog: pytest.LogCaptureFixture
337 ) -> None:
338 """Test getting a parameter when 2411 is not supported."""
339 # Ensure 2411 is not supported and clear any existing messages
340 hvac_ventilator._supports_2411 = False
342 # Test getting a parameter
343 caplog.clear()
344 value = hvac_ventilator.get_fan_param(TEST_PARAM_ID)
345 assert value is None
347 if hvac_ventilator._gwy.msg_db:
348 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
350 def test_get_fan_param_normalization(self, hvac_ventilator: HvacVentilator) -> None:
351 """Test parameter ID normalization."""
352 # Set up the parameter with leading zeros in the parameter store
353 # The get_fan_param method normalizes "03F" to "3F"
354 hvac_ventilator._params_2411["3F"] = 75
355 hvac_ventilator._supports_2411 = True
357 # Test with different formats of the same parameter ID
358 assert hvac_ventilator.get_fan_param("03F") == 75
359 assert hvac_ventilator.get_fan_param("3F") == 75
360 assert hvac_ventilator.get_fan_param("0003F") == 75
362 if hvac_ventilator._gwy.msg_db:
363 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
365 def test_initialized_callback(self, hvac_ventilator: HvacVentilator) -> None:
366 """Test the initialized callback behaviour."""
367 # Set up a mock callback
368 mock_callback = MagicMock()
369 hvac_ventilator.set_initialized_callback(mock_callback)
371 # Initially, the callback shouldn't be called
372 mock_callback.assert_not_called()
374 # Set supports_2411 to True and call _handle_initialized_callback
375 hvac_ventilator._supports_2411 = True
376 hvac_ventilator._handle_initialized_callback()
378 # The callback should be called once
379 mock_callback.assert_called_once()
381 # The callback should be cleared after being called
382 assert hvac_ventilator._initialized_callback is None
384 # Calling again should not call the callback again
385 hvac_ventilator._handle_initialized_callback()
386 mock_callback.assert_called_once() # Still only called once
388 if hvac_ventilator._gwy.msg_db:
389 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
391 def test_hgi_property(
392 self, hvac_ventilator: HvacVentilator, monkeypatch: pytest.MonkeyPatch
393 ) -> None:
394 """Test the hgi property and its caching behaviour."""
395 # Get the gateway's hgi
396 gateway_hgi = hvac_ventilator._gwy.hgi
398 # First call should get the value from the gateway
399 assert hvac_ventilator.hgi is gateway_hgi
401 # The gateway's hgi property should only be called once
402 # (second access comes from the cache)
403 assert hvac_ventilator.hgi is gateway_hgi
404 assert hvac_ventilator._hgi is gateway_hgi # Check the cache directly
406 # Test the caching behaviour by creating a new mock for the gateway's hgi
407 new_hgi = MagicMock()
409 # Use monkeypatch to temporarily replace the hgi property
410 monkeypatch.setattr(hvac_ventilator._gwy, "hgi", new_hgi)
412 # The property still returns the original cached value
413 assert hvac_ventilator.hgi is gateway_hgi
414 assert hvac_ventilator.hgi is not new_hgi
416 # Clear the cache
417 hvac_ventilator._hgi = None
418 if hvac_ventilator._gwy.msg_db:
419 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
421 # Now it should get the new value
422 assert hvac_ventilator.hgi is new_hgi
423 assert hvac_ventilator._hgi is new_hgi # Check the cache was updated
425 def test_invalid_message_handling(self, hvac_ventilator: HvacVentilator) -> None:
426 """Test handling of invalid messages."""
427 # Create an invalid message (missing payload)
428 msg = MagicMock()
429 msg.verb = " I"
430 msg.src = MagicMock()
431 msg.src.id = TEST_DEVICE_ID
432 msg.dst = MagicMock()
433 msg.dst.id = TEST_DEVICE_ID
434 msg.payload = None # Invalid payload
436 # Set up a callback to verify it's not called
437 mock_callback = MagicMock()
438 hvac_ventilator.set_param_update_callback(mock_callback)
440 # This should not raise an exception
441 hvac_ventilator._handle_2411_message(msg)
443 # No parameter update callback should be called
444 mock_callback.assert_not_called()
446 if hvac_ventilator._gwy.msg_db:
447 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection
449 def test_missing_callback(self, hvac_ventilator: HvacVentilator) -> None:
450 """Test behaviour when callbacks are not set."""
451 # This should not raise an exception
452 hvac_ventilator._handle_param_update("3F", 50)
454 # And with a message that would trigger callbacks
455 msg = MagicMock()
456 msg.code = Code._2411
457 msg.verb = " I"
458 msg.src = MagicMock()
459 msg.src.id = TEST_DEVICE_ID
460 msg.dst = MagicMock()
461 msg.dst.id = TEST_DEVICE_ID
462 msg.payload = {"parameter": "3F", "value": 50}
464 # This should not raise an exception
465 hvac_ventilator._handle_2411_message(msg)
467 # Now set a callback and verify it's called
468 mock_callback = MagicMock()
469 hvac_ventilator.set_param_update_callback(mock_callback)
471 # Process the message again
472 hvac_ventilator._handle_2411_message(msg)
474 # Callback should be called with the parameter and value
475 mock_callback.assert_called_once_with("3F", 50)
477 if hvac_ventilator._gwy.msg_db:
478 hvac_ventilator._gwy.msg_db.stop() # close sqlite3 connection