"""Tests for the OpenRGB integration init.""" import socket from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from openrgb.utils import ControllerParsingError, OpenRGBDisconnected, SDKVersionError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.openrgb import async_remove_config_entry_device from homeassistant.components.openrgb.const import DOMAIN, SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry, async_fire_time_changed async def test_entry_setup_unload( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, ) -> None: """Test entry setup and unload.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.runtime_data is not None await hass.config_entries.async_unload(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert mock_openrgb_client.disconnect.called @pytest.mark.usefixtures("mock_openrgb_client") async def test_server_device_registry( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: """Test server device is created in device registry.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED server_device = device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.entry_id)} ) assert server_device == snapshot async def test_remove_config_entry_device_server( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, ) -> None: """Test that server device cannot be removed.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() device_registry = dr.async_get(hass) server_device = device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.entry_id)} ) assert server_device is not None # Try to remove server device - should be blocked result = await async_remove_config_entry_device( hass, mock_config_entry, server_device ) assert result is False async def test_remove_config_entry_device_still_connected( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, ) -> None: """Test that connected devices cannot be removed.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() device_registry = dr.async_get(hass) # Get a device that's in coordinator.data (still connected) devices = dr.async_entries_for_config_entry( device_registry, mock_config_entry.entry_id ) rgb_device = next( (d for d in devices if d.identifiers != {(DOMAIN, mock_config_entry.entry_id)}), None, ) if rgb_device: # Try to remove device that's still connected - should be blocked result = await async_remove_config_entry_device( hass, mock_config_entry, rgb_device ) assert result is False async def test_remove_config_entry_device_disconnected( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, device_registry: dr.DeviceRegistry, ) -> None: """Test that disconnected devices can be removed.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Create a device that's not in coordinator.data (disconnected) entry_id = mock_config_entry.entry_id disconnected_device = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={ ( DOMAIN, f"{entry_id}||KEYBOARD||Old Vendor||Old Device||OLD123||Old Location", ) }, name="Old Disconnected Device", via_device=(DOMAIN, entry_id), ) # Try to remove disconnected device - should succeed result = await async_remove_config_entry_device( hass, mock_config_entry, disconnected_device ) assert result is True @pytest.mark.usefixtures("mock_openrgb_client") async def test_remove_config_entry_device_with_multiple_identifiers( hass: HomeAssistant, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: """Test device removal with multiple domain identifiers.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entry_id = mock_config_entry.entry_id # Create a device with identifiers from multiple domains device_with_multiple_identifiers = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={ ("other_domain", "some_other_id"), # This should be skipped ( DOMAIN, f"{entry_id}||DEVICE||Vendor||Name||SERIAL123||Location", ), # This is a disconnected OpenRGB device }, name="Multi-Domain Device", via_device=(DOMAIN, entry_id), ) # Try to remove device - should succeed because the OpenRGB identifier is disconnected result = await async_remove_config_entry_device( hass, mock_config_entry, device_with_multiple_identifiers ) assert result is True @pytest.mark.parametrize( ("exception", "expected_state"), [ (ConnectionRefusedError, ConfigEntryState.SETUP_RETRY), (OpenRGBDisconnected, ConfigEntryState.SETUP_RETRY), (ControllerParsingError, ConfigEntryState.SETUP_RETRY), (TimeoutError, ConfigEntryState.SETUP_RETRY), (socket.gaierror, ConfigEntryState.SETUP_RETRY), (SDKVersionError, ConfigEntryState.SETUP_RETRY), (RuntimeError("Test error"), ConfigEntryState.SETUP_RETRY), ], ) async def test_setup_entry_exceptions( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, exception: Exception, expected_state: ConfigEntryState, ) -> None: """Test setup entry with various exceptions.""" mock_config_entry.add_to_hass(hass) mock_openrgb_client.client_class_mock.side_effect = exception await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is expected_state async def test_reconnection_on_update_failure( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that coordinator reconnects when update fails.""" mock_config_entry.add_to_hass(hass) # Set up the integration await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Verify initial state state = hass.states.get("light.ene_dram") assert state assert state.state == STATE_ON # Reset mock call counts after initial setup mock_openrgb_client.update.reset_mock() mock_openrgb_client.connect.reset_mock() # Simulate the first update call failing, then second succeeding mock_openrgb_client.update.side_effect = [ OpenRGBDisconnected(), None, # Second call succeeds after reconnect ] freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() await hass.async_block_till_done() # Verify that disconnect and connect were called (reconnection happened) mock_openrgb_client.disconnect.assert_called_once() mock_openrgb_client.connect.assert_called_once() # Verify that update was called twice (once failed, once after reconnect) assert mock_openrgb_client.update.call_count == 2 # Verify that the light is still available after successful reconnect state = hass.states.get("light.ene_dram") assert state assert state.state == STATE_ON async def test_reconnection_fails_second_attempt( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that coordinator fails when reconnection also fails.""" mock_config_entry.add_to_hass(hass) # Set up the integration await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Verify initial state state = hass.states.get("light.ene_dram") assert state assert state.state == STATE_ON # Reset mock call counts after initial setup mock_openrgb_client.update.reset_mock() mock_openrgb_client.connect.reset_mock() # Simulate the first update call failing, and reconnection also failing mock_openrgb_client.update.side_effect = [ OpenRGBDisconnected(), None, # Second call would succeed if reconnect worked ] # Simulate connect raising an exception to mimic failed reconnection mock_openrgb_client.connect.side_effect = ConnectionRefusedError() freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() # Verify that the light became unavailable after failed reconnection state = hass.states.get("light.ene_dram") assert state assert state.state == STATE_UNAVAILABLE # Verify that disconnect and connect were called (reconnection was attempted) mock_openrgb_client.disconnect.assert_called_once() mock_openrgb_client.connect.assert_called_once() # Verify that update was only called in the first attempt mock_openrgb_client.update.assert_called_once() async def test_normal_update_without_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_openrgb_client: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that normal updates work without triggering reconnection.""" mock_config_entry.add_to_hass(hass) # Set up the integration await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Verify initial state state = hass.states.get("light.ene_dram") assert state assert state.state == STATE_ON # Reset mock call counts after initial setup mock_openrgb_client.update.reset_mock() mock_openrgb_client.connect.reset_mock() # Simulate successful update mock_openrgb_client.update.side_effect = None mock_openrgb_client.update.return_value = None freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() await hass.async_block_till_done() # Verify that disconnect and connect were NOT called (no reconnection needed) mock_openrgb_client.disconnect.assert_not_called() mock_openrgb_client.connect.assert_not_called() # Verify that update was called only once mock_openrgb_client.update.assert_called_once() # Verify that the light is still available state = hass.states.get("light.ene_dram") assert state assert state.state == STATE_ON