Skip to content

Conversation

@joshsmonta
Copy link

@joshsmonta joshsmonta commented Dec 13, 2025

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

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.

To help with the load of incoming pull requests:

Copy link

@home-assistant home-assistant bot left a 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!

@home-assistant
Copy link

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

Copy link
Member

@joostlek joostlek left a 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.

@home-assistant home-assistant bot marked this pull request as draft December 15, 2025 18:35
@joshsmonta joshsmonta marked this pull request as ready for review December 19, 2025 04:39
@home-assistant home-assistant bot requested a review from joostlek December 19, 2025 04:39
@home-assistant home-assistant bot marked this pull request as draft December 19, 2025 14:27
@joshsmonta joshsmonta marked this pull request as ready for review December 23, 2025 03:05
@home-assistant home-assistant bot requested a review from joostlek December 23, 2025 03:05
Comment on lines 50 to 52
# Check if an entry already exists
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
Copy link
Member

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?

@home-assistant home-assistant bot marked this pull request as draft January 2, 2026 14:51
@joshsmonta joshsmonta marked this pull request as ready for review January 6, 2026 13:30
@home-assistant home-assistant bot requested a review from joostlek January 6, 2026 13:30
@home-assistant home-assistant bot marked this pull request as draft January 6, 2026 16:51
@joshsmonta joshsmonta marked this pull request as ready for review January 8, 2026 11:59
@home-assistant home-assistant bot requested a review from joostlek January 8, 2026 11:59
Copy link
Member

@joostlek joostlek left a 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

@home-assistant home-assistant bot marked this pull request as draft January 8, 2026 14:06
@joshsmonta joshsmonta marked this pull request as ready for review January 9, 2026 06:31
@home-assistant home-assistant bot requested a review from joostlek January 9, 2026 06:31
@home-assistant home-assistant bot marked this pull request as draft January 9, 2026 15:39
@joshsmonta joshsmonta marked this pull request as ready for review January 12, 2026 15:26
@home-assistant home-assistant bot requested a review from joostlek January 12, 2026 15:26
Comment on lines +85 to +278
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)
Copy link
Member

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

Comment on lines +45 to +74
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"

Copy link
Member

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

Comment on lines +171 to +192
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
Copy link
Member

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

Comment on lines +216 to +228
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
Copy link
Member

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

Comment on lines +246 to +370
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
Copy link
Member

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

@home-assistant home-assistant bot marked this pull request as draft January 12, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants