-
-
Notifications
You must be signed in to change notification settings - Fork 36.5k
Uhoo integration #158887
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Uhoo integration #158887
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @joshsmonta
It seems you haven't yet signed a CLA. Please do so here.
Once you do that we will be able to review and accept this pull request.
Thanks!
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
joostlek
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The uhooapi should be a library hosted on PyPI, not a separate module. It should also be published in a CI and it should contain a proper license.
| # Check if an entry already exists | ||
| if self._async_current_entries(): | ||
| return self.async_abort(reason="single_instance_allowed") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why can't users setup multiple accounts?
joostlek
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please also resolve discussions if you think you solved it correctly
| async def test_async_setup_entry_unauthorized_error_on_login( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_client, | ||
| patch_async_get_clientsession, | ||
| ) -> None: | ||
| """Test setup with invalid API credentials (UnauthorizedError on login).""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Simulate UnauthorizedError on login | ||
| mock_uhoo_client.login.side_effect = UnauthorizedError("Invalid API key") | ||
|
|
||
| # Should raise ConfigEntryError | ||
| with pytest.raises(ConfigEntryError) as exc_info: | ||
| await async_setup_entry(hass, config_entry) | ||
|
|
||
| assert "Invalid API credentials" in str(exc_info.value) | ||
|
|
||
| # Verify login was attempted | ||
| mock_uhoo_client.login.assert_awaited_once() | ||
|
|
||
| # Verify setup_devices was NOT called | ||
| mock_uhoo_client.setup_devices.assert_not_called() | ||
|
|
||
|
|
||
| async def test_async_setup_entry_unauthorized_error_on_setup_devices( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_client, | ||
| patch_async_get_clientsession, | ||
| ) -> None: | ||
| """Test setup with UnauthorizedError during setup_devices.""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Login succeeds but setup_devices fails with UnauthorizedError | ||
| mock_uhoo_client.login.return_value = None | ||
| mock_uhoo_client.setup_devices.side_effect = UnauthorizedError("Token expired") | ||
|
|
||
| # Should raise ConfigEntryError | ||
| with pytest.raises(ConfigEntryError) as exc_info: | ||
| await async_setup_entry(hass, config_entry) | ||
|
|
||
| assert "Invalid API credentials" in str(exc_info.value) | ||
|
|
||
| # Verify both methods were called | ||
| mock_uhoo_client.login.assert_awaited_once() | ||
| mock_uhoo_client.setup_devices.assert_awaited_once() | ||
|
|
||
|
|
||
| async def test_async_setup_entry_connection_error_on_login( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_client, | ||
| patch_async_get_clientsession, | ||
| ) -> None: | ||
| """Test setup with ClientConnectionError during login.""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Simulate ClientConnectionError on login | ||
| mock_uhoo_client.login.side_effect = ClientConnectionError("Connection failed") | ||
|
|
||
| # Should raise ConfigEntryNotReady | ||
| with pytest.raises(ConfigEntryNotReady) as exc_info: | ||
| await async_setup_entry(hass, config_entry) | ||
|
|
||
| assert "Cannot connect to uHoo servers" in str(exc_info.value) | ||
|
|
||
| # Verify login was attempted | ||
| mock_uhoo_client.login.assert_awaited_once() | ||
|
|
||
| # Verify setup_devices was NOT called | ||
| mock_uhoo_client.setup_devices.assert_not_called() | ||
|
|
||
|
|
||
| async def test_async_setup_entry_dns_error_on_login( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_client, | ||
| patch_async_get_clientsession, | ||
| ) -> None: | ||
| """Test setup with DNSError during login.""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Simulate DNSError on login | ||
| mock_uhoo_client.login.side_effect = DNSError("DNS resolution failed") | ||
|
|
||
| # Should raise ConfigEntryNotReady | ||
| with pytest.raises(ConfigEntryNotReady) as exc_info: | ||
| await async_setup_entry(hass, config_entry) | ||
|
|
||
| assert "Cannot connect to uHoo servers" in str(exc_info.value) | ||
|
|
||
| # Verify login was attempted | ||
| mock_uhoo_client.login.assert_awaited_once() | ||
|
|
||
| # Verify setup_devices was NOT called | ||
| mock_uhoo_client.setup_devices.assert_not_called() | ||
|
|
||
|
|
||
| async def test_async_setup_entry_uhoo_error_on_login( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_client, | ||
| patch_async_get_clientsession, | ||
| ) -> None: | ||
| """Test setup with UhooError during login.""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Simulate UhooError on login | ||
| mock_uhoo_client.login.side_effect = UhooError("Some uhoo error") | ||
|
|
||
| # Should raise ConfigEntryNotReady | ||
| with pytest.raises(ConfigEntryNotReady) as exc_info: | ||
| await async_setup_entry(hass, config_entry) | ||
|
|
||
| assert "Some uhoo error" in str(exc_info.value) | ||
|
|
||
| # Verify login was attempted | ||
| mock_uhoo_client.login.assert_awaited_once() | ||
|
|
||
| # Verify setup_devices was NOT called | ||
| mock_uhoo_client.setup_devices.assert_not_called() | ||
|
|
||
|
|
||
| async def test_async_setup_entry_coordinator_first_refresh_fails( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_client, | ||
| mock_uhoo_coordinator, | ||
| patch_async_get_clientsession, | ||
| patch_uhoo_data_update_coordinator, | ||
| ) -> None: | ||
| """Test setup where coordinator's first refresh fails.""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Mock the hass.config_entries methods | ||
| hass.config_entries.async_forward_entry_setups = AsyncMock() | ||
|
|
||
| # Simulate successful client setup but coordinator refresh fails | ||
| mock_uhoo_coordinator.async_config_entry_first_refresh.side_effect = Exception( | ||
| "First refresh failed" | ||
| ) | ||
|
|
||
| # Should propagate the exception | ||
| with pytest.raises(Exception) as exc_info: | ||
| await async_setup_entry(hass, config_entry) | ||
|
|
||
| assert "First refresh failed" in str(exc_info.value) | ||
|
|
||
| # Verify client setup was completed | ||
| mock_uhoo_client.login.assert_awaited_once() | ||
| mock_uhoo_client.setup_devices.assert_awaited_once() | ||
|
|
||
|
|
||
| async def test_async_setup_entry_platform_setup_error( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_client, | ||
| mock_uhoo_coordinator, | ||
| patch_async_get_clientsession, | ||
| patch_uhoo_data_update_coordinator, | ||
| ) -> None: | ||
| """Test setup where platform setup fails.""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Mock the hass.config_entries methods | ||
| mock_forward_entry_setups = AsyncMock( | ||
| side_effect=Exception("Platform setup failed") | ||
| ) | ||
| hass.config_entries.async_forward_entry_setups = mock_forward_entry_setups | ||
|
|
||
| # Should propagate the exception | ||
| with pytest.raises(Exception) as exc_info: | ||
| await async_setup_entry(hass, config_entry) | ||
|
|
||
| assert "Platform setup failed" in str(exc_info.value) | ||
|
|
||
| # Verify client and coordinator setup was completed | ||
| mock_uhoo_client.login.assert_awaited_once() | ||
| mock_uhoo_client.setup_devices.assert_awaited_once() | ||
| mock_uhoo_coordinator.async_config_entry_first_refresh.assert_awaited_once() | ||
|
|
||
|
|
||
| async def test_async_unload_entry_success( | ||
| hass: HomeAssistant, | ||
| ) -> None: | ||
| """Test successful unloading of a config entry.""" | ||
| config_entry = create_mock_config_entry() | ||
|
|
||
| # Mock the hass.config_entries methods | ||
| mock_unload_platforms = AsyncMock(return_value=True) | ||
| hass.config_entries.async_unload_platforms = mock_unload_platforms | ||
|
|
||
| # Call the unload function | ||
| result = await async_unload_entry(hass, config_entry) | ||
|
|
||
| # Verify the unload was successful | ||
| assert result is True | ||
|
|
||
| # Verify async_unload_platforms was called with correct parameters | ||
| mock_unload_platforms.assert_awaited_once_with(config_entry, PLATFORMS) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These can all be parametrized and tested via the entry state
| async def test_async_setup_entry( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_config_entry, | ||
| mock_uhoo_coordinator, | ||
| mock_add_entities, | ||
| mock_device, | ||
| ) -> None: | ||
| """Test setting up sensor entities.""" | ||
| # Setup coordinator with one device | ||
| serial_number = "23f9239m92m3ffkkdkdd" | ||
| mock_uhoo_coordinator.data = {serial_number: mock_device} | ||
| mock_uhoo_config_entry.runtime_data = mock_uhoo_coordinator | ||
|
|
||
| await async_setup_entry(hass, mock_uhoo_config_entry, mock_add_entities) | ||
|
|
||
| # Verify that entities were added | ||
| assert mock_add_entities.called | ||
| call_args = mock_add_entities.call_args[0][0] | ||
| entities = list(call_args) # Convert generator to list | ||
|
|
||
| # Should create entities for each sensor type for the device | ||
| assert len(entities) == len(SENSOR_TYPES) | ||
|
|
||
| # Check that entities have the correct unique IDs | ||
| humidity_entity = next( | ||
| e for e in entities if e._attr_unique_id == f"{serial_number}_humidity" | ||
| ) | ||
| assert humidity_entity is not None | ||
| assert humidity_entity.entity_description.key == "humidity" | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure what this is testing, as in, it tests that entities are passed on the async_add_entities, but the fact that we can snapshot them, already tests that
| def test_uhoo_sensor_entity_available_property( | ||
| mock_uhoo_coordinator, | ||
| mock_device, | ||
| ) -> None: | ||
| """Test the available property.""" | ||
| serial_number = "23f9239m92m3ffkkdkdd" | ||
| mock_uhoo_coordinator.data = {serial_number: mock_device} | ||
|
|
||
| humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") | ||
| entity = UhooSensorEntity(humidity_desc, serial_number, mock_uhoo_coordinator) | ||
|
|
||
| # Mock parent's available property | ||
| with patch( | ||
| "homeassistant.helpers.update_coordinator.CoordinatorEntity.available", | ||
| new_callable=lambda: True, | ||
| ): | ||
| # Entity should be available when device is in coordinator data | ||
| assert entity.available is True | ||
|
|
||
| # Remove device from coordinator data | ||
| mock_uhoo_coordinator.data = {} | ||
| assert entity.available is False |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead, have HA setup the integration, repatch the library to raise an exception, then use the freezer to skip time, and then we can check the state of the entity again and see that its unavailable
| def test_uhoo_sensor_entity_native_unit_of_measurement_celsius( | ||
| mock_uhoo_coordinator, | ||
| mock_device, | ||
| ) -> None: | ||
| """Test native_unit_of_measurement for temperature in Celsius.""" | ||
| serial_number = "23f9239m92m3ffkkdkdd" | ||
| mock_device.user_settings = {"temp": "c"} | ||
| mock_uhoo_coordinator.data = {serial_number: mock_device} | ||
|
|
||
| temp_desc = next(d for d in SENSOR_TYPES if d.key == "temperature") | ||
| entity = UhooSensorEntity(temp_desc, serial_number, mock_uhoo_coordinator) | ||
|
|
||
| assert entity.native_unit_of_measurement == UnitOfTemperature.CELSIUS |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
already tested in the snapshot
| def test_uhoo_sensor_entity_native_unit_of_measurement_other_sensors( | ||
| mock_uhoo_coordinator, | ||
| mock_device, | ||
| ) -> None: | ||
| """Test native_unit_of_measurement for non-temperature sensors.""" | ||
| serial_number = "23f9239m92m3ffkkdkdd" | ||
| mock_uhoo_coordinator.data = {serial_number: mock_device} | ||
|
|
||
| # Test humidity sensor (should use default from description) | ||
| humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") | ||
| entity = UhooSensorEntity(humidity_desc, serial_number, mock_uhoo_coordinator) | ||
|
|
||
| # For non-temperature sensors, it should return the description's unit | ||
| assert entity.native_unit_of_measurement == PERCENTAGE | ||
|
|
||
|
|
||
| async def test_async_setup_entry_no_devices( | ||
| hass: HomeAssistant, | ||
| mock_uhoo_config_entry, | ||
| mock_uhoo_coordinator, | ||
| mock_add_entities, | ||
| ) -> None: | ||
| """Test setting up sensor entities when there are no devices.""" | ||
| mock_uhoo_coordinator.data = {} # No devices | ||
| mock_uhoo_config_entry.runtime_data = mock_uhoo_coordinator | ||
|
|
||
| await async_setup_entry(hass, mock_uhoo_config_entry, mock_add_entities) | ||
|
|
||
| # Should still call add_entities but with empty generator | ||
| assert mock_add_entities.called | ||
|
|
||
| # Convert generator to list to check it's empty | ||
| entities = list(mock_add_entities.call_args[0][0]) | ||
| assert len(entities) == 0 | ||
|
|
||
|
|
||
| def test_all_sensor_types_have_value_functions() -> None: | ||
| """Test that all sensor types have valid value functions.""" | ||
| for sensor_desc in SENSOR_TYPES: | ||
| assert hasattr(sensor_desc, "value_fn") | ||
| assert callable(sensor_desc.value_fn) | ||
|
|
||
| # Create a mock device to test the lambda | ||
| mock_device = MagicMock() | ||
| # Set all possible attributes to non-None values | ||
| fields = [ | ||
| "humidity", | ||
| "temperature", | ||
| "co", | ||
| "co2", | ||
| "pm25", | ||
| "air_pressure", | ||
| "tvoc", | ||
| "no2", | ||
| "ozone", | ||
| "virus_index", | ||
| "mold_index", | ||
| ] | ||
| for attr in fields: | ||
| setattr(mock_device, attr, 1.0) | ||
|
|
||
| # The value function should return a float or None | ||
| result = sensor_desc.value_fn(mock_device) | ||
| assert result is None or isinstance(result, (int, float)) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("sensor_key", "expected_device_class"), | ||
| [ | ||
| ("humidity", SensorDeviceClass.HUMIDITY), | ||
| ("temperature", SensorDeviceClass.TEMPERATURE), | ||
| ("co", SensorDeviceClass.CO), | ||
| ("co2", SensorDeviceClass.CO2), | ||
| ("pm25", SensorDeviceClass.PM25), | ||
| ("air_pressure", SensorDeviceClass.PRESSURE), | ||
| ("tvoc", SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS), | ||
| ("no2", SensorDeviceClass.NITROGEN_DIOXIDE), | ||
| ("ozone", SensorDeviceClass.OZONE), | ||
| ("virus_index", None), # No device class for virus_index | ||
| ("mold_index", None), # No device class for mold_index | ||
| ], | ||
| ) | ||
| def test_sensor_device_classes(sensor_key, expected_device_class) -> None: | ||
| """Test that each sensor has the correct device class.""" | ||
| sensor_desc = next(d for d in SENSOR_TYPES if d.key == sensor_key) | ||
| assert sensor_desc.device_class == expected_device_class | ||
|
|
||
|
|
||
| def test_sensor_state_classes() -> None: | ||
| """Test that all sensors have MEASUREMENT state class.""" | ||
| for sensor_desc in SENSOR_TYPES: | ||
| assert sensor_desc.state_class == SensorStateClass.MEASUREMENT | ||
|
|
||
|
|
||
| def test_temperature_sensor_unit_conversion_logic() -> None: | ||
| """Test the logic for temperature unit conversion.""" | ||
| serial_number = "23f9239m92m3ffkkdkdd" | ||
|
|
||
| # Create a mock device with Celsius setting | ||
| mock_device_c = MagicMock() | ||
| mock_device_c.device_name = "Test Device" | ||
| mock_device_c.user_settings = {"temp": "c"} | ||
|
|
||
| # Create a mock device with Fahrenheit setting | ||
| mock_device_f = MagicMock() | ||
| mock_device_f.device_name = "Test Device" | ||
| mock_device_f.user_settings = {"temp": "f"} | ||
|
|
||
| # Mock coordinator with Celsius setting | ||
| coordinator_c = MagicMock() | ||
| coordinator_c.data = {serial_number: mock_device_c} | ||
|
|
||
| # Mock coordinator with Fahrenheit setting | ||
| coordinator_f = MagicMock() | ||
| coordinator_f.data = {serial_number: mock_device_f} | ||
|
|
||
| temp_desc = next(d for d in SENSOR_TYPES if d.key == "temperature") | ||
|
|
||
| # Create entities with different coordinators | ||
| entity_c = UhooSensorEntity(temp_desc, serial_number, coordinator_c) | ||
| entity_f = UhooSensorEntity(temp_desc, serial_number, coordinator_f) | ||
|
|
||
| # Test the actual property calls | ||
| assert entity_c.native_unit_of_measurement == UnitOfTemperature.CELSIUS | ||
| assert entity_f.native_unit_of_measurement == UnitOfTemperature.FAHRENHEIT |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All in the snapshot already
Proposed change
This is a new integration for uHoo device users. So that they could have a dashboard for their devices in home assistant.
Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: