From 0a672303e40f08f72efdca6e26b35bbf9f6101c0 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Wed, 4 Mar 2026 07:22:43 -0500 Subject: [PATCH 01/13] feat: Entity Refactor --- docs/guide/entity-state-management.md | 321 +++++++++++++ pyproject.toml | 2 +- tests/test_button_entity.py | 154 ++++++ tests/test_climate_entity.py | 228 +++++++++ tests/test_cover_entity.py | 228 +++++++++ tests/test_driver.py | 113 +++++ tests/test_entity.py | 101 +++- tests/test_helpers.py | 18 +- tests/test_ir_emitter_entity.py | 120 +++++ tests/test_light_entity.py | 234 ++++++++++ tests/test_media_player_entity.py | 257 +++++++++++ tests/test_remote_entity.py | 125 +++++ tests/test_select_entity.py | 195 ++++++++ tests/test_sensor_entity.py | 206 +++++++++ tests/test_switch_entity.py | 128 +++++ tests/test_voice_assistant_entity.py | 128 +++++ ucapi_framework/__init__.py | 24 + ucapi_framework/driver.py | 113 +++-- ucapi_framework/entities/README.md | 53 +++ ucapi_framework/entities/__init__.py | 35 ++ ucapi_framework/entities/button.py | 89 ++++ ucapi_framework/entities/climate.py | 236 ++++++++++ ucapi_framework/entities/cover.py | 161 +++++++ ucapi_framework/entities/ir_emitter.py | 89 ++++ ucapi_framework/entities/light.py | 201 ++++++++ ucapi_framework/entities/media_player.py | 488 ++++++++++++++++++++ ucapi_framework/entities/remote.py | 95 ++++ ucapi_framework/entities/select.py | 165 +++++++ ucapi_framework/entities/sensor.py | 155 +++++++ ucapi_framework/entities/switch.py | 99 ++++ ucapi_framework/entities/voice_assistant.py | 93 ++++ ucapi_framework/entity.py | 95 +++- ucapi_framework/helpers.py | 2 +- 33 files changed, 4716 insertions(+), 35 deletions(-) create mode 100644 docs/guide/entity-state-management.md create mode 100644 tests/test_button_entity.py create mode 100644 tests/test_climate_entity.py create mode 100644 tests/test_cover_entity.py create mode 100644 tests/test_ir_emitter_entity.py create mode 100644 tests/test_light_entity.py create mode 100644 tests/test_media_player_entity.py create mode 100644 tests/test_remote_entity.py create mode 100644 tests/test_select_entity.py create mode 100644 tests/test_sensor_entity.py create mode 100644 tests/test_switch_entity.py create mode 100644 tests/test_voice_assistant_entity.py create mode 100644 ucapi_framework/entities/README.md create mode 100644 ucapi_framework/entities/__init__.py create mode 100644 ucapi_framework/entities/button.py create mode 100644 ucapi_framework/entities/climate.py create mode 100644 ucapi_framework/entities/cover.py create mode 100644 ucapi_framework/entities/ir_emitter.py create mode 100644 ucapi_framework/entities/light.py create mode 100644 ucapi_framework/entities/media_player.py create mode 100644 ucapi_framework/entities/remote.py create mode 100644 ucapi_framework/entities/select.py create mode 100644 ucapi_framework/entities/sensor.py create mode 100644 ucapi_framework/entities/switch.py create mode 100644 ucapi_framework/entities/voice_assistant.py diff --git a/docs/guide/entity-state-management.md b/docs/guide/entity-state-management.md new file mode 100644 index 0000000..218ea22 --- /dev/null +++ b/docs/guide/entity-state-management.md @@ -0,0 +1,321 @@ +# MediaPlayerEntity Usage Guide + +The `MediaPlayerEntity` class provides built-in state management for MediaPlayer entities, eliminating the need for devices to track entity state via `get_device_attributes()`. + +## Key Concept: Entity-Managed State + +Instead of storing state on the device and retrieving it with `get_device_attributes()`, entities now manage their own state internally using the existing `self.attributes` dictionary that all ucapi entities have: + +- **Property getters** for read-only access (e.g., `entity.state`, `entity.volume`) +- **Setter methods** for updates (e.g., `entity.set_state()`, `entity.set_volume()`) +- **Bulk update method** for efficient multi-attribute updates (`entity.set_attributes()`) +- **State storage** in `self.attributes` dict - the same dict used by ucapi entities + +This matches your existing pattern: + +```python +self.attributes[Attributes.SOURCE] = source +self.update(self.attributes) +``` + + +## Benefits + +1. **Natural separation of concerns**: Entities manage their own state +2. **No device boilerplate**: No need to implement `get_device_attributes()` +3. **Type-safe**: IDE autocomplete and type checking for all attributes +4. **Explicit control**: Choose when to push updates to Remote with `update` parameter +5. **Still overridable**: Subclasses can override any method or property +6. **Consistent with ucapi**: Uses the existing `self.attributes` dict pattern + +## Basic Usage + +```python +from ucapi import media_player +from ucapi_framework.entities import MediaPlayerEntity + +class MyMediaPlayer(MediaPlayerEntity): + def __init__(self, device_config, device): + entity_id = f"media_player.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + media_player.Features.ON_OFF, + media_player.Features.VOLUME, + media_player.Features.MEDIA_TITLE, + ], + attributes={ + media_player.Attributes.STATE: media_player.States.UNKNOWN + } + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + """Handle commands from the Remote.""" + if cmd_id == media_player.Commands.ON: + await self._device.turn_on() + # Update state - automatically pushes to Remote + self.set_state(media_player.States.ON) + + elif cmd_id == media_player.Commands.OFF: + await self._device.turn_off() + self.set_state(media_player.States.OFF) + + elif cmd_id == media_player.Commands.VOLUME: + await self._device.set_volume(params['volume']) + self.set_volume(params['volume']) + + return ucapi.StatusCodes.OK +``` + +## Reading State + +All attributes have read-only property getters that access `self.attributes`: + +```python +# Check current state +if entity.state == media_player.States.PLAYING: + print(f"Now playing: {entity.media_title}") + +# Access any attribute +print(f"Volume: {entity.volume}") +print(f"Muted: {entity.muted}") +print(f"Source: {entity.source}") +print(f"Available sources: {entity.source_list}") + +# Direct access to attributes dict also works +state = entity.attributes.get(media_player.Attributes.STATE) +``` + +## Updating Single Attributes + +Each attribute has a setter method that updates `self.attributes` with optional `update` parameter: + +```python +# Update state and push to Remote (default) +entity.set_state(media_player.States.PLAYING) + +# Update volume without pushing to Remote +entity.set_volume(75, update=False) + +# Later, push all changes at once +entity.update(entity.attributes) + +# Or use the traditional pattern +entity.attributes[media_player.Attributes.VOLUME] = 75 +entity.update(entity.attributes) +``` + +## Updating Multiple Attributes Efficiently + +Use `set_attributes()` to update multiple attributes with a single Remote update: + +```python +# Efficient: Single update call for all changes +entity.set_attributes( + state=media_player.States.PLAYING, + volume=50, + muted=False, + media_title="Song Title", + media_artist="Artist Name", + media_album="Album Name", + source="Spotify", +) + +# Or without pushing to Remote yet +entity.set_attributes( + state=media_player.States.PLAYING, + volume=50, + media_title="Song Title", + update=False # Don't push yet +) +# Later... +entity.update({...}) # Push when ready +``` + +## Device Update Pattern + +When your device receives state updates, update the entity directly: + +```python +class MyDevice(BaseDeviceInterface): + def __init__(self, device_config, driver=None): + super().__init__(device_config, driver=driver) + self.entity = None # Will be set after entity creation + + async def on_state_update(self, state_data): + """Handle state updates from the device.""" + if self.entity: + # Update entity state directly + self.entity.set_attributes( + state=self.map_state(state_data['status']), + volume=state_data.get('volume'), + media_title=state_data.get('track_title'), + media_artist=state_data.get('track_artist'), + ) +``` + +## Controlling When Updates Are Sent + +### Pattern 1: Immediate Updates (Default) + +```python +# Each setter immediately pushes to Remote +self.set_state(media_player.States.PLAYING) # Update sent +self.set_volume(75) # Update sent +self.set_media_title("Song") # Update sent +# Result: 3 separate update calls to Remote +``` + +### Pattern 2: Batched Updates (Recommended for multiple changes) + +```python +# Set attributes without updates +entity.set_state(media_player.States.PLAYING, update=False) +entity.set_volume(75, update=False) +entity.set_media_title("Song", update=False) + +# Single update call +entity.update(entity.attributes) +# Result: 1 update call to Remote +``` + +### Pattern 3: Bulk Update (Most Efficient) + +```python +# Single call, single update +self.set_attributes( + state=media_player.States.PLAYING, + volume=75, + media_title="Song", +) +# Result: 1 update call to Remote +``` + +## Advanced: Overriding Behavior + +You can override any getter or setter for custom behavior: + +```python +class CustomMediaPlayer(MediaPlayerEntity): + def set_state(self, value, *, update=True): + """Add custom logic before setting state.""" + # Custom validation + if value == media_player.States.PLAYING and not self._device.is_ready: + value = media_player.States.BUFFERING + + # Call parent implementation + super().set_state(value, update=update) + + @property + def volume(self): + """Override getter to return scaled volume.""" + # Device uses 0-255, Remote uses 0-100 + if self._volume is not None: + return int(self._volume * 100 / 255) + return None + + def set_volume(self, value, *, update=True): + """Override setter to scale volume.""" + # Convert 0-100 to 0-255 for device + if value is not None: + scaled_value = int(value * 255 / 100) + self._volume = scaled_value + else: + self._volume = None + + if update: + # Use original value for Remote (0-100) + self.update({media_player.Attributes.VOLUME: value}) +``` + +## Migration from get_device_attributes() + +### Before (Device-Managed State) + +```python +# Old pattern: Device tracks state +class MyDevice(BaseDeviceInterface): + def __init__(self, device_config): + super().__init__(device_config) + self.state = media_player.States.UNKNOWN + self.volume = 0 + self.media_title = None + + def get_device_attributes(self, entity_id): + """Return entity attributes.""" + return { + media_player.Attributes.STATE: self.state, + media_player.Attributes.VOLUME: self.volume, + media_player.Attributes.MEDIA_TITLE: self.media_title, + } + +class MyMediaPlayer(media_player.MediaPlayer): + def __init__(self, device_config, device): + self._device = device + super().__init__(...) + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == media_player.Commands.VOLUME: + await self._device.set_volume(params['volume']) + # Update device state + self._device.volume = params['volume'] + # Trigger refresh + await self._driver.refresh_entity_state(entity_id) +``` + +### After (Entity-Managed State) + +```python +# New pattern: Entity tracks state +class MyDevice(BaseDeviceInterface): + def __init__(self, device_config): + super().__init__(device_config) + # No state tracking needed! + +class MyMediaPlayer(MediaPlayerEntity): + def __init__(self, device_config, device): + self._device = device + super().__init__(...) + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == media_player.Commands.VOLUME: + await self._device.set_volume(params['volume']) + # Update entity state directly - no device involvement! + self.set_volume(params['volume']) +``` + +## Available Attributes + +All MediaPlayer attributes are supported: + +- `state` - Playback state (States enum) +- `volume` - Volume level (0-100) +- `muted` - Mute status (bool) +- `media_duration` - Media duration in seconds +- `media_position` - Current position in seconds +- `media_position_updated_at` - Position update timestamp +- `media_type` - Media type (e.g., 'music', 'video') +- `media_image_url` - Artwork URL +- `media_title` - Track/show title +- `media_artist` - Artist name +- `media_album` - Album name +- `repeat` - Repeat mode (RepeatMode enum) +- `shuffle` - Shuffle status (bool) +- `source` - Current input source +- `source_list` - Available sources (list) +- `sound_mode` - Current sound mode +- `sound_mode_list` - Available sound modes (list) + +## Next Steps + +This pattern will be extended to other entity types: + +- `ClimateEntity` +- `CoverEntity` +- `LightEntity` +- `SwitchEntity` +- etc. + +Each will follow the same pattern: property getters for reading, setter methods for writing, and optional `update` parameter for control. diff --git a/pyproject.toml b/pyproject.toml index 7b383e0..1a9e2cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ucapi-framework" -version = "1.8.4" +version = "1.9.0b1" description = "ucapi framework that provides core functionality for building integrations." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_button_entity.py b/tests/test_button_entity.py new file mode 100644 index 0000000..2ff8f28 --- /dev/null +++ b/tests/test_button_entity.py @@ -0,0 +1,154 @@ +"""Tests for ButtonEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import button +from ucapi_framework import ButtonEntity + + +class TestButtonEntity: + """Test ButtonEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={button.Attributes.STATE: button.States.AVAILABLE} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a ButtonEntity for testing.""" + entity = ButtonEntity( + "button.test", + "Test Button", + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == button.States.AVAILABLE + + def test_initial_state_always_available(self, mock_api): + """Test that button always initializes STATE to AVAILABLE (ucapi hardcodes this).""" + entity = ButtonEntity( + "button.unset", + "Unset Button", + ) + entity._api = mock_api # noqa: SLF001 + # ucapi.button.Button hardcodes {Attributes.STATE: States.AVAILABLE} in __init__ + assert entity.state == button.States.AVAILABLE + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(button.States.UNAVAILABLE, update=True) + + # Verify internal state was updated + assert entity.state == button.States.UNAVAILABLE + + # Verify update was called + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "button.test" + assert button.Attributes.STATE in attributes + assert attributes[button.Attributes.STATE] == button.States.UNAVAILABLE + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(button.States.UNAVAILABLE, update=False) + + # Verify internal state was updated + assert entity.state == button.States.UNAVAILABLE + + # Verify update was NOT called + assert not mock_api.configured_entities.update_attributes.called + + def test_set_state_available(self, entity, mock_api): + """Test transitioning back to AVAILABLE.""" + entity.set_state(button.States.UNAVAILABLE, update=False) + assert entity.state == button.States.UNAVAILABLE + + entity.set_state(button.States.AVAILABLE, update=False) + assert entity.state == button.States.AVAILABLE + + def test_set_state_none_does_not_update_remote(self, entity, mock_api): + """Test set_state(None) stores None but update filter strips it.""" + # set_state(None) writes None into attributes dict + entity.set_state(None, update=True) + + # Internal state stores None + assert entity.state is None + + # The update should have been called but the None filter in + # update_attributes strips the None value — so update_attributes + # is called with an empty dict (or not called if filter removes all) + # Either way, the Remote should not receive a None value + if mock_api.configured_entities.update_attributes.called: + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert button.Attributes.STATE not in attributes + + def test_property_getter_is_read_only(self, entity): + """Test that the state property cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = button.States.AVAILABLE # type: ignore[misc] + + def test_only_one_attribute(self, entity, mock_api): + """Test that Button only has the STATE attribute.""" + entity.set_state(button.States.UNAVAILABLE, update=True) + + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + # Should only contain STATE + assert list(attributes.keys()) == [button.Attributes.STATE] + + +class TestButtonEntityInheritance: + """Test that ButtonEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomButton(ButtonEntity): + def __init__(self): + super().__init__( + "button.custom", + "Custom Button", + ) + self.custom_set_state_called = False + + def set_state(self, value, *, update=True): + """Override set_state to add custom logic.""" + self.custom_set_state_called = True + super().set_state(value, update=update) + + entity = CustomButton() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(button.States.AVAILABLE, update=False) + assert entity.custom_set_state_called is True + assert entity.state == button.States.AVAILABLE + + def test_custom_property_getter(self): + """Test that the state property getter can be overridden.""" + + class CustomButton(ButtonEntity): + def __init__(self): + super().__init__( + "button.custom", + "Custom Button", + ) + + @property + def state(self): + """Override state getter to always return AVAILABLE.""" + return button.States.AVAILABLE + + entity = CustomButton() + # Property override always returns AVAILABLE + assert entity.state == button.States.AVAILABLE diff --git a/tests/test_climate_entity.py b/tests/test_climate_entity.py new file mode 100644 index 0000000..eb0c295 --- /dev/null +++ b/tests/test_climate_entity.py @@ -0,0 +1,228 @@ +"""Tests for ClimateEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import climate +from ucapi_framework import ClimateEntity + + +class TestClimateEntity: + """Test ClimateEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={climate.Attributes.STATE: climate.States.OFF} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a ClimateEntity for testing.""" + entity = ClimateEntity( + "climate.test", + "Test Thermostat", + features=[climate.Features.ON_OFF, climate.Features.HEAT], + attributes={climate.Attributes.STATE: climate.States.OFF}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == climate.States.OFF + assert entity.current_temperature is None + assert entity.target_temperature is None + assert entity.target_temperature_high is None + assert entity.target_temperature_low is None + assert entity.fan_mode is None + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(climate.States.HEAT, update=True) + + assert entity.state == climate.States.HEAT + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "climate.test" + assert climate.Attributes.STATE in attributes + assert attributes[climate.Attributes.STATE] == climate.States.HEAT + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(climate.States.COOL, update=False) + + assert entity.state == climate.States.COOL + assert not mock_api.configured_entities.update_attributes.called + + def test_set_current_temperature(self, entity, mock_api): + """Test set_current_temperature() updates state and calls update.""" + entity.set_current_temperature(22.5, update=True) + + assert entity.current_temperature == 22.5 + assert mock_api.configured_entities.update_attributes.called + + def test_set_current_temperature_without_update(self, entity, mock_api): + """Test set_current_temperature(update=False) does not call update.""" + entity.set_current_temperature(22.5, update=False) + + assert entity.current_temperature == 22.5 + assert not mock_api.configured_entities.update_attributes.called + + def test_set_target_temperature(self, entity, mock_api): + """Test set_target_temperature() updates state and calls update.""" + entity.set_target_temperature(21.0, update=True) + + assert entity.target_temperature == 21.0 + assert mock_api.configured_entities.update_attributes.called + + def test_set_target_temperature_without_update(self, entity, mock_api): + """Test set_target_temperature(update=False) does not call update.""" + entity.set_target_temperature(21.0, update=False) + + assert entity.target_temperature == 21.0 + assert not mock_api.configured_entities.update_attributes.called + + def test_set_target_temperature_high(self, entity, mock_api): + """Test set_target_temperature_high() updates state and calls update.""" + entity.set_target_temperature_high(25.0, update=True) + + assert entity.target_temperature_high == 25.0 + assert mock_api.configured_entities.update_attributes.called + + def test_set_target_temperature_low(self, entity, mock_api): + """Test set_target_temperature_low() updates state and calls update.""" + entity.set_target_temperature_low(18.0, update=True) + + assert entity.target_temperature_low == 18.0 + assert mock_api.configured_entities.update_attributes.called + + def test_set_fan_mode(self, entity, mock_api): + """Test set_fan_mode() updates state and calls update.""" + entity.set_fan_mode("AUTO", update=True) + + assert entity.fan_mode == "AUTO" + assert mock_api.configured_entities.update_attributes.called + + def test_set_fan_mode_without_update(self, entity, mock_api): + """Test set_fan_mode(update=False) does not call update.""" + entity.set_fan_mode("HIGH", update=False) + + assert entity.fan_mode == "HIGH" + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_bulk_update(self, entity, mock_api): + """Test set_attributes() updates multiple attributes with single update call.""" + entity.set_attributes( + state=climate.States.HEAT, + current_temperature=20.5, + target_temperature=22.0, + fan_mode="AUTO", + update=True, + ) + + assert entity.state == climate.States.HEAT + assert entity.current_temperature == 20.5 + assert entity.target_temperature == 22.0 + assert entity.fan_mode == "AUTO" + + # Verify update was called only once + assert mock_api.configured_entities.update_attributes.call_count == 1 + + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "climate.test" + assert len(attributes) == 4 + assert attributes[climate.Attributes.STATE] == climate.States.HEAT + assert attributes[climate.Attributes.CURRENT_TEMPERATURE] == 20.5 + assert attributes[climate.Attributes.TARGET_TEMPERATURE] == 22.0 + assert attributes[climate.Attributes.FAN_MODE] == "AUTO" + + def test_set_attributes_without_update(self, entity, mock_api): + """Test set_attributes(update=False) does not call entity.update().""" + entity.set_attributes( + state=climate.States.COOL, + target_temperature=19.0, + update=False, + ) + + assert entity.state == climate.States.COOL + assert entity.target_temperature == 19.0 + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_ignores_none_values(self, entity, mock_api): + """Test set_attributes() ignores None values.""" + entity.set_attributes(state=climate.States.HEAT, current_temperature=None, update=True) + + assert entity.state == climate.States.HEAT + assert entity.current_temperature is None + + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert len(attributes) == 1 + assert climate.Attributes.STATE in attributes + + def test_set_attributes_all_six(self, entity, mock_api): + """Test setting all six climate attributes at once.""" + entity.set_attributes( + state=climate.States.HEAT_COOL, + current_temperature=21.0, + target_temperature=22.0, + target_temperature_high=25.0, + target_temperature_low=18.0, + fan_mode="AUTO", + update=True, + ) + + assert entity.state == climate.States.HEAT_COOL + assert entity.current_temperature == 21.0 + assert entity.target_temperature == 22.0 + assert entity.target_temperature_high == 25.0 + assert entity.target_temperature_low == 18.0 + assert entity.fan_mode == "AUTO" + + assert mock_api.configured_entities.update_attributes.call_count == 1 + + def test_property_getters_are_read_only(self, entity): + """Test that property getters cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = climate.States.HEAT # type: ignore[misc] + + with pytest.raises(AttributeError): + entity.current_temperature = 20.0 # type: ignore[misc] + + with pytest.raises(AttributeError): + entity.target_temperature = 21.0 # type: ignore[misc] + + +class TestClimateEntityInheritance: + """Test that ClimateEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomClimate(ClimateEntity): + def __init__(self): + super().__init__( + "climate.custom", + "Custom Thermostat", + features=[], + attributes={}, + ) + self.custom_called = False + + def set_state(self, value, *, update=True): + self.custom_called = True + super().set_state(value, update=update) + + entity = CustomClimate() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(climate.States.COOL, update=False) + assert entity.custom_called is True + assert entity.state == climate.States.COOL diff --git a/tests/test_cover_entity.py b/tests/test_cover_entity.py new file mode 100644 index 0000000..b51f5bd --- /dev/null +++ b/tests/test_cover_entity.py @@ -0,0 +1,228 @@ +"""Tests for CoverEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import cover +from ucapi_framework import CoverEntity + + +class TestCoverEntity: + """Test CoverEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={cover.Attributes.STATE: cover.States.CLOSED} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a CoverEntity for testing.""" + entity = CoverEntity( + "cover.test", + "Test Cover", + features=[cover.Features.OPEN, cover.Features.CLOSE], + attributes={cover.Attributes.STATE: cover.States.CLOSED}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + # State was set to CLOSED in constructor + assert entity.state == cover.States.CLOSED + # These were not set, so should be None + assert entity.position is None + assert entity.tilt_position is None + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(cover.States.OPEN, update=True) + + # Verify internal state was updated + assert entity.state == cover.States.OPEN + + # Verify update was called + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "cover.test" + assert cover.Attributes.STATE in attributes + assert attributes[cover.Attributes.STATE] == cover.States.OPEN + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(cover.States.OPEN, update=False) + + # Verify internal state was updated + assert entity.state == cover.States.OPEN + + # Verify update was NOT called + assert not mock_api.configured_entities.update_attributes.called + + def test_set_state_transitions(self, entity, mock_api): + """Test all valid state transitions.""" + for state in [ + cover.States.OPENING, + cover.States.OPEN, + cover.States.CLOSING, + cover.States.CLOSED, + cover.States.UNKNOWN, + cover.States.UNAVAILABLE, + ]: + entity.set_state(state, update=False) + assert entity.state == state + + def test_set_position(self, entity, mock_api): + """Test set_position() updates state and calls update.""" + entity.set_position(75, update=True) + + assert entity.position == 75 + assert mock_api.configured_entities.update_attributes.called + + def test_set_position_without_update(self, entity, mock_api): + """Test set_position(update=False) does not call entity.update().""" + entity.set_position(75, update=False) + + assert entity.position == 75 + assert not mock_api.configured_entities.update_attributes.called + + def test_set_tilt_position(self, entity, mock_api): + """Test set_tilt_position() updates state and calls update.""" + entity.set_tilt_position(45, update=True) + + assert entity.tilt_position == 45 + assert mock_api.configured_entities.update_attributes.called + + def test_set_tilt_position_without_update(self, entity, mock_api): + """Test set_tilt_position(update=False) does not call entity.update().""" + entity.set_tilt_position(45, update=False) + + assert entity.tilt_position == 45 + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_bulk_update(self, entity, mock_api): + """Test set_attributes() updates multiple attributes with single update call.""" + entity.set_attributes( + state=cover.States.OPEN, + position=100, + tilt_position=50, + update=True, + ) + + # Verify all internal state was updated + assert entity.state == cover.States.OPEN + assert entity.position == 100 + assert entity.tilt_position == 50 + + # Verify update was called only once + assert mock_api.configured_entities.update_attributes.call_count == 1 + + # Verify all attributes were included in the update + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "cover.test" + assert len(attributes) == 3 + assert attributes[cover.Attributes.STATE] == cover.States.OPEN + assert attributes[cover.Attributes.POSITION] == 100 + assert attributes[cover.Attributes.TILT_POSITION] == 50 + + def test_set_attributes_without_update(self, entity, mock_api): + """Test set_attributes(update=False) does not call entity.update().""" + entity.set_attributes(state=cover.States.OPEN, position=100, update=False) + + # Verify internal state was updated + assert entity.state == cover.States.OPEN + assert entity.position == 100 + + # Verify update was NOT called + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_ignores_none_values(self, entity, mock_api): + """Test set_attributes() ignores None values.""" + entity.set_attributes(state=cover.States.OPEN, position=None, update=True) + + # Only state should be in internal storage + assert entity.state == cover.States.OPEN + assert entity.position is None + + # Verify only state was included in update + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert len(attributes) == 1 + assert cover.Attributes.STATE in attributes + + def test_property_getters_are_read_only(self, entity): + """Test that property getters cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = cover.States.OPEN # type: ignore[misc] + + def test_all_cover_attributes(self, entity, mock_api): + """Test setting all cover attributes.""" + entity.set_attributes( + state=cover.States.OPEN, + position=100, + tilt_position=0, + update=True, + ) + + assert entity.state == cover.States.OPEN + assert entity.position == 100 + assert entity.tilt_position == 0 + + # Verify single update call + assert mock_api.configured_entities.update_attributes.call_count == 1 + + +class TestCoverEntityInheritance: + """Test that CoverEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomCover(CoverEntity): + def __init__(self): + super().__init__( + "cover.custom", + "Custom Cover", + features=[], + attributes={}, + ) + self.custom_set_state_called = False + + def set_state(self, value, *, update=True): + """Override set_state to add custom logic.""" + self.custom_set_state_called = True + super().set_state(value, update=update) + + entity = CustomCover() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(cover.States.OPEN, update=False) + assert entity.custom_set_state_called is True + assert entity.state == cover.States.OPEN + + def test_custom_property_getter(self): + """Test that property getters can be overridden.""" + + class CustomCover(CoverEntity): + def __init__(self): + super().__init__( + "cover.custom", + "Custom Cover", + features=[], + attributes={}, + ) + + @property + def state(self): + """Override state getter to always return OPEN.""" + return cover.States.OPEN + + entity = CustomCover() + # Even if internal state is None, getter returns OPEN + assert entity.state == cover.States.OPEN diff --git a/tests/test_driver.py b/tests/test_driver.py index 720f5a1..6232320 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -1985,6 +1985,58 @@ def get_device_attributes(self, entity_id): # Should have called API update_attributes directly driver.api.configured_entities.update_attributes.assert_called_once() + @pytest.mark.asyncio + async def test_refresh_entity_state_calls_sync_state_when_overridden(self): + """Test refresh_entity_state short-circuits to sync_state when overridden.""" + from ucapi_framework import Entity as FrameworkEntity + + class SyncingMediaPlayer(media_player.MediaPlayer, FrameworkEntity): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sync_state_called = 0 + + async def sync_state(self): + self.sync_state_called += 1 + + driver = self._create_driver() + config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1") + driver.add_configured_device(config, connect=False) + + entity = SyncingMediaPlayer( + "media_player.dev1", + "Test", + features=[], + attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, + ) + entity._api = driver.api # noqa: SLF001 + driver.api.configured_entities.get = MagicMock(return_value=entity) + + await driver.refresh_entity_state("media_player.dev1") + + # sync_state should have been called, not the match-block fallback + assert entity.sync_state_called == 1 + driver.api.configured_entities.update_attributes.assert_not_called() + + @pytest.mark.asyncio + async def test_refresh_entity_state_no_shortcircuit_without_sync_state_override(self): + """Test refresh_entity_state uses match block when sync_state is NOT overridden.""" + driver = self._create_driver() + config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1") + driver.add_configured_device(config, connect=False) + device = driver._device_instances["dev1"] + await device.connect() + device._state = "playing" + + # Plain MagicMock entity (not a FrameworkEntity — no sync_state override) + mock_entity = MagicMock() + mock_entity.entity_type = EntityTypes.MEDIA_PLAYER + driver.api.configured_entities.get = MagicMock(return_value=mock_entity) + + await driver.refresh_entity_state("media_player.dev1") + + # Falls through to match block — should call update_attributes + driver.api.configured_entities.update_attributes.assert_called() + class TestOnSubscribeEntitiesEdgeCases: """Tests for on_subscribe_entities edge cases.""" @@ -2382,6 +2434,67 @@ async def test_on_device_update_none_update(self, caplog): assert "Received None update" in caplog.text + @pytest.mark.asyncio + async def test_on_device_update_calls_sync_state_when_overridden(self): + """Test on_device_update short-circuits to sync_state when overridden.""" + from ucapi_framework import Entity as FrameworkEntity + + class SyncingMediaPlayer(media_player.MediaPlayer, FrameworkEntity): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sync_state_called = 0 + + async def sync_state(self): + self.sync_state_called += 1 + + driver = self._create_driver() + config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1") + driver.add_configured_device(config, connect=False) + + entity = SyncingMediaPlayer( + "media_player.dev1", + "Test", + features=[], + attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, + ) + entity._api = driver.api # noqa: SLF001 + driver.api.configured_entities.get = MagicMock(return_value=entity) + driver.api.configured_entities.contains = MagicMock(return_value=True) + + await driver.on_device_update("media_player.dev1", {"state": "playing"}) + + # Driver skips sync_state entities — entity's own subscription handles it. + # The driver must NOT call sync_state here to avoid double execution. + assert entity.sync_state_called == 0 + driver.api.configured_entities.update_attributes.assert_not_called() + + @pytest.mark.asyncio + async def test_on_device_update_no_shortcircuit_without_sync_state_override(self): + """Test on_device_update uses attribute routing when sync_state is NOT overridden.""" + from ucapi_framework import Entity as FrameworkEntity + + class PlainMediaPlayer(media_player.MediaPlayer, FrameworkEntity): + pass # No sync_state override + + driver = self._create_driver() + config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1") + driver.add_configured_device(config, connect=False) + + entity = PlainMediaPlayer( + "media_player.dev1", + "Test", + features=[], + attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, + ) + entity._api = driver.api # noqa: SLF001 + driver.api.configured_entities.get = MagicMock(return_value=entity) + driver.api.configured_entities.contains = MagicMock(return_value=True) + + await driver.on_device_update("media_player.dev1", {"state": "playing"}) + + # Falls through to attribute routing — should call update_attributes + driver.api.configured_entities.update_attributes.assert_called() + class TestDriverCoverageGaps: """Additional tests to fill coverage gaps.""" diff --git a/tests/test_entity.py b/tests/test_entity.py index 4c7d4cc..cb45626 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -1,6 +1,6 @@ """Tests for Entity ABC.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from ucapi import media_player, sensor @@ -536,3 +536,102 @@ def test_filter_changed_attributes_entity_not_found(self, mock_api): # Should return all attributes when entity not found result = entity.filter_changed_attributes(update) assert result == update + + +class TestSyncStateAndSubscription: + """Tests for sync_state() and subscribe_to_device() coordinator pattern.""" + + @pytest.fixture + def mock_api(self): + """Provide a mock API for all tests.""" + return MagicMock() + + def test_sync_state_default_is_noop(self, mock_api): + """Test that default sync_state() is a no-op (base Entity does not override).""" + entity = TestMediaPlayer("media_player.test", "Test Player") + entity._api = mock_api # noqa: SLF001 + # Base class sync_state is a no-op — overriding it is what triggers the coordinator path + assert type(entity).sync_state is Entity.sync_state + + @pytest.mark.asyncio + async def test_sync_state_noop_does_not_push(self, mock_api): + """Test that calling the default no-op sync_state() makes no API calls.""" + entity = TestMediaPlayer("media_player.test", "Test Player") + entity._api = mock_api # noqa: SLF001 + await entity.sync_state() + mock_api.configured_entities.update_attributes.assert_not_called() + + @pytest.mark.asyncio + async def test_subscribe_to_device_wires_sync_state(self, mock_api): + """Test subscribe_to_device wires UPDATE event to sync_state.""" + class SyncingMediaPlayer(media_player.MediaPlayer, Entity): + def __init__(self): + super().__init__( + "media_player.test", + "Test Player", + features=[media_player.Features.ON_OFF], + attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, + ) + self.sync_state_called = 0 + + async def sync_state(self): + self.sync_state_called += 1 + + from ucapi_framework.device import DeviceEvents + + entity = SyncingMediaPlayer() + entity._api = mock_api # noqa: SLF001 + + mock_device = MagicMock() + entity.subscribe_to_device(mock_device) + + # Verify events.on was called with UPDATE event + mock_device.events.on.assert_called_once_with( + DeviceEvents.UPDATE, entity._handle_device_update # noqa: SLF001 + ) + + @pytest.mark.asyncio + async def test_handle_device_update_calls_sync_state(self, mock_api): + """Test _handle_device_update dispatches to sync_state.""" + sync_state_mock = AsyncMock() + + entity = TestMediaPlayer("media_player.test", "Test Player") + entity._api = mock_api # noqa: SLF001 + entity.sync_state = sync_state_mock # type: ignore[method-assign] + + await entity._handle_device_update("device_id", {"state": "ON"}) # noqa: SLF001 + + sync_state_mock.assert_awaited_once() + + @pytest.mark.asyncio + async def test_handle_device_update_ignores_args(self, mock_api): + """Test _handle_device_update accepts any args/kwargs without error.""" + entity = TestMediaPlayer("media_player.test", "Test Player") + entity._api = mock_api # noqa: SLF001 + + # Should not raise regardless of args passed by the event emitter + await entity._handle_device_update() # noqa: SLF001 + await entity._handle_device_update("device_id", {"key": "value"}, extra="kwarg") # noqa: SLF001 + + def test_sync_state_overridden_detected(self): + """Test that overriding sync_state is detectable for driver short-circuit.""" + class OverridingEntity(media_player.MediaPlayer, Entity): + def __init__(self): + super().__init__( + "media_player.test", + "Test", + features=[], + attributes={}, + ) + + async def sync_state(self): + pass + + base_entity = TestMediaPlayer("media_player.test", "Test Player") + overriding_entity = OverridingEntity() + + # Base entity uses Entity.sync_state (no-op) — not overridden + assert type(base_entity).sync_state is Entity.sync_state + + # Overriding entity has its own sync_state — driver should short-circuit + assert type(overriding_entity).sync_state is not Entity.sync_state diff --git a/tests/test_helpers.py b/tests/test_helpers.py index c85fa58..f2ee12e 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -119,7 +119,9 @@ def create_response(data, status=200): return response def mock_get(url, **_kwargs): - if url.endswith("/api/activities"): + if "/api/activities" in url and url.split("?")[0].endswith( + "/api/activities" + ): return create_response(mock_activities_list) else: # Extract activity ID from URL @@ -169,7 +171,7 @@ def create_response(data, status=200): response.__aexit__ = AsyncMock(return_value=None) return response - def mock_get(_url, **kwargs): + def mock_get(url, **kwargs): # Verify API key is in headers assert "Authorization" in kwargs.get("headers", {}) assert kwargs["headers"]["Authorization"] == "Bearer test-api-key" @@ -293,7 +295,9 @@ def create_response(data, status=200): return response def mock_get(url, **_kwargs): - if url.endswith("/api/activities"): + if "/api/activities" in url and url.split("?")[0].endswith( + "/api/activities" + ): return create_response(activities) else: # Fail on individual activity fetch @@ -332,7 +336,9 @@ def create_response(data, status=200): return response def mock_get(url, **_kwargs): - if url.endswith("/api/activities"): + if "/api/activities" in url and url.split("?")[0].endswith( + "/api/activities" + ): return create_response([{"entity_id": "activity.empty"}]) else: return create_response(activity_no_entities) @@ -381,7 +387,9 @@ def create_response(data, status=200): return response def mock_get(url, **_kwargs): - if url.endswith("/api/activities"): + if "/api/activities" in url and url.split("?")[0].endswith( + "/api/activities" + ): return create_response([{"entity_id": "activity.test"}]) else: return create_response(activity) diff --git a/tests/test_ir_emitter_entity.py b/tests/test_ir_emitter_entity.py new file mode 100644 index 0000000..ee7db0d --- /dev/null +++ b/tests/test_ir_emitter_entity.py @@ -0,0 +1,120 @@ +"""Tests for IREmitterEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import ir_emitter +from ucapi_framework import IREmitterEntity + + +class TestIREmitterEntity: + """Test IREmitterEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={ir_emitter.Attributes.STATE: ir_emitter.States.ON} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create an IREmitterEntity for testing.""" + entity = IREmitterEntity( + "ir_emitter.test", + "Test IR Emitter", + features=[ir_emitter.Features.SEND_IR], + attributes={ir_emitter.Attributes.STATE: ir_emitter.States.ON}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == ir_emitter.States.ON + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(ir_emitter.States.UNAVAILABLE, update=True) + + assert entity.state == ir_emitter.States.UNAVAILABLE + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "ir_emitter.test" + assert ir_emitter.Attributes.STATE in attributes + assert attributes[ir_emitter.Attributes.STATE] == ir_emitter.States.UNAVAILABLE + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(ir_emitter.States.UNAVAILABLE, update=False) + + assert entity.state == ir_emitter.States.UNAVAILABLE + assert not mock_api.configured_entities.update_attributes.called + + def test_set_state_back_to_on(self, entity, mock_api): + """Test transitioning back to ON.""" + entity.set_state(ir_emitter.States.UNAVAILABLE, update=False) + assert entity.state == ir_emitter.States.UNAVAILABLE + + entity.set_state(ir_emitter.States.ON, update=False) + assert entity.state == ir_emitter.States.ON + + def test_set_state_none_does_not_send_none(self, entity, mock_api): + """Test set_state(None) stores None but update filter strips it.""" + entity.set_state(None, update=True) + + assert entity.state is None + + if mock_api.configured_entities.update_attributes.called: + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert ir_emitter.Attributes.STATE not in attributes + + def test_property_getter_is_read_only(self, entity): + """Test that the state property cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = ir_emitter.States.ON # type: ignore[misc] + + def test_only_one_attribute(self, entity, mock_api): + """Test that IREmitterEntity only manages the STATE attribute.""" + entity.set_state(ir_emitter.States.UNAVAILABLE, update=True) + + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert list(attributes.keys()) == [ir_emitter.Attributes.STATE] + + def test_no_set_attributes_method(self, entity): + """Test that IREmitterEntity does not have a set_attributes bulk helper.""" + assert not hasattr(entity, "set_attributes") + + +class TestIREmitterEntityInheritance: + """Test that IREmitterEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomIREmitter(IREmitterEntity): + def __init__(self): + super().__init__( + "ir_emitter.custom", + "Custom IR Emitter", + features=[ir_emitter.Features.SEND_IR], + attributes={}, + ) + self.custom_called = False + + def set_state(self, value, *, update=True): + self.custom_called = True + super().set_state(value, update=update) + + entity = CustomIREmitter() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(ir_emitter.States.ON, update=False) + assert entity.custom_called is True + assert entity.state == ir_emitter.States.ON diff --git a/tests/test_light_entity.py b/tests/test_light_entity.py new file mode 100644 index 0000000..ad2dbed --- /dev/null +++ b/tests/test_light_entity.py @@ -0,0 +1,234 @@ +"""Tests for LightEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import light +from ucapi_framework import LightEntity + + +class TestLightEntity: + """Test LightEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={light.Attributes.STATE: light.States.OFF} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a LightEntity for testing.""" + entity = LightEntity( + "light.test", + "Test Light", + features=[light.Features.ON_OFF, light.Features.DIM], + attributes={light.Attributes.STATE: light.States.OFF}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + # State was set to OFF in constructor + assert entity.state == light.States.OFF + # These were not set, so should be None + assert entity.brightness is None + assert entity.hue is None + assert entity.saturation is None + assert entity.color_temperature is None + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(light.States.ON, update=True) + + # Verify internal state was updated + assert entity.state == light.States.ON + + # Verify update was called + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "light.test" + assert light.Attributes.STATE in attributes + assert attributes[light.Attributes.STATE] == light.States.ON + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(light.States.ON, update=False) + + # Verify internal state was updated + assert entity.state == light.States.ON + + # Verify update was NOT called + assert not mock_api.configured_entities.update_attributes.called + + def test_set_brightness(self, entity, mock_api): + """Test set_brightness() updates state and calls update.""" + entity.set_brightness(80, update=True) + + assert entity.brightness == 80 + assert mock_api.configured_entities.update_attributes.called + + def test_set_brightness_without_update(self, entity, mock_api): + """Test set_brightness(update=False) does not call entity.update().""" + entity.set_brightness(80, update=False) + + assert entity.brightness == 80 + assert not mock_api.configured_entities.update_attributes.called + + def test_set_hue(self, entity, mock_api): + """Test set_hue() updates state and calls update.""" + entity.set_hue(180, update=True) + + assert entity.hue == 180 + assert mock_api.configured_entities.update_attributes.called + + def test_set_saturation(self, entity, mock_api): + """Test set_saturation() updates state and calls update.""" + entity.set_saturation(75, update=True) + + assert entity.saturation == 75 + assert mock_api.configured_entities.update_attributes.called + + def test_set_color_temperature(self, entity, mock_api): + """Test set_color_temperature() updates state and calls update.""" + entity.set_color_temperature(4000, update=True) + + assert entity.color_temperature == 4000 + assert mock_api.configured_entities.update_attributes.called + + def test_set_attributes_bulk_update(self, entity, mock_api): + """Test set_attributes() updates multiple attributes with single update call.""" + entity.set_attributes( + state=light.States.ON, + brightness=75, + hue=120, + saturation=80, + color_temperature=3500, + update=True, + ) + + # Verify all internal state was updated + assert entity.state == light.States.ON + assert entity.brightness == 75 + assert entity.hue == 120 + assert entity.saturation == 80 + assert entity.color_temperature == 3500 + + # Verify update was called only once + assert mock_api.configured_entities.update_attributes.call_count == 1 + + # Verify all attributes were included in the update + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "light.test" + assert len(attributes) == 5 + assert attributes[light.Attributes.STATE] == light.States.ON + assert attributes[light.Attributes.BRIGHTNESS] == 75 + assert attributes[light.Attributes.HUE] == 120 + assert attributes[light.Attributes.SATURATION] == 80 + assert attributes[light.Attributes.COLOR_TEMPERATURE] == 3500 + + def test_set_attributes_without_update(self, entity, mock_api): + """Test set_attributes(update=False) does not call entity.update().""" + entity.set_attributes(state=light.States.ON, brightness=50, update=False) + + # Verify internal state was updated + assert entity.state == light.States.ON + assert entity.brightness == 50 + + # Verify update was NOT called + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_ignores_none_values(self, entity, mock_api): + """Test set_attributes() ignores None values.""" + entity.set_attributes(state=light.States.ON, brightness=None, update=True) + + # Only state should be in internal storage + assert entity.state == light.States.ON + assert entity.brightness is None + + # Verify only state was included in update + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert len(attributes) == 1 + assert light.Attributes.STATE in attributes + + def test_property_getters_are_read_only(self, entity): + """Test that property getters cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = light.States.ON # type: ignore[misc] + + def test_all_light_attributes(self, entity, mock_api): + """Test setting all light attributes.""" + entity.set_attributes( + state=light.States.ON, + brightness=60, + hue=240, + saturation=90, + color_temperature=2700, + update=True, + ) + + assert entity.state == light.States.ON + assert entity.brightness == 60 + assert entity.hue == 240 + assert entity.saturation == 90 + assert entity.color_temperature == 2700 + + # Verify single update call + assert mock_api.configured_entities.update_attributes.call_count == 1 + + +class TestLightEntityInheritance: + """Test that LightEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomLight(LightEntity): + def __init__(self): + super().__init__( + "light.custom", + "Custom Light", + features=[], + attributes={}, + ) + self.custom_set_state_called = False + + def set_state(self, value, *, update=True): + """Override set_state to add custom logic.""" + self.custom_set_state_called = True + super().set_state(value, update=update) + + entity = CustomLight() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(light.States.ON, update=False) + assert entity.custom_set_state_called is True + assert entity.state == light.States.ON + + def test_custom_property_getter(self): + """Test that property getters can be overridden.""" + + class CustomLight(LightEntity): + def __init__(self): + super().__init__( + "light.custom", + "Custom Light", + features=[], + attributes={}, + ) + + @property + def state(self): + """Override state getter to always return ON.""" + return light.States.ON + + entity = CustomLight() + # Even if internal state is None, getter returns ON + assert entity.state == light.States.ON diff --git a/tests/test_media_player_entity.py b/tests/test_media_player_entity.py new file mode 100644 index 0000000..0a4f5f8 --- /dev/null +++ b/tests/test_media_player_entity.py @@ -0,0 +1,257 @@ +"""Tests for MediaPlayerEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import media_player +from ucapi_framework import MediaPlayerEntity + + +class TestMediaPlayerEntity: + """Test MediaPlayerEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a MediaPlayerEntity for testing.""" + entity = MediaPlayerEntity( + "media_player.test", + "Test Player", + features=[media_player.Features.ON_OFF, media_player.Features.VOLUME], + attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + # State was set to UNKNOWN in constructor + assert entity.state == media_player.States.UNKNOWN + # These were not set, so should be None + assert entity.volume is None + assert entity.muted is None + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(media_player.States.PLAYING, update=True) + + # Verify internal state was updated + assert entity.state == media_player.States.PLAYING + + # Verify update was called + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "media_player.test" + assert media_player.Attributes.STATE in attributes + assert attributes[media_player.Attributes.STATE] == media_player.States.PLAYING + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(media_player.States.PLAYING, update=False) + + # Verify internal state was updated + assert entity.state == media_player.States.PLAYING + + # Verify update was NOT called + assert not mock_api.configured_entities.update_attributes.called + + def test_set_volume(self, entity, mock_api): + """Test set_volume() updates state and calls update.""" + entity.set_volume(75, update=True) + + assert entity.volume == 75 + assert mock_api.configured_entities.update_attributes.called + + def test_set_muted(self, entity, mock_api): + """Test set_muted() updates state and calls update.""" + entity.set_muted(True, update=True) + + assert entity.muted is True + assert mock_api.configured_entities.update_attributes.called + + def test_set_media_title(self, entity, mock_api): + """Test set_media_title() updates state and calls update.""" + entity.set_media_title("Test Song", update=True) + + assert entity.media_title == "Test Song" + assert mock_api.configured_entities.update_attributes.called + + def test_set_media_artist(self, entity, mock_api): + """Test set_media_artist() updates state and calls update.""" + entity.set_media_artist("Test Artist", update=True) + + assert entity.media_artist == "Test Artist" + assert mock_api.configured_entities.update_attributes.called + + def test_set_source_list(self, entity, mock_api): + """Test set_source_list() updates state and calls update.""" + sources = ["HDMI 1", "HDMI 2", "Bluetooth"] + entity.set_source_list(sources, update=True) + + assert entity.source_list == sources + assert mock_api.configured_entities.update_attributes.called + + def test_set_attributes_bulk_update(self, entity, mock_api): + """Test set_attributes() updates multiple attributes with single update call.""" + entity.set_attributes( + state=media_player.States.PLAYING, + volume=50, + muted=False, + media_title="Song Title", + media_artist="Artist Name", + update=True, + ) + + # Verify all internal state was updated + assert entity.state == media_player.States.PLAYING + assert entity.volume == 50 + assert entity.muted is False + assert entity.media_title == "Song Title" + assert entity.media_artist == "Artist Name" + + # Verify update was called only once + assert mock_api.configured_entities.update_attributes.call_count == 1 + + # Verify all attributes were included in the update + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "media_player.test" + assert len(attributes) == 5 + assert attributes[media_player.Attributes.STATE] == media_player.States.PLAYING + assert attributes[media_player.Attributes.VOLUME] == 50 + assert attributes[media_player.Attributes.MUTED] is False + assert attributes[media_player.Attributes.MEDIA_TITLE] == "Song Title" + assert attributes[media_player.Attributes.MEDIA_ARTIST] == "Artist Name" + + def test_set_attributes_without_update(self, entity, mock_api): + """Test set_attributes(update=False) does not call entity.update().""" + entity.set_attributes( + state=media_player.States.PLAYING, volume=50, update=False + ) + + # Verify internal state was updated + assert entity.state == media_player.States.PLAYING + assert entity.volume == 50 + + # Verify update was NOT called + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_ignores_none_values(self, entity, mock_api): + """Test set_attributes() ignores None values.""" + entity.set_attributes(state=media_player.States.PLAYING, volume=None, update=True) + + # Only state should be in internal storage + assert entity.state == media_player.States.PLAYING + assert entity.volume is None + + # Verify only state was included in update + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert len(attributes) == 1 + assert media_player.Attributes.STATE in attributes + + def test_property_getters_are_read_only(self, entity): + """Test that property getters cannot be set directly.""" + # This should raise AttributeError + with pytest.raises(AttributeError): + entity.state = media_player.States.PLAYING # type: ignore[misc] + + def test_all_media_attributes(self, entity, mock_api): + """Test setting all media-related attributes.""" + entity.set_attributes( + state=media_player.States.PLAYING, + volume=75, + muted=False, + media_duration=300, + media_position=120, + media_type="music", + media_title="Test Song", + media_artist="Test Artist", + media_album="Test Album", + repeat=media_player.RepeatMode.ALL, + shuffle=True, + source="Spotify", + source_list=["Spotify", "Bluetooth"], + sound_mode="Stereo", + sound_mode_list=["Stereo", "Surround"], + update=True, + ) + + # Verify all attributes + assert entity.state == media_player.States.PLAYING + assert entity.volume == 75 + assert entity.muted is False + assert entity.media_duration == 300 + assert entity.media_position == 120 + assert entity.media_type == "music" + assert entity.media_title == "Test Song" + assert entity.media_artist == "Test Artist" + assert entity.media_album == "Test Album" + assert entity.repeat == media_player.RepeatMode.ALL + assert entity.shuffle is True + assert entity.source == "Spotify" + assert entity.source_list == ["Spotify", "Bluetooth"] + assert entity.sound_mode == "Stereo" + assert entity.sound_mode_list == ["Stereo", "Surround"] + + # Verify single update call + assert mock_api.configured_entities.update_attributes.call_count == 1 + + +class TestMediaPlayerEntityInheritance: + """Test that MediaPlayerEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomMediaPlayer(MediaPlayerEntity): + def __init__(self): + super().__init__( + "media_player.custom", + "Custom Player", + features=[], + attributes={}, + ) + self.custom_set_state_called = False + + def set_state(self, value, *, update=True): + """Override set_state to add custom logic.""" + self.custom_set_state_called = True + super().set_state(value, update=update) + + entity = CustomMediaPlayer() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(media_player.States.PLAYING, update=False) + assert entity.custom_set_state_called is True + assert entity.state == media_player.States.PLAYING + + def test_custom_property_getter(self): + """Test that property getters can be overridden.""" + + class CustomMediaPlayer(MediaPlayerEntity): + def __init__(self): + super().__init__( + "media_player.custom", + "Custom Player", + features=[], + attributes={}, + ) + + @property + def state(self): + """Override state getter to always return PLAYING.""" + return media_player.States.PLAYING + + entity = CustomMediaPlayer() + # Even if internal state is None, getter returns PLAYING + assert entity.state == media_player.States.PLAYING diff --git a/tests/test_remote_entity.py b/tests/test_remote_entity.py new file mode 100644 index 0000000..c5e3a48 --- /dev/null +++ b/tests/test_remote_entity.py @@ -0,0 +1,125 @@ +"""Tests for RemoteEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import remote +from ucapi_framework import RemoteEntity + + +class TestRemoteEntity: + """Test RemoteEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={remote.Attributes.STATE: remote.States.OFF} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a RemoteEntity for testing.""" + entity = RemoteEntity( + "remote.test", + "Test Remote", + features=[remote.Features.SEND_CMD], + attributes={remote.Attributes.STATE: remote.States.OFF}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == remote.States.OFF + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(remote.States.ON, update=True) + + assert entity.state == remote.States.ON + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "remote.test" + assert remote.Attributes.STATE in attributes + assert attributes[remote.Attributes.STATE] == remote.States.ON + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(remote.States.ON, update=False) + + assert entity.state == remote.States.ON + assert not mock_api.configured_entities.update_attributes.called + + def test_set_state_unavailable(self, entity, mock_api): + """Test transitioning to UNAVAILABLE.""" + entity.set_state(remote.States.UNAVAILABLE, update=False) + assert entity.state == remote.States.UNAVAILABLE + + def test_set_state_back_to_off(self, entity, mock_api): + """Test transitioning back to OFF.""" + entity.set_state(remote.States.ON, update=False) + assert entity.state == remote.States.ON + + entity.set_state(remote.States.OFF, update=False) + assert entity.state == remote.States.OFF + + def test_set_state_none_does_not_send_none(self, entity, mock_api): + """Test set_state(None) stores None but update filter strips it.""" + entity.set_state(None, update=True) + + assert entity.state is None + + if mock_api.configured_entities.update_attributes.called: + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert remote.Attributes.STATE not in attributes + + def test_property_getter_is_read_only(self, entity): + """Test that the state property cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = remote.States.ON # type: ignore[misc] + + def test_only_one_attribute(self, entity, mock_api): + """Test that RemoteEntity only manages the STATE attribute.""" + entity.set_state(remote.States.ON, update=True) + + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert list(attributes.keys()) == [remote.Attributes.STATE] + + def test_no_set_attributes_method(self, entity): + """Test that RemoteEntity does not have a set_attributes bulk helper.""" + assert not hasattr(entity, "set_attributes") + + +class TestRemoteEntityInheritance: + """Test that RemoteEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomRemote(RemoteEntity): + def __init__(self): + super().__init__( + "remote.custom", + "Custom Remote", + features=[], + attributes={}, + ) + self.custom_called = False + + def set_state(self, value, *, update=True): + self.custom_called = True + super().set_state(value, update=update) + + entity = CustomRemote() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(remote.States.ON, update=False) + assert entity.custom_called is True + assert entity.state == remote.States.ON diff --git a/tests/test_select_entity.py b/tests/test_select_entity.py new file mode 100644 index 0000000..3df4dfd --- /dev/null +++ b/tests/test_select_entity.py @@ -0,0 +1,195 @@ +"""Tests for SelectEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import select +from ucapi_framework import SelectEntity + + +class TestSelectEntity: + """Test SelectEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={select.Attributes.STATE: select.States.ON} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a SelectEntity for testing. + + Note: select.Select does NOT accept a ``features`` parameter. + """ + entity = SelectEntity( + "select.test", + "Test Select", + attributes={ + select.Attributes.STATE: select.States.ON, + select.Attributes.CURRENT_OPTION: "HDMI 1", + select.Attributes.OPTIONS: ["HDMI 1", "HDMI 2", "HDMI 3"], + }, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == select.States.ON + assert entity.current_option == "HDMI 1" + assert entity.options == ["HDMI 1", "HDMI 2", "HDMI 3"] + + def test_initial_state_minimal(self, mock_api): + """Test initial state when only required args are passed.""" + entity = SelectEntity("select.minimal", "Minimal Select", attributes={}) + entity._api = mock_api # noqa: SLF001 + assert entity.state is None + assert entity.current_option is None + assert entity.options is None + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(select.States.UNAVAILABLE, update=True) + + assert entity.state == select.States.UNAVAILABLE + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "select.test" + assert select.Attributes.STATE in attributes + assert attributes[select.Attributes.STATE] == select.States.UNAVAILABLE + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(select.States.UNAVAILABLE, update=False) + + assert entity.state == select.States.UNAVAILABLE + assert not mock_api.configured_entities.update_attributes.called + + def test_set_current_option(self, entity, mock_api): + """Test set_current_option() updates state and calls update.""" + entity.set_current_option("HDMI 2", update=True) + + assert entity.current_option == "HDMI 2" + assert mock_api.configured_entities.update_attributes.called + + def test_set_current_option_without_update(self, entity, mock_api): + """Test set_current_option(update=False) does not call update.""" + entity.set_current_option("HDMI 2", update=False) + + assert entity.current_option == "HDMI 2" + assert not mock_api.configured_entities.update_attributes.called + + def test_set_options(self, entity, mock_api): + """Test set_options() updates the options list and calls update.""" + new_options = ["Input 1", "Input 2"] + entity.set_options(new_options, update=True) + + assert entity.options == new_options + assert mock_api.configured_entities.update_attributes.called + + def test_set_options_without_update(self, entity, mock_api): + """Test set_options(update=False) does not call update.""" + new_options = ["Input 1", "Input 2"] + entity.set_options(new_options, update=False) + + assert entity.options == new_options + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_bulk_update(self, entity, mock_api): + """Test set_attributes() updates multiple attributes with single update call.""" + entity.set_attributes( + state=select.States.ON, + current_option="HDMI 2", + options=["HDMI 1", "HDMI 2"], + update=True, + ) + + assert entity.state == select.States.ON + assert entity.current_option == "HDMI 2" + assert entity.options == ["HDMI 1", "HDMI 2"] + + # Verify update was called only once + assert mock_api.configured_entities.update_attributes.call_count == 1 + + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "select.test" + # STATE is unchanged (mock returns ON, we set ON — filtered out by filter_changed_attributes) + # Only CURRENT_OPTION and OPTIONS appear in the update + assert select.Attributes.CURRENT_OPTION in attributes + assert attributes[select.Attributes.CURRENT_OPTION] == "HDMI 2" + assert select.Attributes.OPTIONS in attributes + assert attributes[select.Attributes.OPTIONS] == ["HDMI 1", "HDMI 2"] + + def test_set_attributes_without_update(self, entity, mock_api): + """Test set_attributes(update=False) does not call entity.update().""" + entity.set_attributes( + state=select.States.ON, + current_option="HDMI 3", + update=False, + ) + + assert entity.state == select.States.ON + assert entity.current_option == "HDMI 3" + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_ignores_none_values(self, entity, mock_api): + """Test set_attributes() ignores None values.""" + entity.set_attributes(state=select.States.ON, current_option=None, update=True) + + assert entity.state == select.States.ON + assert entity.current_option == "HDMI 1" # unchanged + + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + # current_option=None was ignored, so entity.current_option stays "HDMI 1" + # The filter may include CURRENT_OPTION (it's in entity.attributes but not in mock), + # but it must NOT be None + if select.Attributes.CURRENT_OPTION in attributes: + assert ( + attributes[select.Attributes.CURRENT_OPTION] is not None + ) # None was ignored + + def test_property_getters_are_read_only(self, entity): + """Test that property getters cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = select.States.ON # type: ignore[misc] + + with pytest.raises(AttributeError): + entity.current_option = "HDMI 2" # type: ignore[misc] + + # Note: entity.options has a setter (required to avoid clash with ucapi Entity.options) + # so direct assignment is allowed but has no effect on the select options list + + +class TestSelectEntityInheritance: + """Test that SelectEntity can be subclassed and overridden.""" + + def test_custom_set_current_option(self): + """Test that set_current_option can be overridden.""" + + class CustomSelect(SelectEntity): + def __init__(self): + super().__init__( + "select.custom", + "Custom Select", + attributes={}, + ) + self.custom_called = False + + def set_current_option(self, value, *, update=True): + self.custom_called = True + super().set_current_option(value, update=update) + + entity = CustomSelect() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_current_option("Option A", update=False) + assert entity.custom_called is True + assert entity.current_option == "Option A" diff --git a/tests/test_sensor_entity.py b/tests/test_sensor_entity.py new file mode 100644 index 0000000..bf6f8ca --- /dev/null +++ b/tests/test_sensor_entity.py @@ -0,0 +1,206 @@ +"""Tests for SensorEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import sensor +from ucapi_framework import SensorEntity + + +class TestSensorEntity: + """Test SensorEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={sensor.Attributes.STATE: sensor.States.ON} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a SensorEntity for testing. + + Note: sensor.Sensor does NOT accept a ``cmd_handler`` parameter. + """ + entity = SensorEntity( + "sensor.test", + "Test Sensor", + features=[], + attributes={ + sensor.Attributes.STATE: sensor.States.ON, + sensor.Attributes.VALUE: 21.5, + sensor.Attributes.UNIT: "°C", + }, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == sensor.States.ON + assert entity.value == 21.5 + assert entity.unit == "°C" + + def test_initial_state_minimal(self, mock_api): + """Test initial state when only required args are passed.""" + entity = SensorEntity( + "sensor.minimal", "Minimal Sensor", features=[], attributes={} + ) + entity._api = mock_api # noqa: SLF001 + assert entity.state is None + assert entity.value is None + assert entity.unit is None + + def test_set_state_with_update(self, entity, mock_api): + """Test set_state() calls entity.update() by default.""" + entity.set_state(sensor.States.UNAVAILABLE, update=True) + + assert entity.state == sensor.States.UNAVAILABLE + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "sensor.test" + assert sensor.Attributes.STATE in attributes + assert attributes[sensor.Attributes.STATE] == sensor.States.UNAVAILABLE + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(sensor.States.UNAVAILABLE, update=False) + + assert entity.state == sensor.States.UNAVAILABLE + assert not mock_api.configured_entities.update_attributes.called + + def test_set_value_int(self, entity, mock_api): + """Test set_value() with an integer value.""" + entity.set_value(42, update=True) + + assert entity.value == 42 + assert mock_api.configured_entities.update_attributes.called + + def test_set_value_float(self, entity, mock_api): + """Test set_value() with a float value.""" + entity.set_value(23.7, update=True) + + assert entity.value == 23.7 + assert mock_api.configured_entities.update_attributes.called + + def test_set_value_string(self, entity, mock_api): + """Test set_value() with a string value (sensor value is typed Any).""" + entity.set_value("high", update=True) + + assert entity.value == "high" + assert mock_api.configured_entities.update_attributes.called + + def test_set_value_without_update(self, entity, mock_api): + """Test set_value(update=False) does not call update.""" + entity.set_value(99.9, update=False) + + assert entity.value == 99.9 + assert not mock_api.configured_entities.update_attributes.called + + def test_set_unit(self, entity, mock_api): + """Test set_unit() updates state and calls update.""" + entity.set_unit("°F", update=True) + + assert entity.unit == "°F" + assert mock_api.configured_entities.update_attributes.called + + def test_set_unit_without_update(self, entity, mock_api): + """Test set_unit(update=False) does not call update.""" + entity.set_unit("K", update=False) + + assert entity.unit == "K" + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_bulk_update(self, entity, mock_api): + """Test set_attributes() updates multiple attributes with single update call.""" + entity.set_attributes( + state=sensor.States.ON, + value=30.0, + unit="°F", + update=True, + ) + + assert entity.state == sensor.States.ON + assert entity.value == 30.0 + assert entity.unit == "°F" + + # Verify update was called only once + assert mock_api.configured_entities.update_attributes.call_count == 1 + + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "sensor.test" + # STATE is unchanged (mock returns ON, we set ON), so filter removes it + # Only VALUE and UNIT are new/changed + assert sensor.Attributes.VALUE in attributes + assert attributes[sensor.Attributes.VALUE] == 30.0 + assert sensor.Attributes.UNIT in attributes + assert attributes[sensor.Attributes.UNIT] == "°F" + + def test_set_attributes_without_update(self, entity, mock_api): + """Test set_attributes(update=False) does not call entity.update().""" + entity.set_attributes(value=50, unit="W", update=False) + + assert entity.value == 50 + assert entity.unit == "W" + assert not mock_api.configured_entities.update_attributes.called + + def test_set_attributes_ignores_none_values(self, entity, mock_api): + """Test set_attributes() ignores None values.""" + entity.set_attributes(state=sensor.States.ON, value=None, update=True) + + assert entity.state == sensor.States.ON + assert entity.value == 21.5 # unchanged from fixture + + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + # value=None was ignored (not written to entity attributes) + # The entity.value stays at 21.5 (unchanged from fixture) + # STATE is filtered (unchanged), VALUE and UNIT may appear in update + # The key assertion: we didn't accidentally write None for value + if sensor.Attributes.VALUE in attributes: + assert attributes[sensor.Attributes.VALUE] is not None # None was ignored + + def test_property_getters_are_read_only(self, entity): + """Test that property getters cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = sensor.States.ON # type: ignore[misc] + + with pytest.raises(AttributeError): + entity.value = 0 # type: ignore[misc] + + with pytest.raises(AttributeError): + entity.unit = "°C" # type: ignore[misc] + + +class TestSensorEntityInheritance: + """Test that SensorEntity can be subclassed and overridden.""" + + def test_custom_set_value(self): + """Test that set_value can be overridden.""" + + class CustomSensor(SensorEntity): + def __init__(self): + super().__init__( + "sensor.custom", + "Custom Sensor", + features=[], + attributes={}, + ) + self.custom_called = False + + def set_value(self, value, *, update=True): + self.custom_called = True + super().set_value(value, update=update) + + entity = CustomSensor() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_value(42, update=False) + assert entity.custom_called is True + assert entity.value == 42 diff --git a/tests/test_switch_entity.py b/tests/test_switch_entity.py new file mode 100644 index 0000000..5028db4 --- /dev/null +++ b/tests/test_switch_entity.py @@ -0,0 +1,128 @@ +"""Tests for SwitchEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import switch +from ucapi_framework import SwitchEntity + + +class TestSwitchEntity: + """Test SwitchEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={switch.Attributes.STATE: switch.States.OFF} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a SwitchEntity for testing.""" + entity = SwitchEntity( + "switch.test", + "Test Switch", + features=[switch.Features.ON_OFF], + attributes={switch.Attributes.STATE: switch.States.OFF}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == switch.States.OFF + + def test_set_state_on_with_update(self, entity, mock_api): + """Test set_state(ON) calls entity.update() by default.""" + entity.set_state(switch.States.ON, update=True) + + assert entity.state == switch.States.ON + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "switch.test" + assert switch.Attributes.STATE in attributes + assert attributes[switch.Attributes.STATE] == switch.States.ON + + def test_set_state_off_with_update(self, entity, mock_api): + """Test set_state(OFF) calls entity.update() when state actually changes.""" + # Make mock return ON so that transitioning to OFF appears as a change + mock_api.configured_entities.get.return_value = MagicMock( + attributes={switch.Attributes.STATE: switch.States.ON} + ) + entity.set_state(switch.States.OFF, update=True) + + assert entity.state == switch.States.OFF + assert mock_api.configured_entities.update_attributes.called + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(switch.States.ON, update=False) + + assert entity.state == switch.States.ON + assert not mock_api.configured_entities.update_attributes.called + + def test_set_state_unavailable(self, entity, mock_api): + """Test transitioning to UNAVAILABLE.""" + entity.set_state(switch.States.UNAVAILABLE, update=False) + assert entity.state == switch.States.UNAVAILABLE + + def test_set_state_none_does_not_send_none(self, entity, mock_api): + """Test set_state(None) stores None but update filter strips it.""" + entity.set_state(None, update=True) + + assert entity.state is None + + if mock_api.configured_entities.update_attributes.called: + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert switch.Attributes.STATE not in attributes + + def test_property_getter_is_read_only(self, entity): + """Test that the state property cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = switch.States.ON # type: ignore[misc] + + def test_only_one_attribute(self, entity, mock_api): + """Test that SwitchEntity only manages the STATE attribute.""" + entity.set_state(switch.States.ON, update=True) + + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert list(attributes.keys()) == [switch.Attributes.STATE] + + def test_no_set_attributes_method(self, entity): + """Test that SwitchEntity does not have a set_attributes bulk helper.""" + assert not hasattr(entity, "set_attributes") + + +class TestSwitchEntityInheritance: + """Test that SwitchEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomSwitch(SwitchEntity): + def __init__(self): + super().__init__( + "switch.custom", + "Custom Switch", + features=[], + attributes={}, + ) + self.custom_called = False + + def set_state(self, value, *, update=True): + self.custom_called = True + super().set_state(value, update=update) + + entity = CustomSwitch() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(switch.States.ON, update=False) + assert entity.custom_called is True + assert entity.state == switch.States.ON diff --git a/tests/test_voice_assistant_entity.py b/tests/test_voice_assistant_entity.py new file mode 100644 index 0000000..2483480 --- /dev/null +++ b/tests/test_voice_assistant_entity.py @@ -0,0 +1,128 @@ +"""Tests for VoiceAssistantEntity with built-in state management.""" + +import pytest +from unittest.mock import MagicMock +from ucapi import voice_assistant +from ucapi_framework import VoiceAssistantEntity + + +class TestVoiceAssistantEntity: + """Test VoiceAssistantEntity state management.""" + + @pytest.fixture + def mock_api(self): + """Create a mock API for testing.""" + api = MagicMock() + api.configured_entities.get.return_value = MagicMock( + attributes={voice_assistant.Attributes.STATE: voice_assistant.States.OFF} + ) + return api + + @pytest.fixture + def entity(self, mock_api): + """Create a VoiceAssistantEntity for testing.""" + entity = VoiceAssistantEntity( + "voice_assistant.test", + "Test Voice Assistant", + features=[voice_assistant.Features.TRANSCRIPTION], + attributes={voice_assistant.Attributes.STATE: voice_assistant.States.OFF}, + ) + entity._api = mock_api # noqa: SLF001 + return entity + + def test_initial_state(self, entity): + """Test initial state from constructor attributes.""" + assert entity.state == voice_assistant.States.OFF + + def test_set_state_on_with_update(self, entity, mock_api): + """Test set_state(ON) calls entity.update() by default.""" + entity.set_state(voice_assistant.States.ON, update=True) + + assert entity.state == voice_assistant.States.ON + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "voice_assistant.test" + assert voice_assistant.Attributes.STATE in attributes + assert attributes[voice_assistant.Attributes.STATE] == voice_assistant.States.ON + + def test_set_state_off_with_update(self, entity, mock_api): + """Test set_state(OFF) calls entity.update() when state actually changes.""" + # Make mock return ON so that transitioning to OFF appears as a change + mock_api.configured_entities.get.return_value = MagicMock( + attributes={voice_assistant.Attributes.STATE: voice_assistant.States.ON} + ) + entity.set_state(voice_assistant.States.OFF, update=True) + + assert entity.state == voice_assistant.States.OFF + assert mock_api.configured_entities.update_attributes.called + + def test_set_state_without_update(self, entity, mock_api): + """Test set_state(update=False) does not call entity.update().""" + entity.set_state(voice_assistant.States.ON, update=False) + + assert entity.state == voice_assistant.States.ON + assert not mock_api.configured_entities.update_attributes.called + + def test_set_state_unavailable(self, entity, mock_api): + """Test transitioning to UNAVAILABLE.""" + entity.set_state(voice_assistant.States.UNAVAILABLE, update=False) + assert entity.state == voice_assistant.States.UNAVAILABLE + + def test_set_state_none_does_not_send_none(self, entity, mock_api): + """Test set_state(None) stores None but update filter strips it.""" + entity.set_state(None, update=True) + + assert entity.state is None + + if mock_api.configured_entities.update_attributes.called: + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert voice_assistant.Attributes.STATE not in attributes + + def test_property_getter_is_read_only(self, entity): + """Test that the state property cannot be set directly.""" + with pytest.raises(AttributeError): + entity.state = voice_assistant.States.ON # type: ignore[misc] + + def test_only_one_attribute(self, entity, mock_api): + """Test that VoiceAssistantEntity only manages the STATE attribute.""" + entity.set_state(voice_assistant.States.ON, update=True) + + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert list(attributes.keys()) == [voice_assistant.Attributes.STATE] + + def test_no_set_attributes_method(self, entity): + """Test that VoiceAssistantEntity does not have a set_attributes bulk helper.""" + assert not hasattr(entity, "set_attributes") + + +class TestVoiceAssistantEntityInheritance: + """Test that VoiceAssistantEntity can be subclassed and overridden.""" + + def test_custom_set_state(self): + """Test that set_state can be overridden.""" + + class CustomVoiceAssistant(VoiceAssistantEntity): + def __init__(self): + super().__init__( + "voice_assistant.custom", + "Custom Voice Assistant", + features=[], + attributes={}, + ) + self.custom_called = False + + def set_state(self, value, *, update=True): + self.custom_called = True + super().set_state(value, update=update) + + entity = CustomVoiceAssistant() + entity._api = MagicMock() # noqa: SLF001 + entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + + entity.set_state(voice_assistant.States.ON, update=False) + assert entity.custom_called is True + assert entity.state == voice_assistant.States.ON diff --git a/ucapi_framework/__init__.py b/ucapi_framework/__init__.py index 1aee8d5..f37e3f0 100644 --- a/ucapi_framework/__init__.py +++ b/ucapi_framework/__init__.py @@ -61,6 +61,19 @@ SwitchAttributes, VoiceAssistantAttributes, ) +from .entities import ( + MediaPlayerEntity, + LightEntity, + CoverEntity, + ButtonEntity, + ClimateEntity, + IREmitterEntity, + RemoteEntity, + SelectEntity, + SensorEntity, + SwitchEntity, + VoiceAssistantEntity, +) __all__ = [ "BaseIntegrationDriver", @@ -102,6 +115,17 @@ "SensorAttributes", "SwitchAttributes", "VoiceAssistantAttributes", + "MediaPlayerEntity", + "LightEntity", + "CoverEntity", + "ButtonEntity", + "ClimateEntity", + "IREmitterEntity", + "RemoteEntity", + "SelectEntity", + "SensorEntity", + "SwitchEntity", + "VoiceAssistantEntity", ] __version__ = "1.8.4" diff --git a/ucapi_framework/driver.py b/ucapi_framework/driver.py index 712fa87..7c278b8 100644 --- a/ucapi_framework/driver.py +++ b/ucapi_framework/driver.py @@ -594,6 +594,12 @@ def get_device_attributes(self, entity_id: str): cast(FrameworkEntity, configured_entity) if has_update else None ) + # Short-circuit: if entity has overridden sync_state(), delegate entirely to it. + # This is the coordinator pattern — the entity knows how to read its own device. + if framework_entity and type(framework_entity).sync_state is not FrameworkEntity.sync_state: + await framework_entity.sync_state() + return + # Try to get attributes from device device_attrs = None if hasattr(device, "get_device_attributes"): @@ -1021,34 +1027,65 @@ def filter_entities_by_type( return filtered_entities def get_entity_by_id( - self, entity_id: str, source: EntitySource | str = EntitySource.ALL + self, + entity_id: str | None = None, + source: EntitySource | str = EntitySource.ALL, + *, + entity_type: EntityTypes | str | None = None, + device_id: str | None = None, + sub_device_id: str | None = None, ) -> Entity | None: """ - Get a specific entity by its ID. + Get a specific entity by its full ID or by its component parts. + + Accepts either a full ``entity_id`` string **or** the keyword components + ``entity_type``, ``device_id``, and ``sub_device_id``, which are passed + directly to the module-level :func:`create_entity_id` to construct the + lookup key. All three components are required when ``entity_id`` is omitted. - Searches for the entity in available entities, configured entities, or both. + Example usage:: - Example usage: - # Get an entity from any source + # Full entity ID (original form — unchanged) entity = driver.get_entity_by_id("light.living_room.main") - # Get only from configured entities + # Component parts — avoids a separate create_entity_id call + entity = driver.get_entity_by_id( + entity_type=EntityTypes.LIGHT, + device_id="living_room", + sub_device_id="main", + ) + + # No sub-device + entity = driver.get_entity_by_id( + entity_type=EntityTypes.MEDIA_PLAYER, + device_id="receiver_abc", + ) + + # Restrict search scope entity = driver.get_entity_by_id( "sensor.bedroom.temp", - source=EntitySource.CONFIGURED + source=EntitySource.CONFIGURED, ) - if entity: - print(f"Found entity: {entity.name}") + :param entity_id: Full entity identifier string. When provided, the + component kwargs are ignored. + :param source: Which collection(s) to search (``EntitySource`` enum or string): + ``ALL`` (default), ``AVAILABLE``, or ``CONFIGURED``. + :param entity_type: Entity type. Required when ``entity_id`` is ``None``. + :param device_id: Device identifier. Required when ``entity_id`` is ``None``. + :param sub_device_id: Optional sub-device identifier. + :return: Entity object if found, ``None`` otherwise. + :raises ValueError: If ``source`` is invalid, or if ``entity_type`` or + ``device_id`` are missing when ``entity_id`` is not provided. + """ + # Build entity_id from components when not supplied directly + if entity_id is None: + if entity_type is None or device_id is None: + raise ValueError( + "entity_type and device_id are both required when entity_id is not provided." + ) + entity_id = create_entity_id(entity_type, device_id, sub_device_id) - :param entity_id: Entity identifier to search for - :param source: Which collection(s) to search (EntitySource enum or string): - EntitySource.ALL or "all" (default) - both available and configured - EntitySource.AVAILABLE or "available" - only available entities - EntitySource.CONFIGURED or "configured" - only configured entities - :return: Entity object if found, None otherwise - :raises ValueError: If source is not valid - """ # Normalize source to string source_str = source.value if isinstance(source, EntitySource) else source @@ -1351,21 +1388,40 @@ async def on_device_connection_error(self, device_id: str, message: str) -> None async def on_device_update( self, - entity_id: str, - update: dict[str, Any] | None, + entity_id: str | None = None, + update: dict[str, Any] | None = None, clear_media_when_off: bool = True, ) -> None: """ Handle device state updates. - Default implementation extracts entity-type-specific attributes from the - update dict and updates configured/available entities accordingly. - Override this method to customize update handling or add state mapping. + This handler is wired to ``DeviceEvents.UPDATE`` and supports two patterns: + + **Coordinator pattern** (recommended): + Entities that override ``sync_state()`` and call ``subscribe_to_device()`` + manage their own state. The device simply emits ``DeviceEvents.UPDATE`` with + no arguments — ``entity_id`` and ``update`` are ignored, and this handler + returns immediately without doing any work. + + **Legacy / attribute-routing pattern**: + The device emits ``DeviceEvents.UPDATE`` with an ``entity_id`` and an + ``update`` dict of raw attribute values. This handler extracts the + entity-type-specific attributes and pushes them to the Remote. Override + this method to customise the attribute routing or state mapping. + + :param entity_id: Entity identifier. Required for the legacy pattern; omit + (or pass ``None``) when using the coordinator pattern. + :param update: Dictionary of raw attribute values to apply. Required for the + legacy pattern; omit (or pass ``None``) when using the + coordinator pattern. + :param clear_media_when_off: Legacy pattern only. If ``True``, clears all + media player attributes when the state transitions + to ``OFF``. Has no effect in the coordinator pattern. + """ + if entity_id is None: + # Coordinator pattern: entities handle their own updates via sync_state(). + return - :param device_id: Device identifier - :param update: Dictionary containing updated properties - :param clear_media_when_off: If True, clears all media player attributes when state is OFF - """ if update is None: _LOG.warning("[%s] Received None update, skipping", entity_id) return @@ -1391,6 +1447,11 @@ async def on_device_update( cast(FrameworkEntity, configured_entity) if has_custom_behavior else None ) + # Short-circuit: if entity has overridden sync_state(), it manages its own state + # via subscribe_to_device(). Skip attribute routing to avoid double execution. + if framework_entity and type(framework_entity).sync_state is not FrameworkEntity.sync_state: + return + attributes: dict[str, Any] = {} match configured_entity.entity_type: diff --git a/ucapi_framework/entities/README.md b/ucapi_framework/entities/README.md new file mode 100644 index 0000000..d9711dd --- /dev/null +++ b/ucapi_framework/entities/README.md @@ -0,0 +1,53 @@ +# Entity Subclasses with Built-in State Management + +This folder contains entity subclasses that manage their own state internally, eliminating the need for devices to track entity state via `get_device_attributes()`. + +## Philosophy + +**Before**: Devices tracked state, entities retrieved it via `get_device_attributes()` + +**Now**: Entities track their own state using property-based accessors + +This provides a more natural separation of concerns where: + +- **Devices** handle communication with physical/remote devices +- **Entities** manage their presentation state for the Remote + +## Available Entity Classes + +### MediaPlayerEntity + +Full-featured MediaPlayer with state management for all media player attributes. + +**Example**: + +```python +from ucapi_framework.entities import MediaPlayerEntity + +class MyMediaPlayer(MediaPlayerEntity): + def __init__(self, device_config, device): + super().__init__( + f"media_player.{device_config.id}", + device_config.name, + features=[...], + attributes={...} + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == media_player.Commands.ON: + await self._device.turn_on() + self.set_state(media_player.States.ON) # State managed by entity! +``` + +## Coming Soon + +- `ClimateEntity` - Climate control with temperature, fan mode, etc. +- `CoverEntity` - Covers/blinds with position and tilt +- `LightEntity` - Lights with brightness, color, etc. +- `SwitchEntity` - Simple on/off switches +- Additional entity types... + +## Documentation + +See [Entity State Management Guide](../../docs/guide/entity-state-management.md) for comprehensive usage examples and patterns. diff --git a/ucapi_framework/entities/__init__.py b/ucapi_framework/entities/__init__.py new file mode 100644 index 0000000..cdcf89b --- /dev/null +++ b/ucapi_framework/entities/__init__.py @@ -0,0 +1,35 @@ +""" +Entity subclasses with built-in state management. + +Provides concrete entity implementations that manage their own state using +property-based accessors and update methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from .media_player import MediaPlayerEntity +from .light import LightEntity +from .cover import CoverEntity +from .button import ButtonEntity +from .climate import ClimateEntity +from .ir_emitter import IREmitterEntity +from .remote import RemoteEntity +from .select import SelectEntity +from .sensor import SensorEntity +from .switch import SwitchEntity +from .voice_assistant import VoiceAssistantEntity + +__all__ = [ + "MediaPlayerEntity", + "LightEntity", + "CoverEntity", + "ButtonEntity", + "ClimateEntity", + "IREmitterEntity", + "RemoteEntity", + "SelectEntity", + "SensorEntity", + "SwitchEntity", + "VoiceAssistantEntity", +] diff --git a/ucapi_framework/entities/button.py b/ucapi_framework/entities/button.py new file mode 100644 index 0000000..f4b7655 --- /dev/null +++ b/ucapi_framework/entities/button.py @@ -0,0 +1,89 @@ +""" +Button entity with built-in state management. + +Provides a Button entity subclass that manages its own state internally +using a property getter and setter method. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import button +from ucapi_framework.entity import Entity + + +class ButtonEntity(button.Button, Entity): + """ + Button entity with built-in state management. + + This class extends the base Button entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + A Button entity has a single attribute: STATE (AVAILABLE or UNAVAILABLE). + + **State Management Pattern**: + - A property getter provides read access (e.g., ``entity.state``) + - A setter method handles updates (e.g., ``entity.set_state(States.AVAILABLE)``) + - The setter accepts an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - The property is overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import button + from ucapi_framework.entities import ButtonEntity + + class MyButton(ButtonEntity): + def __init__(self, device_config, device): + entity_id = f"button.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[button.Features.PRESS], + attributes={button.Attributes.STATE: button.States.AVAILABLE}, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == button.Commands.PUSH: + await self._device.press() + # State stays AVAILABLE after a press + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Button entity with state tracking. + + Accepts the same parameters as ucapi.button.Button: + ``identifier``, ``name``, optional ``area``, optional ``cmd_handler``. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getter (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> button.States | None: + """Get current availability state (AVAILABLE or UNAVAILABLE).""" + return self.attributes.get(button.Attributes.STATE) + + # ======================================================================== + # Setter Method (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: button.States | None, *, update: bool = False) -> None: + """ + Set availability state. + + :param value: New state value (AVAILABLE or UNAVAILABLE) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[button.Attributes.STATE] = value + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/climate.py b/ucapi_framework/entities/climate.py new file mode 100644 index 0000000..2816797 --- /dev/null +++ b/ucapi_framework/entities/climate.py @@ -0,0 +1,236 @@ +""" +Climate entity with built-in state management. + +Provides a Climate entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import climate +from ucapi_framework.entity import Entity + + +class ClimateEntity(climate.Climate, Entity): + """ + Climate entity with built-in state management. + + This class extends the base Climate entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - Each attribute has a property getter (e.g., ``entity.state``) + - Each attribute has a setter method (e.g., ``entity.set_state(States.HEAT)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import climate + from ucapi_framework.entities import ClimateEntity + + class MyThermostat(ClimateEntity): + def __init__(self, device_config, device): + entity_id = f"climate.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + climate.Features.ON_OFF, + climate.Features.HEAT, + climate.Features.CURRENT_TEMPERATURE, + climate.Features.TARGET_TEMPERATURE, + ], + attributes={ + climate.Attributes.STATE: climate.States.OFF, + climate.Attributes.CURRENT_TEMPERATURE: 20.0, + climate.Attributes.TARGET_TEMPERATURE: 21.0, + }, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == climate.Commands.ON: + await self._device.turn_on() + self.set_state(climate.States.HEAT) + elif cmd_id == climate.Commands.TARGET_TEMPERATURE: + await self._device.set_temperature(params["temperature"]) + self.set_target_temperature(params["temperature"]) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Climate entity with state tracking. + + Accepts the same parameters as ucapi.climate.Climate. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> climate.States | None: + """Get current climate state (OFF, HEAT, COOL, HEAT_COOL, FAN, AUTO).""" + return self.attributes.get(climate.Attributes.STATE) + + @property + def current_temperature(self) -> float | None: + """Get current measured temperature.""" + return self.attributes.get(climate.Attributes.CURRENT_TEMPERATURE) + + @property + def target_temperature(self) -> float | None: + """Get target temperature.""" + return self.attributes.get(climate.Attributes.TARGET_TEMPERATURE) + + @property + def target_temperature_high(self) -> float | None: + """Get upper bound of target temperature range.""" + return self.attributes.get(climate.Attributes.TARGET_TEMPERATURE_HIGH) + + @property + def target_temperature_low(self) -> float | None: + """Get lower bound of target temperature range.""" + return self.attributes.get(climate.Attributes.TARGET_TEMPERATURE_LOW) + + @property + def fan_mode(self) -> str | None: + """Get current fan mode.""" + return self.attributes.get(climate.Attributes.FAN_MODE) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: climate.States | None, *, update: bool = False) -> None: + """ + Set climate state. + + :param value: New state value (OFF, HEAT, COOL, HEAT_COOL, FAN, AUTO) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[climate.Attributes.STATE] = value + if update: + self.update(self.attributes) + + def set_current_temperature( + self, value: float | None, *, update: bool = False + ) -> None: + """ + Set current measured temperature. + + :param value: Current temperature value + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[climate.Attributes.CURRENT_TEMPERATURE] = value + if update: + self.update(self.attributes) + + def set_target_temperature( + self, value: float | None, *, update: bool = False + ) -> None: + """ + Set target temperature. + + :param value: Target temperature value + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[climate.Attributes.TARGET_TEMPERATURE] = value + if update: + self.update(self.attributes) + + def set_target_temperature_high( + self, value: float | None, *, update: bool = False + ) -> None: + """ + Set upper bound of target temperature range. + + :param value: High target temperature value + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[climate.Attributes.TARGET_TEMPERATURE_HIGH] = value + if update: + self.update(self.attributes) + + def set_target_temperature_low( + self, value: float | None, *, update: bool = False + ) -> None: + """ + Set lower bound of target temperature range. + + :param value: Low target temperature value + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[climate.Attributes.TARGET_TEMPERATURE_LOW] = value + if update: + self.update(self.attributes) + + def set_fan_mode(self, value: str | None, *, update: bool = False) -> None: + """ + Set fan mode. + + :param value: Fan mode string + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[climate.Attributes.FAN_MODE] = value + if update: + self.update(self.attributes) + + # ======================================================================== + # Bulk Update Helper + # ======================================================================== + + def set_attributes( + self, + *, + state: climate.States | None = None, + current_temperature: float | None = None, + target_temperature: float | None = None, + target_temperature_high: float | None = None, + target_temperature_low: float | None = None, + fan_mode: str | None = None, + update: bool = False, + ) -> None: + """ + Update multiple attributes at once with a single Remote update call. + + Only non-``None`` arguments are written into ``self.attributes``. + + :param state: Climate state + :param current_temperature: Current measured temperature + :param target_temperature: Target temperature + :param target_temperature_high: Upper bound of target temperature range + :param target_temperature_low: Lower bound of target temperature range + :param fan_mode: Fan mode string + :param update: If True, call entity.update() once after all changes (default: True) + """ + if state is not None: + self.attributes[climate.Attributes.STATE] = state + if current_temperature is not None: + self.attributes[climate.Attributes.CURRENT_TEMPERATURE] = ( + current_temperature + ) + if target_temperature is not None: + self.attributes[climate.Attributes.TARGET_TEMPERATURE] = target_temperature + if target_temperature_high is not None: + self.attributes[climate.Attributes.TARGET_TEMPERATURE_HIGH] = ( + target_temperature_high + ) + if target_temperature_low is not None: + self.attributes[climate.Attributes.TARGET_TEMPERATURE_LOW] = ( + target_temperature_low + ) + if fan_mode is not None: + self.attributes[climate.Attributes.FAN_MODE] = fan_mode + + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/cover.py b/ucapi_framework/entities/cover.py new file mode 100644 index 0000000..95368dc --- /dev/null +++ b/ucapi_framework/entities/cover.py @@ -0,0 +1,161 @@ +""" +Cover entity with built-in state management. + +Provides a Cover entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import cover +from ucapi_framework.entity import Entity + + +class CoverEntity(cover.Cover, Entity): + """ + Cover entity with built-in state management. + + This class extends the base Cover entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - Each attribute has a property getter (e.g., ``entity.state``) + - Each attribute has a setter method (e.g., ``entity.set_state(States.OPEN)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import cover + from ucapi_framework.entities import CoverEntity + + class MyBlind(CoverEntity): + def __init__(self, device_config, device): + entity_id = f"cover.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + cover.Features.OPEN, + cover.Features.CLOSE, + cover.Features.POSITION, + ], + attributes={ + cover.Attributes.STATE: cover.States.CLOSED, + cover.Attributes.POSITION: 0, + }, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == cover.Commands.OPEN: + await self._device.open() + self.set_state(cover.States.OPENING) + + elif cmd_id == cover.Commands.SET_POSITION: + await self._device.set_position(params['position']) + self.set_position(params['position']) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Cover entity with state tracking. + + Accepts the same parameters as ucapi.cover.Cover. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> cover.States | None: + """Get current cover state (OPEN, CLOSED, OPENING, CLOSING, etc.).""" + return self.attributes.get(cover.Attributes.STATE) + + @property + def position(self) -> int | None: + """Get current position (0=closed, 100=fully open).""" + return self.attributes.get(cover.Attributes.POSITION) + + @property + def tilt_position(self) -> int | None: + """Get current tilt position (0=closed, 100=fully open).""" + return self.attributes.get(cover.Attributes.TILT_POSITION) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: cover.States | None, *, update: bool = False) -> None: + """ + Set cover state. + + :param value: New state value + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[cover.Attributes.STATE] = value + if update: + self.update(self.attributes) + + def set_position(self, value: int | None, *, update: bool = False) -> None: + """ + Set cover position. + + :param value: Position (0=closed, 100=fully open) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[cover.Attributes.POSITION] = value + if update: + self.update(self.attributes) + + def set_tilt_position(self, value: int | None, *, update: bool = False) -> None: + """ + Set cover tilt position. + + :param value: Tilt position (0=closed, 100=fully open) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[cover.Attributes.TILT_POSITION] = value + if update: + self.update(self.attributes) + + # ======================================================================== + # Bulk Update Helper + # ======================================================================== + + def set_attributes( + self, + *, + state: cover.States | None = None, + position: int | None = None, + tilt_position: int | None = None, + update: bool = False, + ) -> None: + """ + Update multiple attributes at once with a single Remote update call. + + Only non-``None`` arguments are written into ``self.attributes``. + + :param state: Cover state + :param position: Position (0=closed, 100=fully open) + :param tilt_position: Tilt position (0=closed, 100=fully open) + :param update: If True, call entity.update() once after all changes (default: True) + """ + if state is not None: + self.attributes[cover.Attributes.STATE] = state + if position is not None: + self.attributes[cover.Attributes.POSITION] = position + if tilt_position is not None: + self.attributes[cover.Attributes.TILT_POSITION] = tilt_position + + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/ir_emitter.py b/ucapi_framework/entities/ir_emitter.py new file mode 100644 index 0000000..277524a --- /dev/null +++ b/ucapi_framework/entities/ir_emitter.py @@ -0,0 +1,89 @@ +""" +IR Emitter entity with built-in state management. + +Provides an IREmitter entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import ir_emitter +from ucapi_framework.entity import Entity + + +class IREmitterEntity(ir_emitter.IREmitter, Entity): + """ + IR Emitter entity with built-in state management. + + This class extends the base IREmitter entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - The state attribute has a property getter (e.g., ``entity.state``) + - The state attribute has a setter method (e.g., ``entity.set_state(States.ON)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import ir_emitter + from ucapi_framework.entities import IREmitterEntity + + class MyIRBlaster(IREmitterEntity): + def __init__(self, device_config, device): + entity_id = f"ir_emitter.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ir_emitter.Features.SEND_IR], + attributes={ + ir_emitter.Attributes.STATE: ir_emitter.States.ON, + }, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == ir_emitter.Commands.SEND_IR: + await self._device.send_ir(params) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize IREmitter entity with state tracking. + + Accepts the same parameters as ucapi.ir_emitter.IREmitter. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> ir_emitter.States | None: + """Get current on/off state.""" + return self.attributes.get(ir_emitter.Attributes.STATE) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state( + self, value: ir_emitter.States | None, *, update: bool = False + ) -> None: + """ + Set on/off state. + + :param value: New state value (ON, UNAVAILABLE, UNKNOWN) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[ir_emitter.Attributes.STATE] = value + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/light.py b/ucapi_framework/entities/light.py new file mode 100644 index 0000000..65c455a --- /dev/null +++ b/ucapi_framework/entities/light.py @@ -0,0 +1,201 @@ +""" +Light entity with built-in state management. + +Provides a Light entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import light +from ucapi_framework.entity import Entity + + +class LightEntity(light.Light, Entity): + """ + Light entity with built-in state management. + + This class extends the base Light entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - Each attribute has a property getter (e.g., ``entity.state``) + - Each attribute has a setter method (e.g., ``entity.set_state(States.ON)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import light + from ucapi_framework.entities import LightEntity + + class MyLight(LightEntity): + def __init__(self, device_config, device): + entity_id = f"light.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + light.Features.ON_OFF, + light.Features.DIM, + light.Features.COLOR_TEMPERATURE, + ], + attributes={ + light.Attributes.STATE: light.States.OFF, + light.Attributes.BRIGHTNESS: 0, + }, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == light.Commands.ON: + await self._device.turn_on() + self.set_state(light.States.ON) + + elif cmd_id == light.Commands.SET_BRIGHTNESS: + await self._device.set_brightness(params['brightness']) + self.set_brightness(params['brightness']) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Light entity with state tracking. + + Accepts the same parameters as ucapi.light.Light. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> light.States | None: + """Get current on/off state.""" + return self.attributes.get(light.Attributes.STATE) + + @property + def hue(self) -> int | None: + """Get current hue value (0-360).""" + return self.attributes.get(light.Attributes.HUE) + + @property + def saturation(self) -> int | None: + """Get current saturation value (0-100).""" + return self.attributes.get(light.Attributes.SATURATION) + + @property + def brightness(self) -> int | None: + """Get current brightness value (0-100).""" + return self.attributes.get(light.Attributes.BRIGHTNESS) + + @property + def color_temperature(self) -> int | None: + """Get current color temperature in Kelvin.""" + return self.attributes.get(light.Attributes.COLOR_TEMPERATURE) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: light.States | None, *, update: bool = False) -> None: + """ + Set on/off state. + + :param value: New state value + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[light.Attributes.STATE] = value + if update: + self.update(self.attributes) + + def set_hue(self, value: int | None, *, update: bool = False) -> None: + """ + Set hue value. + + :param value: Hue (0-360) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[light.Attributes.HUE] = value + if update: + self.update(self.attributes) + + def set_saturation(self, value: int | None, *, update: bool = False) -> None: + """ + Set saturation value. + + :param value: Saturation (0-100) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[light.Attributes.SATURATION] = value + if update: + self.update(self.attributes) + + def set_brightness(self, value: int | None, *, update: bool = False) -> None: + """ + Set brightness value. + + :param value: Brightness (0-100) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[light.Attributes.BRIGHTNESS] = value + if update: + self.update(self.attributes) + + def set_color_temperature(self, value: int | None, *, update: bool = False) -> None: + """ + Set color temperature. + + :param value: Color temperature in Kelvin + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[light.Attributes.COLOR_TEMPERATURE] = value + if update: + self.update(self.attributes) + + # ======================================================================== + # Bulk Update Helper + # ======================================================================== + + def set_attributes( + self, + *, + state: light.States | None = None, + hue: int | None = None, + saturation: int | None = None, + brightness: int | None = None, + color_temperature: int | None = None, + update: bool = False, + ) -> None: + """ + Update multiple attributes at once with a single Remote update call. + + Only non-``None`` arguments are written into ``self.attributes``. + + :param state: On/off state + :param hue: Hue (0-360) + :param saturation: Saturation (0-100) + :param brightness: Brightness (0-100) + :param color_temperature: Color temperature in Kelvin + :param update: If True, call entity.update() once after all changes (default: True) + """ + if state is not None: + self.attributes[light.Attributes.STATE] = state + if hue is not None: + self.attributes[light.Attributes.HUE] = hue + if saturation is not None: + self.attributes[light.Attributes.SATURATION] = saturation + if brightness is not None: + self.attributes[light.Attributes.BRIGHTNESS] = brightness + if color_temperature is not None: + self.attributes[light.Attributes.COLOR_TEMPERATURE] = color_temperature + + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/media_player.py b/ucapi_framework/entities/media_player.py new file mode 100644 index 0000000..7878896 --- /dev/null +++ b/ucapi_framework/entities/media_player.py @@ -0,0 +1,488 @@ +""" +MediaPlayer entity with built-in state management. + +Provides a MediaPlayer entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import media_player +from ucapi_framework.entity import Entity + + +class MediaPlayerEntity(media_player.MediaPlayer, Entity): + """ + MediaPlayer entity with built-in state management. + + This class extends the base MediaPlayer entity to provide built-in state tracking + and management. Instead of requiring devices to track state via get_device_attributes(), + entities now manage their own state internally. + + **State Management Pattern**: + - Each attribute has a property getter (e.g., `entity.state`) + - Each attribute has a setter method (e.g., `entity.set_state(States.PLAYING)`) + - Setter methods accept an optional `update` parameter to control whether + `entity.update()` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import media_player + from ucapi_framework.entities import MediaPlayerEntity + + class MyMediaPlayer(MediaPlayerEntity): + def __init__(self, device_config, device): + entity_id = f"media_player.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + media_player.Features.ON_OFF, + media_player.Features.VOLUME, + ], + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == media_player.Commands.ON: + await self._device.turn_on() + # Update state - automatically calls entity.update() + self.set_state(media_player.States.ON) + + elif cmd_id == media_player.Commands.VOLUME: + await self._device.set_volume(params['volume']) + # Update volume without triggering entity.update() + self.set_volume(params['volume'], update=False) + # Then update state and trigger one update + self.set_state(media_player.States.PLAYING) + ``` + + **Benefits**: + - Natural separation of concerns (entity manages its own state) + - No need for get_device_attributes() on devices + - Type-safe state access with IDE autocomplete + - Explicit control over when updates are sent to Remote + - Still fully overridable for custom behavior + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize MediaPlayer entity with state tracking. + + Accepts the same parameters as ucapi.media_player.MediaPlayer. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> media_player.States | None: + """Get current playback state.""" + return self.attributes.get(media_player.Attributes.STATE) + + @property + def volume(self) -> int | None: + """Get current volume level (0-100).""" + return self.attributes.get(media_player.Attributes.VOLUME) + + @property + def muted(self) -> bool | None: + """Get mute status.""" + return self.attributes.get(media_player.Attributes.MUTED) + + @property + def media_duration(self) -> int | None: + """Get media duration in seconds.""" + return self.attributes.get(media_player.Attributes.MEDIA_DURATION) + + @property + def media_position(self) -> int | None: + """Get current media position in seconds.""" + return self.attributes.get(media_player.Attributes.MEDIA_POSITION) + + @property + def media_position_updated_at(self) -> str | None: + """Get timestamp when media position was last updated.""" + return self.attributes.get(media_player.Attributes.MEDIA_POSITION_UPDATED_AT) + + @property + def media_type(self) -> str | None: + """Get media type (e.g., 'music', 'video').""" + return self.attributes.get(media_player.Attributes.MEDIA_TYPE) + + @property + def media_image_url(self) -> str | None: + """Get URL of media artwork/thumbnail.""" + return self.attributes.get(media_player.Attributes.MEDIA_IMAGE_URL) + + @property + def media_title(self) -> str | None: + """Get media title.""" + return self.attributes.get(media_player.Attributes.MEDIA_TITLE) + + @property + def media_artist(self) -> str | None: + """Get media artist name.""" + return self.attributes.get(media_player.Attributes.MEDIA_ARTIST) + + @property + def media_album(self) -> str | None: + """Get media album name.""" + return self.attributes.get(media_player.Attributes.MEDIA_ALBUM) + + @property + def repeat(self) -> media_player.RepeatMode | None: + """Get repeat mode.""" + return self.attributes.get(media_player.Attributes.REPEAT) + + @property + def shuffle(self) -> bool | None: + """Get shuffle status.""" + return self.attributes.get(media_player.Attributes.SHUFFLE) + + @property + def source(self) -> str | None: + """Get current input source.""" + return self.attributes.get(media_player.Attributes.SOURCE) + + @property + def source_list(self) -> list[str] | None: + """Get list of available input sources.""" + return self.attributes.get(media_player.Attributes.SOURCE_LIST) + + @property + def sound_mode(self) -> str | None: + """Get current sound mode.""" + return self.attributes.get(media_player.Attributes.SOUND_MODE) + + @property + def sound_mode_list(self) -> list[str] | None: + """Get list of available sound modes.""" + return self.attributes.get(media_player.Attributes.SOUND_MODE_LIST) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state( + self, value: media_player.States | None, *, update: bool = False + ) -> None: + """ + Set playback state. + + :param value: New state value + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.STATE] = value + if update: + self.update(self.attributes) + + def set_volume(self, value: int | None, *, update: bool = False) -> None: + """ + Set volume level. + + :param value: Volume level (0-100) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.VOLUME] = value + if update: + self.update(self.attributes) + + def set_muted(self, value: bool | None, *, update: bool = False) -> None: + """ + Set mute status. + + :param value: Mute status + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MUTED] = value + if update: + self.update(self.attributes) + + def set_media_duration(self, value: int | None, *, update: bool = False) -> None: + """ + Set media duration. + + :param value: Duration in seconds + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_DURATION] = value + if update: + self.update(self.attributes) + + def set_media_position(self, value: int | None, *, update: bool = False) -> None: + """ + Set media position. + + :param value: Position in seconds + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_POSITION] = value + if update: + self.update(self.attributes) + + def set_media_position_updated_at( + self, value: str | None, *, update: bool = False + ) -> None: + """ + Set media position update timestamp. + + :param value: Timestamp string + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_POSITION_UPDATED_AT] = value + if update: + self.update(self.attributes) + + def set_media_type(self, value: str | None, *, update: bool = False) -> None: + """ + Set media type. + + :param value: Media type (e.g., 'music', 'video') + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_TYPE] = value + if update: + self.update(self.attributes) + + def set_media_image_url(self, value: str | None, *, update: bool = False) -> None: + """ + Set media artwork URL. + + :param value: URL of media artwork/thumbnail + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_IMAGE_URL] = value + if update: + self.update(self.attributes) + + def set_media_title(self, value: str | None, *, update: bool = False) -> None: + """ + Set media title. + + :param value: Media title + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_TITLE] = value + if update: + self.update(self.attributes) + + def set_media_artist(self, value: str | None, *, update: bool = False) -> None: + """ + Set media artist. + + :param value: Artist name + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_ARTIST] = value + if update: + self.update(self.attributes) + + def set_media_album(self, value: str | None, *, update: bool = False) -> None: + """ + Set media album. + + :param value: Album name + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.MEDIA_ALBUM] = value + if update: + self.update(self.attributes) + + def set_repeat( + self, value: media_player.RepeatMode | None, *, update: bool = False + ) -> None: + """ + Set repeat mode. + + :param value: Repeat mode + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.REPEAT] = value + if update: + self.update(self.attributes) + + def set_shuffle(self, value: bool | None, *, update: bool = False) -> None: + """ + Set shuffle status. + + :param value: Shuffle status + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.SHUFFLE] = value + if update: + self.update(self.attributes) + + def set_source(self, value: str | None, *, update: bool = False) -> None: + """ + Set input source. + + :param value: Source name + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.SOURCE] = value + if update: + self.update(self.attributes) + + def set_source_list(self, value: list[str] | None, *, update: bool = False) -> None: + """ + Set available input sources. + + :param value: List of source names + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.SOURCE_LIST] = value + if update: + self.update(self.attributes) + + def set_sound_mode(self, value: str | None, *, update: bool = False) -> None: + """ + Set sound mode. + + :param value: Sound mode name + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.SOUND_MODE] = value + if update: + self.update(self.attributes) + + def set_sound_mode_list( + self, value: list[str] | None, *, update: bool = False + ) -> None: + """ + Set available sound modes. + + :param value: List of sound mode names + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[media_player.Attributes.SOUND_MODE_LIST] = value + if update: + self.update(self.attributes) + + # ======================================================================== + # Bulk Update Helper + # ======================================================================== + + def set_attributes( + self, + *, + state: media_player.States | None = None, + volume: int | None = None, + muted: bool | None = None, + media_duration: int | None = None, + media_position: int | None = None, + media_position_updated_at: str | None = None, + media_type: str | None = None, + media_image_url: str | None = None, + media_title: str | None = None, + media_artist: str | None = None, + media_album: str | None = None, + repeat: media_player.RepeatMode | None = None, + shuffle: bool | None = None, + source: str | None = None, + source_list: list[str] | None = None, + sound_mode: str | None = None, + sound_mode_list: list[str] | None = None, + update: bool = False, + ) -> None: + """ + Update multiple attributes at once. + + This is more efficient than calling individual setters when updating + multiple attributes, as it only triggers one entity.update() call. + + **Example**: + ```python + # Update multiple attributes efficiently + entity.set_attributes( + state=media_player.States.PLAYING, + volume=50, + media_title="Song Title", + media_artist="Artist Name", + update=True # Single update call for all changes + ) + ``` + + :param state: Playback state + :param volume: Volume level (0-100) + :param muted: Mute status + :param media_duration: Media duration in seconds + :param media_position: Media position in seconds + :param media_position_updated_at: Position update timestamp + :param media_type: Media type + :param media_image_url: Media artwork URL + :param media_title: Media title + :param media_artist: Media artist + :param media_album: Media album + :param repeat: Repeat mode + :param shuffle: Shuffle status + :param source: Input source + :param source_list: Available input sources + :param sound_mode: Sound mode + :param sound_mode_list: Available sound modes + :param update: If True, call entity.update() once after setting all attributes (default: True) + """ + # Update attributes dict with non-None values + if state is not None: + self.attributes[media_player.Attributes.STATE] = state + + if volume is not None: + self.attributes[media_player.Attributes.VOLUME] = volume + + if muted is not None: + self.attributes[media_player.Attributes.MUTED] = muted + + if media_duration is not None: + self.attributes[media_player.Attributes.MEDIA_DURATION] = media_duration + + if media_position is not None: + self.attributes[media_player.Attributes.MEDIA_POSITION] = media_position + + if media_position_updated_at is not None: + self.attributes[media_player.Attributes.MEDIA_POSITION_UPDATED_AT] = ( + media_position_updated_at + ) + + if media_type is not None: + self.attributes[media_player.Attributes.MEDIA_TYPE] = media_type + + if media_image_url is not None: + self.attributes[media_player.Attributes.MEDIA_IMAGE_URL] = media_image_url + + if media_title is not None: + self.attributes[media_player.Attributes.MEDIA_TITLE] = media_title + + if media_artist is not None: + self.attributes[media_player.Attributes.MEDIA_ARTIST] = media_artist + + if media_album is not None: + self.attributes[media_player.Attributes.MEDIA_ALBUM] = media_album + + if repeat is not None: + self.attributes[media_player.Attributes.REPEAT] = repeat + + if shuffle is not None: + self.attributes[media_player.Attributes.SHUFFLE] = shuffle + + if source is not None: + self.attributes[media_player.Attributes.SOURCE] = source + + if source_list is not None: + self.attributes[media_player.Attributes.SOURCE_LIST] = source_list + + if sound_mode is not None: + self.attributes[media_player.Attributes.SOUND_MODE] = sound_mode + + if sound_mode_list is not None: + self.attributes[media_player.Attributes.SOUND_MODE_LIST] = sound_mode_list + + # Trigger update if requested + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/remote.py b/ucapi_framework/entities/remote.py new file mode 100644 index 0000000..0532d1d --- /dev/null +++ b/ucapi_framework/entities/remote.py @@ -0,0 +1,95 @@ +""" +Remote entity with built-in state management. + +Provides a Remote entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import remote +from ucapi_framework.entity import Entity + + +class RemoteEntity(remote.Remote, Entity): + """ + Remote entity with built-in state management. + + This class extends the base Remote entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - The state attribute has a property getter (e.g., ``entity.state``) + - The state attribute has a setter method (e.g., ``entity.set_state(States.ON)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import remote + from ucapi_framework.entities import RemoteEntity + + class MyRemote(RemoteEntity): + def __init__(self, device_config, device): + entity_id = f"remote.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + remote.Features.ON_OFF, + remote.Features.SEND_CMD, + ], + attributes={ + remote.Attributes.STATE: remote.States.OFF, + }, + simple_commands=["POWER", "MUTE", "VOLUME_UP", "VOLUME_DOWN"], + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == remote.Commands.ON: + await self._device.turn_on() + self.set_state(remote.States.ON) + elif cmd_id == remote.Commands.OFF: + await self._device.turn_off() + self.set_state(remote.States.OFF) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Remote entity with state tracking. + + Accepts the same parameters as ucapi.remote.Remote. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> remote.States | None: + """Get current on/off state.""" + return self.attributes.get(remote.Attributes.STATE) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: remote.States | None, *, update: bool = False) -> None: + """ + Set on/off state. + + :param value: New state value (ON, OFF, UNAVAILABLE, UNKNOWN) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[remote.Attributes.STATE] = value + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/select.py b/ucapi_framework/entities/select.py new file mode 100644 index 0000000..2ef4897 --- /dev/null +++ b/ucapi_framework/entities/select.py @@ -0,0 +1,165 @@ +""" +Select entity with built-in state management. + +Provides a Select entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import select +from ucapi_framework.entity import Entity + + +class SelectEntity(select.Select, Entity): + """ + Select entity with built-in state management. + + This class extends the base Select entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - Each attribute has a property getter (e.g., ``entity.current_option``) + - Each attribute has a setter method (e.g., ``entity.set_current_option("Mode A")``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + Note: ``select.Select.__init__`` does not accept ``features`` — ucapi handles + that internally. Pass only ``identifier``, ``name``, ``attributes``, and + optional ``area`` / ``cmd_handler``. + + **Example Usage**: + + ```python + from ucapi import select + from ucapi_framework.entities import SelectEntity + + class MyInputSelect(SelectEntity): + def __init__(self, device_config, device): + entity_id = f"select.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + attributes={ + select.Attributes.STATE: select.States.ON, + select.Attributes.CURRENT_OPTION: "HDMI 1", + select.Attributes.OPTIONS: ["HDMI 1", "HDMI 2", "HDMI 3"], + }, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == select.Commands.SELECT_OPTION: + await self._device.select_input(params["option"]) + self.set_current_option(params["option"]) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Select entity with state tracking. + + Accepts the same parameters as ucapi.select.Select. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> select.States | None: + """Get current state (ON, UNAVAILABLE, UNKNOWN).""" + return self.attributes.get(select.Attributes.STATE) + + @property + def current_option(self) -> str | None: + """Get the currently selected option.""" + return self.attributes.get(select.Attributes.CURRENT_OPTION) + + @property + def options(self) -> list[str] | None: + """Get the list of available options.""" + return self.attributes.get(select.Attributes.OPTIONS) + + @options.setter + def options(self, _value: object) -> None: + """ + Discard the entity-level ``options`` assignment from ucapi's Entity.__init__ + (which stores config/options dicts, not the select options list). + The select options list is managed via ``self.attributes`` and ``set_options()``. + """ + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: select.States | None, *, update: bool = False) -> None: + """ + Set entity state. + + :param value: New state value (ON, UNAVAILABLE, UNKNOWN) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[select.Attributes.STATE] = value + if update: + self.update(self.attributes) + + def set_current_option(self, value: str | None, *, update: bool = False) -> None: + """ + Set the currently selected option. + + :param value: Option string + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[select.Attributes.CURRENT_OPTION] = value + if update: + self.update(self.attributes) + + def set_options(self, value: list[str] | None, *, update: bool = False) -> None: + """ + Set the list of available options. + + :param value: List of option strings + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[select.Attributes.OPTIONS] = value + if update: + self.update(self.attributes) + + # ======================================================================== + # Bulk Update Helper + # ======================================================================== + + def set_attributes( + self, + *, + state: select.States | None = None, + current_option: str | None = None, + options: list[str] | None = None, + update: bool = False, + ) -> None: + """ + Update multiple attributes at once with a single Remote update call. + + Only non-``None`` arguments are written into ``self.attributes``. + + :param state: Entity state + :param current_option: Currently selected option + :param options: List of available options + :param update: If True, call entity.update() once after all changes (default: True) + """ + if state is not None: + self.attributes[select.Attributes.STATE] = state + if current_option is not None: + self.attributes[select.Attributes.CURRENT_OPTION] = current_option + if options is not None: + self.attributes[select.Attributes.OPTIONS] = options + + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/sensor.py b/ucapi_framework/entities/sensor.py new file mode 100644 index 0000000..04a864a --- /dev/null +++ b/ucapi_framework/entities/sensor.py @@ -0,0 +1,155 @@ +""" +Sensor entity with built-in state management. + +Provides a Sensor entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import sensor +from ucapi_framework.entity import Entity + + +class SensorEntity(sensor.Sensor, Entity): + """ + Sensor entity with built-in state management. + + This class extends the base Sensor entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - Each attribute has a property getter (e.g., ``entity.value``) + - Each attribute has a setter method (e.g., ``entity.set_value(23.5)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import sensor + from ucapi_framework.entities import SensorEntity + + class MyTemperatureSensor(SensorEntity): + def __init__(self, device_config, device): + entity_id = f"sensor.{device_config.id}.temperature" + super().__init__( + entity_id, + device_config.name, + features=[], + attributes={ + sensor.Attributes.STATE: sensor.States.ON, + sensor.Attributes.VALUE: 0.0, + sensor.Attributes.UNIT: "°C", + }, + device_class=sensor.DeviceClasses.TEMPERATURE, + ) + self._device = device + + async def sync_state(self): + self.attributes[sensor.Attributes.STATE] = sensor.States.ON + self.attributes[sensor.Attributes.VALUE] = self._device.temperature + self.update(self.attributes) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Sensor entity with state tracking. + + Accepts the same parameters as ucapi.sensor.Sensor. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> sensor.States | None: + """Get current sensor state (ON, UNAVAILABLE, UNKNOWN).""" + return self.attributes.get(sensor.Attributes.STATE) + + @property + def value(self) -> Any: + """Get the current sensor measurement value.""" + return self.attributes.get(sensor.Attributes.VALUE) + + @property + def unit(self) -> str | None: + """Get the unit of measurement.""" + return self.attributes.get(sensor.Attributes.UNIT) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: sensor.States | None, *, update: bool = False) -> None: + """ + Set sensor state. + + :param value: New state value (ON, UNAVAILABLE, UNKNOWN) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[sensor.Attributes.STATE] = value + if update: + self.update(self.attributes) + + def set_value(self, value: Any, *, update: bool = False) -> None: + """ + Set the sensor measurement value. + + :param value: Measurement value (numeric or string) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[sensor.Attributes.VALUE] = value + if update: + self.update(self.attributes) + + def set_unit(self, value: str | None, *, update: bool = False) -> None: + """ + Set the unit of measurement. + + :param value: Unit string (e.g., "°C", "%", "W") + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[sensor.Attributes.UNIT] = value + if update: + self.update(self.attributes) + + # ======================================================================== + # Bulk Update Helper + # ======================================================================== + + def set_attributes( + self, + *, + state: sensor.States | None = None, + value: Any = None, + unit: str | None = None, + update: bool = False, + ) -> None: + """ + Update multiple attributes at once with a single Remote update call. + + Only non-``None`` arguments are written into ``self.attributes``. + + :param state: Sensor state + :param value: Measurement value + :param unit: Unit of measurement + :param update: If True, call entity.update() once after all changes (default: True) + """ + if state is not None: + self.attributes[sensor.Attributes.STATE] = state + if value is not None: + self.attributes[sensor.Attributes.VALUE] = value + if unit is not None: + self.attributes[sensor.Attributes.UNIT] = unit + + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/switch.py b/ucapi_framework/entities/switch.py new file mode 100644 index 0000000..13357cd --- /dev/null +++ b/ucapi_framework/entities/switch.py @@ -0,0 +1,99 @@ +""" +Switch entity with built-in state management. + +Provides a Switch entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import switch +from ucapi_framework.entity import Entity + + +class SwitchEntity(switch.Switch, Entity): + """ + Switch entity with built-in state management. + + This class extends the base Switch entity to provide built-in state tracking + and management. State is stored directly in the existing ``self.attributes`` + dict that all ucapi entities have. + + **State Management Pattern**: + - The state attribute has a property getter (e.g., ``entity.state``) + - The state attribute has a setter method (e.g., ``entity.set_state(States.ON)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import switch + from ucapi_framework.entities import SwitchEntity + + class MySwitch(SwitchEntity): + def __init__(self, device_config, device): + entity_id = f"switch.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + switch.Features.ON_OFF, + switch.Features.TOGGLE, + ], + attributes={ + switch.Attributes.STATE: switch.States.OFF, + }, + device_class=switch.DeviceClasses.SWITCH, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == switch.Commands.ON: + await self._device.turn_on() + self.set_state(switch.States.ON) + elif cmd_id == switch.Commands.OFF: + await self._device.turn_off() + self.set_state(switch.States.OFF) + elif cmd_id == switch.Commands.TOGGLE: + await self._device.toggle() + new_state = switch.States.OFF if self.state == switch.States.ON else switch.States.ON + self.set_state(new_state) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize Switch entity with state tracking. + + Accepts the same parameters as ucapi.switch.Switch. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> switch.States | None: + """Get current on/off state.""" + return self.attributes.get(switch.Attributes.STATE) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state(self, value: switch.States | None, *, update: bool = False) -> None: + """ + Set on/off state. + + :param value: New state value (ON, OFF, UNAVAILABLE, UNKNOWN) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[switch.Attributes.STATE] = value + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entities/voice_assistant.py b/ucapi_framework/entities/voice_assistant.py new file mode 100644 index 0000000..9dd252d --- /dev/null +++ b/ucapi_framework/entities/voice_assistant.py @@ -0,0 +1,93 @@ +""" +Voice Assistant entity with built-in state management. + +Provides a VoiceAssistant entity subclass that manages its own state internally +using property getters and setter methods. + +:copyright: (c) 2025 by Jack Powell. +:license: Mozilla Public License Version 2.0, see LICENSE for more details. +""" + +from typing import Any +from ucapi import voice_assistant +from ucapi_framework.entity import Entity + + +class VoiceAssistantEntity(voice_assistant.VoiceAssistant, Entity): + """ + Voice Assistant entity with built-in state management. + + This class extends the base VoiceAssistant entity to provide built-in state + tracking and management. State is stored directly in the existing + ``self.attributes`` dict that all ucapi entities have. + + **State Management Pattern**: + - The state attribute has a property getter (e.g., ``entity.state``) + - The state attribute has a setter method (e.g., ``entity.set_state(States.ON)``) + - Setter methods accept an optional ``update`` parameter to control whether + ``entity.update()`` is called automatically (default: True) + - Properties are still overridable by subclasses for custom behavior + + **Example Usage**: + + ```python + from ucapi import voice_assistant + from ucapi_framework.entities import VoiceAssistantEntity + + class MyVoiceAssistant(VoiceAssistantEntity): + def __init__(self, device_config, device): + entity_id = f"voice_assistant.{device_config.id}" + super().__init__( + entity_id, + device_config.name, + features=[ + voice_assistant.Features.TRANSCRIPTION, + voice_assistant.Features.RESPONSE_TEXT, + ], + attributes={ + voice_assistant.Attributes.STATE: voice_assistant.States.OFF, + }, + ) + self._device = device + + async def handle_command(self, entity_id, cmd_id, params): + if cmd_id == voice_assistant.Commands.VOICE_START: + await self._device.start_listening() + self.set_state(voice_assistant.States.ON) + ``` + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """ + Initialize VoiceAssistant entity with state tracking. + + Accepts the same parameters as ucapi.voice_assistant.VoiceAssistant. + State is stored in the existing self.attributes dict that all ucapi entities have. + """ + super().__init__(*args, **kwargs) + + # ======================================================================== + # Property Getters (read-only access, overridable) + # ======================================================================== + + @property + def state(self) -> voice_assistant.States | None: + """Get current on/off state.""" + return self.attributes.get(voice_assistant.Attributes.STATE) + + # ======================================================================== + # Setter Methods (with optional auto-update, overridable) + # ======================================================================== + + def set_state( + self, value: voice_assistant.States | None, *, update: bool = False + ) -> None: + """ + Set on/off state. + + :param value: New state value (ON, OFF, UNAVAILABLE, UNKNOWN) + :param update: If True, call entity.update() to push changes to Remote (default: True) + """ + self.attributes[voice_assistant.Attributes.STATE] = value + if update: + self.update(self.attributes) diff --git a/ucapi_framework/entity.py b/ucapi_framework/entity.py index 097bb4a..7df41c4 100644 --- a/ucapi_framework/entity.py +++ b/ucapi_framework/entity.py @@ -7,7 +7,7 @@ from abc import ABC from dataclasses import asdict, is_dataclass -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from ucapi import ( IntegrationAPI, button, @@ -24,6 +24,9 @@ ) from .helpers import EntityAttributes +if TYPE_CHECKING: + from .device import BaseDeviceInterface + # Mapping from ucapi entity classes to their Attributes enums _ENTITY_ATTRIBUTES_MAP = { button.Button: button.Attributes, @@ -170,6 +173,10 @@ def update_attributes(self, update: dict[str, Any], *, force: bool = False) -> N :param update: dictionary containing the updated properties. :param force: if True, update attributes even if they haven't changed. """ + # Strip None values - they represent attributes that have never been set + # and should not be sent to the Remote. Empty strings and False are valid. + update = {k: v for k, v in update.items() if v is not None} + if force: attributes = update # Even with force=True, skip if update is empty @@ -269,6 +276,92 @@ def update( self.update_attributes(attrs, force=force) + def set_unavailable(self) -> None: + """ + Mark this entity as unavailable on the Remote. + + Sets ``Attributes.STATE`` to ``States.UNAVAILABLE`` for the entity type + and pushes the update immediately. Works for all ucapi entity types + (button, climate, cover, light, media_player, remote, select, etc.). + + Example:: + + def on_device_disconnected(self): + self.set_unavailable() + """ + self.attributes[media_player.Attributes.STATE] = media_player.States.UNAVAILABLE # type: ignore[attr-defined] + self.update(self.attributes) # type: ignore[no-member] + + def subscribe_to_device(self, device: "BaseDeviceInterface") -> None: + """ + Subscribe to device UPDATE events. + + Registers ``sync_state()`` as a listener on the device's UPDATE event. + Call this in ``__init__`` to wire the entity to its device — after that, + every ``DeviceEvents.UPDATE`` emission will automatically invoke + ``sync_state()`` on this entity. + + The framework also calls ``sync_state()`` directly during + ``on_device_connected`` and ``refresh_entity_state``, so subscription + handles the push-notification path while the driver handles the + poll/reconnect path. + + :param device: Device instance to subscribe to. + + Example:: + + class MyLight(LightEntity): + def __init__(self, config, device): + super().__init__(...) + self._device = device + self.subscribe_to_device(device) + + async def sync_state(self) -> None: + self.attributes[light.Attributes.STATE] = self.map_entity_states( + self._device.state + ) + self.attributes[light.Attributes.BRIGHTNESS] = self._device.brightness + self.update(self.attributes) + """ + from .device import DeviceEvents # local import to avoid circular dependency + + device.events.on(DeviceEvents.UPDATE, self._handle_device_update) + + async def _handle_device_update(self, *_args: Any, **_kwargs: Any) -> None: + """Internal handler wired to DeviceEvents.UPDATE by subscribe_to_device.""" + await self.sync_state() + + async def sync_state(self) -> None: + """ + Sync entity state from device to Remote. + + Override this method to read current values from ``self._device``, + write them into ``self.attributes``, and call ``self.update(self.attributes)`` + to push the state to the Remote. + + The framework calls this method automatically in two situations: + + - **Device reconnect** — after ``on_device_connected``, the driver calls + ``sync_state()`` on each configured entity for the device. + - **Device UPDATE event** — if the entity has subscribed via + ``subscribe_to_device()``, ``sync_state()`` is called on every + ``DeviceEvents.UPDATE`` emission. + + The default implementation is a no-op. Override it when the entity + manages its own state (i.e. the developer is not using the driver's + default ``on_device_update`` attribute-routing logic). + + Example:: + + async def sync_state(self) -> None: + self.attributes[light.Attributes.STATE] = self.map_entity_states( + self._device.state + ) + self.attributes[light.Attributes.BRIGHTNESS] = self._device.brightness + self.update(self.attributes) + """ + # No-op by default. Subclasses override to pull from device and push to Remote. + def filter_changed_attributes(self, update: dict[str, Any]) -> dict[str, Any]: """ Filter the given attributes and return only the changed values. diff --git a/ucapi_framework/helpers.py b/ucapi_framework/helpers.py index fc93eb6..230f7c6 100644 --- a/ucapi_framework/helpers.py +++ b/ucapi_framework/helpers.py @@ -206,7 +206,7 @@ async def find_orphaned_entities( try: async with aiohttp.ClientSession() as session: # Step 1: Get all activities - activities_url = f"{remote_url}/api/activities" + activities_url = f"{remote_url}/api/activities?limit=100" async with session.get( activities_url, headers=headers, From bf90ea61e8ebac51c7810a7a3cc3cd4587ef6b7f Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Wed, 4 Mar 2026 07:22:59 -0500 Subject: [PATCH 02/13] feat: Entity Refactor --- tests/test_climate_entity.py | 4 +++- tests/test_driver.py | 4 +++- tests/test_entity.py | 9 +++++++-- tests/test_media_player_entity.py | 4 +++- ucapi_framework/driver.py | 10 ++++++++-- uv.lock | 2 +- 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/test_climate_entity.py b/tests/test_climate_entity.py index eb0c295..e98220d 100644 --- a/tests/test_climate_entity.py +++ b/tests/test_climate_entity.py @@ -156,7 +156,9 @@ def test_set_attributes_without_update(self, entity, mock_api): def test_set_attributes_ignores_none_values(self, entity, mock_api): """Test set_attributes() ignores None values.""" - entity.set_attributes(state=climate.States.HEAT, current_temperature=None, update=True) + entity.set_attributes( + state=climate.States.HEAT, current_temperature=None, update=True + ) assert entity.state == climate.States.HEAT assert entity.current_temperature is None diff --git a/tests/test_driver.py b/tests/test_driver.py index 6232320..51d97ab 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -2018,7 +2018,9 @@ async def sync_state(self): driver.api.configured_entities.update_attributes.assert_not_called() @pytest.mark.asyncio - async def test_refresh_entity_state_no_shortcircuit_without_sync_state_override(self): + async def test_refresh_entity_state_no_shortcircuit_without_sync_state_override( + self, + ): """Test refresh_entity_state uses match block when sync_state is NOT overridden.""" driver = self._create_driver() config = DeviceConfigForTests("dev1", "Device 1", "192.168.1.1") diff --git a/tests/test_entity.py b/tests/test_entity.py index cb45626..1d4c05b 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -564,13 +564,16 @@ async def test_sync_state_noop_does_not_push(self, mock_api): @pytest.mark.asyncio async def test_subscribe_to_device_wires_sync_state(self, mock_api): """Test subscribe_to_device wires UPDATE event to sync_state.""" + class SyncingMediaPlayer(media_player.MediaPlayer, Entity): def __init__(self): super().__init__( "media_player.test", "Test Player", features=[media_player.Features.ON_OFF], - attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, + attributes={ + media_player.Attributes.STATE: media_player.States.UNKNOWN + }, ) self.sync_state_called = 0 @@ -587,7 +590,8 @@ async def sync_state(self): # Verify events.on was called with UPDATE event mock_device.events.on.assert_called_once_with( - DeviceEvents.UPDATE, entity._handle_device_update # noqa: SLF001 + DeviceEvents.UPDATE, + entity._handle_device_update, # noqa: SLF001 ) @pytest.mark.asyncio @@ -615,6 +619,7 @@ async def test_handle_device_update_ignores_args(self, mock_api): def test_sync_state_overridden_detected(self): """Test that overriding sync_state is detectable for driver short-circuit.""" + class OverridingEntity(media_player.MediaPlayer, Entity): def __init__(self): super().__init__( diff --git a/tests/test_media_player_entity.py b/tests/test_media_player_entity.py index 0a4f5f8..818dd18 100644 --- a/tests/test_media_player_entity.py +++ b/tests/test_media_player_entity.py @@ -146,7 +146,9 @@ def test_set_attributes_without_update(self, entity, mock_api): def test_set_attributes_ignores_none_values(self, entity, mock_api): """Test set_attributes() ignores None values.""" - entity.set_attributes(state=media_player.States.PLAYING, volume=None, update=True) + entity.set_attributes( + state=media_player.States.PLAYING, volume=None, update=True + ) # Only state should be in internal storage assert entity.state == media_player.States.PLAYING diff --git a/ucapi_framework/driver.py b/ucapi_framework/driver.py index 7c278b8..a09f4fb 100644 --- a/ucapi_framework/driver.py +++ b/ucapi_framework/driver.py @@ -596,7 +596,10 @@ def get_device_attributes(self, entity_id: str): # Short-circuit: if entity has overridden sync_state(), delegate entirely to it. # This is the coordinator pattern — the entity knows how to read its own device. - if framework_entity and type(framework_entity).sync_state is not FrameworkEntity.sync_state: + if ( + framework_entity + and type(framework_entity).sync_state is not FrameworkEntity.sync_state + ): await framework_entity.sync_state() return @@ -1449,7 +1452,10 @@ async def on_device_update( # Short-circuit: if entity has overridden sync_state(), it manages its own state # via subscribe_to_device(). Skip attribute routing to avoid double execution. - if framework_entity and type(framework_entity).sync_state is not FrameworkEntity.sync_state: + if ( + framework_entity + and type(framework_entity).sync_state is not FrameworkEntity.sync_state + ): return attributes: dict[str, Any] = {} diff --git a/uv.lock b/uv.lock index 4bd8597..a9f9d6a 100644 --- a/uv.lock +++ b/uv.lock @@ -1314,7 +1314,7 @@ wheels = [ [[package]] name = "ucapi-framework" -version = "1.8.4" +version = "1.9.0b1" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 732ad43184958d8a4170b0b0f5e36d92324382e0 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Wed, 4 Mar 2026 07:25:15 -0500 Subject: [PATCH 03/13] ci: support pre-release version tags --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d63205e..bf38b63 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,7 +3,7 @@ name: Publish to PyPI on: push: tags: - - "v*.*.*" # Triggers on version tags like v1.0.0, v2.1.3, etc. + - "v*" # Triggers on version tags like v1.0.0, v2.1.3, v1.9.0b1, etc. jobs: test: From 00462563d90cdb355071afa7955c27a010fb1e4b Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Wed, 4 Mar 2026 07:25:54 -0500 Subject: [PATCH 04/13] chore: bump version to 1.9.0b2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1a9e2cd..59c7225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ucapi-framework" -version = "1.9.0b1" +version = "1.9.0b2" description = "ucapi framework that provides core functionality for building integrations." readme = "README.md" requires-python = ">=3.11" From 38e41d24bc72074c23d22f3b129a1166d0a866cf Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Wed, 4 Mar 2026 07:48:54 -0500 Subject: [PATCH 05/13] chore: bump version to 1.9.0b3 --- pyproject.toml | 2 +- ucapi_framework/device.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 59c7225..7c98104 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ucapi-framework" -version = "1.9.0b2" +version = "1.9.0b3" description = "ucapi framework that provides core functionality for building integrations." readme = "README.md" requires-python = ">=3.11" diff --git a/ucapi_framework/device.py b/ucapi_framework/device.py index 9b33f0c..b28f1b7 100644 --- a/ucapi_framework/device.py +++ b/ucapi_framework/device.py @@ -119,6 +119,26 @@ def driver(self) -> Any | None: """ return self._driver + def push_update(self) -> None: + """ + Notify the framework that this device's state has changed. + + Emits ``DeviceEvents.UPDATE`` with no payload. Any entity that has called + ``subscribe_to_device()`` will have its ``sync_state()`` method invoked + automatically in response. + + This is the recommended way to trigger state propagation when using the + coordinator pattern:: + + async def _poll(self): + self.volume = await self._fetch_volume() + self.push_update() + + For the legacy attribute-routing pattern, emit ``DeviceEvents.UPDATE`` + directly with ``entity_id`` and an ``update`` dict instead. + """ + self.events.emit(DeviceEvents.UPDATE) + def update_config(self, **kwargs) -> bool: """ Update device configuration attributes and persist changes. From efcc8b5746b4ef424006d0e69a50d176814fcffb Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Wed, 4 Mar 2026 20:31:40 -0500 Subject: [PATCH 06/13] chore: bump version to 1.9.0b4 --- pyproject.toml | 2 +- ucapi_framework/driver.py | 18 ++++++++++------ ucapi_framework/entity.py | 43 ++++++++++++++++++++++++--------------- uv.lock | 2 +- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c98104..a53a753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ucapi-framework" -version = "1.9.0b3" +version = "1.9.0b4" description = "ucapi framework that provides core functionality for building integrations." readme = "README.md" requires-python = ">=3.11" diff --git a/ucapi_framework/driver.py b/ucapi_framework/driver.py index a09f4fb..d4c0fb5 100644 --- a/ucapi_framework/driver.py +++ b/ucapi_framework/driver.py @@ -594,8 +594,8 @@ def get_device_attributes(self, entity_id: str): cast(FrameworkEntity, configured_entity) if has_update else None ) - # Short-circuit: if entity has overridden sync_state(), delegate entirely to it. - # This is the coordinator pattern — the entity knows how to read its own device. + # Coordinator pattern: entity owns its state via sync_state() + subscribe_to_device(). + # Call sync_state() directly and skip all legacy attribute-routing paths. if ( framework_entity and type(framework_entity).sync_state is not FrameworkEntity.sync_state @@ -1216,9 +1216,10 @@ async def on_device_connected(self, device_id: str) -> None: """ Handle device connection. - Sets integration device state to CONNECTED and refreshes all entity states - for this device. This ensures entity states are updated after the device - has connected and populated its attributes via get_device_attributes(). + Sets integration device state to CONNECTED and refreshes state for legacy-pattern + entities. Coordinator-pattern entities (those with ``sync_state()`` overridden) + are skipped here — they receive state via ``push_update()`` emitted by the + device's own ``connect()`` implementation, avoiding a redundant double-sync. :param device_id: Device identifier """ @@ -1230,8 +1231,13 @@ async def on_device_connected(self, device_id: str) -> None: await self.api.set_device_state(ucapi.DeviceStates.CONNECTED) - # Refresh entity states now that device is connected and has populated attributes for entity_id in self.get_entity_ids_for_device(device_id): + configured_entity = self.api.configured_entities.get(entity_id) + if isinstance(configured_entity, FrameworkEntity) and ( + type(configured_entity).sync_state is not FrameworkEntity.sync_state + ): + # Coordinator pattern: entity syncs via push_update() from device.connect() + continue await self.refresh_entity_state(entity_id) async def on_device_disconnected(self, device_id: str) -> None: diff --git a/ucapi_framework/entity.py b/ucapi_framework/entity.py index 7df41c4..09e567b 100644 --- a/ucapi_framework/entity.py +++ b/ucapi_framework/entity.py @@ -173,6 +173,10 @@ def update_attributes(self, update: dict[str, Any], *, force: bool = False) -> N :param update: dictionary containing the updated properties. :param force: if True, update attributes even if they haven't changed. """ + # Skip entirely if this entity is not configured on the Remote. + if not self._api.configured_entities.contains(self._framework_entity_id): + return + # Strip None values - they represent attributes that have never been set # and should not be sent to the Remote. Empty strings and False are valid. update = {k: v for k, v in update.items() if v is not None} @@ -289,8 +293,9 @@ def set_unavailable(self) -> None: def on_device_disconnected(self): self.set_unavailable() """ - self.attributes[media_player.Attributes.STATE] = media_player.States.UNAVAILABLE # type: ignore[attr-defined] - self.update(self.attributes) # type: ignore[no-member] + # All ucapi States enums share the same UNAVAILABLE string value, + # so media_player.States.UNAVAILABLE works as a proxy for all entity types. + self.update({media_player.Attributes.STATE: media_player.States.UNAVAILABLE}) def subscribe_to_device(self, device: "BaseDeviceInterface") -> None: """ @@ -317,11 +322,10 @@ def __init__(self, config, device): self.subscribe_to_device(device) async def sync_state(self) -> None: - self.attributes[light.Attributes.STATE] = self.map_entity_states( - self._device.state - ) - self.attributes[light.Attributes.BRIGHTNESS] = self._device.brightness - self.update(self.attributes) + self.update({ + light.Attributes.STATE: self.map_entity_states(self._device.state), + light.Attributes.BRIGHTNESS: self._device.brightness, + }) """ from .device import DeviceEvents # local import to avoid circular dependency @@ -335,9 +339,9 @@ async def sync_state(self) -> None: """ Sync entity state from device to Remote. - Override this method to read current values from ``self._device``, - write them into ``self.attributes``, and call ``self.update(self.attributes)`` - to push the state to the Remote. + Override this method to read current values from ``self._device`` and call + ``self.update()`` with a **fresh dict or dataclass** — do not mutate + ``self.attributes`` directly, as that would defeat change-filtering. The framework calls this method automatically in two situations: @@ -351,14 +355,21 @@ async def sync_state(self) -> None: manages its own state (i.e. the developer is not using the driver's default ``on_device_update`` attribute-routing logic). - Example:: + Example with dict:: async def sync_state(self) -> None: - self.attributes[light.Attributes.STATE] = self.map_entity_states( - self._device.state - ) - self.attributes[light.Attributes.BRIGHTNESS] = self._device.brightness - self.update(self.attributes) + self.update({ + light.Attributes.STATE: self.map_entity_states(self._device.state), + light.Attributes.BRIGHTNESS: self._device.brightness, + }) + + Example with dataclass (recommended):: + + async def sync_state(self) -> None: + self.update(LightAttributes( + STATE=self.map_entity_states(self._device.state), + BRIGHTNESS=self._device.brightness, + )) """ # No-op by default. Subclasses override to pull from device and push to Remote. diff --git a/uv.lock b/uv.lock index a9f9d6a..35258f1 100644 --- a/uv.lock +++ b/uv.lock @@ -1314,7 +1314,7 @@ wheels = [ [[package]] name = "ucapi-framework" -version = "1.9.0b1" +version = "1.9.0b3" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From d5ab0836092c2f465a80dc8c88e9336938b02ab5 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sun, 8 Mar 2026 09:08:31 -0400 Subject: [PATCH 07/13] fix: improve typing for entity_classes parameter --- ucapi_framework/driver.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ucapi_framework/driver.py b/ucapi_framework/driver.py index d4c0fb5..999e9e7 100644 --- a/ucapi_framework/driver.py +++ b/ucapi_framework/driver.py @@ -12,7 +12,7 @@ from collections.abc import Callable from dataclasses import is_dataclass from enum import Enum -from typing import Any, Generic, TypeVar, cast +from typing import Any, Generic, TypeVar, TypeAlias, cast import ucapi import ucapi.api as uc @@ -40,6 +40,9 @@ DeviceT = TypeVar("DeviceT", bound=BaseDeviceInterface) # Device interface type ConfigT = TypeVar("ConfigT") # Device configuration type (any object with attributes) +# Factory function signature: receives (config, device) and returns one or more entities +EntityFactory: TypeAlias = Callable[[Any, Any], Entity | list[Entity]] + _LOG = logging.getLogger(__name__) # Common attribute names for device configuration extraction @@ -134,10 +137,7 @@ class BaseIntegrationDriver(Generic[DeviceT, ConfigT]): def __init__( self, device_class: type[DeviceT], - entity_classes: list[ - type[Entity] | Callable[[ConfigT, DeviceT], Entity | list[Entity]] - ] - | type[Entity], + entity_classes: list[type[Entity] | EntityFactory] | type[Entity], require_connection_before_registry: bool = False, loop: asyncio.AbstractEventLoop | None = None, driver_id: str | None = None, From 53bb6e04248b07162053534b40af36dfeb0d2b26 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sun, 8 Mar 2026 09:09:07 -0400 Subject: [PATCH 08/13] chore: update uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 35258f1..a6440ef 100644 --- a/uv.lock +++ b/uv.lock @@ -1314,7 +1314,7 @@ wheels = [ [[package]] name = "ucapi-framework" -version = "1.9.0b3" +version = "1.9.0b4" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, From 1f6bc35308753597e3ece8bdefb1827c7aee47ef Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sun, 8 Mar 2026 10:55:39 -0400 Subject: [PATCH 09/13] docs: update for coordinator pattern and 1.9+ flow - Rewrite MIGRATION_GUIDE.md: remove 1.8.4 upgrade steps, replace with coordinator pattern migration guide (subscribe_to_device, sync_state, push_update, fresh dict requirement, factory lambdas, hub pattern) - Rewrite docs/guide/device-patterns.md: add separation of concerns section, update all device examples to use push_update() with raw state properties, remove legacy emit pattern - Rewrite docs/guide/driver.md: update for factory lambda entity_classes, document coordinator vs legacy on_device_update paths, update all examples to current API --- MIGRATION_GUIDE.md | 1136 ++++++++++++--------------------- docs/guide/device-patterns.md | 305 +++++---- docs/guide/driver.md | 507 ++++++--------- 3 files changed, 790 insertions(+), 1158 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 9da9b0d..3961f37 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,6 +1,6 @@ -# Migration Guide: Converting to ucapi framework +# Migration Guide: Converting to ucapi-framework -This guide helps you migrate an existing Unfolded Circle integration to use the ucapi framework. We'll show you the before/after for each component with real examples from the PSN integration migration. +This guide helps you migrate an existing Unfolded Circle integration to use the ucapi-framework. We'll cover both migrating from raw ucapi code and upgrading between framework versions. ## Table of Contents @@ -9,30 +9,32 @@ This guide helps you migrate an existing Unfolded Circle integration to use the - [Step-by-Step Migration](#step-by-step-migration) - [1. Configuration Management](#1-configuration-management) - [2. Device Implementation](#2-device-implementation) - - [3. Setup Flow](#3-setup-flow) - - [4. Driver Integration](#4-driver-integration) - - [5. Entity Implementation](#5-entity-implementation) + - [3. Entity Implementation](#3-entity-implementation) + - [4. Setup Flow](#4-setup-flow) + - [5. Driver Integration](#5-driver-integration) +- [Upgrading to 1.9+: The Coordinator Pattern](#upgrading-to-19-the-coordinator-pattern) - [Common Patterns](#common-patterns) - [Testing Your Migration](#testing-your-migration) ## Why Migrate? -**Before ucapi_framework_:** +**Before ucapi-framework:** - ~1500 lines of boilerplate per integration - Manual configuration management with dict manipulation - Global state management with module-level variables - Repetitive event handler wiring - Copy-paste setup flow code - Manual device lifecycle management +- Entity and device state tightly coupled -**After ucapi_framework_:** +**After ucapi-framework:** - ~400 lines of integration-specific code - Type-safe configuration with dataclasses - Clean OOP design with proper encapsulation - Automatic event handler wiring - Reusable setup flow base class - Automatic device lifecycle management -- Full IDE autocomplete support +- Clear separation of concerns between device and entity **Code Reduction:** ~70% less code to write and maintain! @@ -40,122 +42,71 @@ This guide helps you migrate an existing Unfolded Circle integration to use the The migration follows these steps: -1. **Configuration** - Replace dict-based config with typed dataclass + BaseDeviceManager -2. **Device** - Inherit from device interface (StatelessHTTPDevice, PollingDevice, etc.) -3. **Setup Flow** - Inherit from BaseSetupFlow, implement required methods -4. **Driver** - Inherit from BaseIntegrationDriver, remove global state -5. **Entities** - Update to reference device instances instead of global state +1. **Configuration** — Replace dict-based config with typed dataclass + `BaseConfigManager` +2. **Device** — Inherit from a device base class; device knows nothing about entities +3. **Entities** — Inherit from framework `Entity`; entity subscribes to the device and owns its own state +4. **Setup Flow** — Inherit from `BaseSetupFlow`, implement required methods +5. **Driver** — Inherit from `BaseIntegrationDriver`, remove global state + ## Step-by-Step Migration ### 1. Configuration Management -#### Before: Dict-Based Configuration +#### Before: Dict-Based Configuration (~80 lines) ```python # config.py - Old approach -import json -import os +import json, os from typing import TypedDict -class PSNDevice(TypedDict): - """PSN device configuration.""" +class MyDevice(TypedDict): identifier: str name: str - npsso: str + host: str -# Global configuration dict -devices: dict[str, PSNDevice] = {} -_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +devices: dict[str, MyDevice] = {} +_config_path = os.path.join(os.path.dirname(__file__), "config.json") def _load() -> bool: - """Load configuration from disk.""" global devices if not os.path.exists(_config_path): return True - - try: - with open(_config_path, "r", encoding="utf-8") as f: - data = json.load(f) - devices = {k: PSNDevice(**v) for k, v in data.items()} - return True - except Exception as e: - return False - -def _store() -> bool: - """Store configuration to disk.""" - try: - with open(_config_path, "w", encoding="utf-8") as f: - json.dump({k: dict(v) for k, v in devices.items()}, f, indent=4) - return True - except Exception as e: - return False + with open(_config_path) as f: + data = json.load(f) + devices = {k: MyDevice(**v) for k, v in data.items()} + return True -def add_device(device: PSNDevice) -> bool: - """Add or update device.""" +def add_device(device: MyDevice) -> bool: devices[device["identifier"]] = device return _store() def remove_device(identifier: str) -> bool: - """Remove device.""" if identifier in devices: devices.pop(identifier) return _store() return False - -def get_device(identifier: str) -> PSNDevice | None: - """Get device by identifier.""" - return devices.get(identifier) - -def all_devices() -> list[PSNDevice]: - """Get all configured devices.""" - return list(devices.values()) - -def clear() -> bool: - """Clear all devices.""" - global devices - devices = {} - return _store() - -# Initialize on import -_load() ``` -**Problems:** -- ~80 lines of boilerplate -- Global mutable state -- Manual JSON serialization -- No type safety for operations -- Manual error handling everywhere -- Dict manipulation prone to errors +**Problems:** ~80 lines of boilerplate, global mutable state, manual JSON serialization. -#### After: BaseDeviceManager with Dataclass +#### After: BaseConfigManager with Dataclass (~5 lines) ```python # config.py - New approach from dataclasses import dataclass -from ucapi_framework_ import BaseDeviceManager +from ucapi_framework import BaseConfigManager @dataclass -class PSNDevice: - """PSN device configuration.""" +class MyDeviceConfig: identifier: str name: str - npsso: str + host: str -class PSNDeviceManager(BaseDeviceManager[PSNDevice]): - """PSN device configuration manager with JSON persistence.""" +class MyConfigManager(BaseConfigManager[MyDeviceConfig]): pass ``` -**Benefits:** -- ~15 lines total (80% reduction!) -- No global state -- Type-safe operations -- Automatic JSON serialization -- Built-in error handling -- IDE autocomplete for all operations - **Usage Comparison:** ```python @@ -163,769 +114,516 @@ class PSNDeviceManager(BaseDeviceManager[PSNDevice]): import config device = config.get_device(device_id) config.add_device(new_device) -all_devices = config.all_devices() # New: -config = PSNDeviceManager("config.json", PSNDevice) +config = MyConfigManager("config.json", MyDeviceConfig) device = config.get(device_id) config.add_or_update(new_device) -all_devices = config.all() ``` +--- + ### 2. Device Implementation -#### Before: Manual Connection Management +The framework enforces a key principle: **the device knows nothing about entities**. It stores raw hardware state (power, volume, input source — whatever your device has) and signals that something changed. Entities subscribe to those signals and translate them into ucapi attributes. + +#### Before: Device Emitting Entity-Specific Attributes ```python -# psn.py - Old approach -class PSNAccount: - """PlayStation Network account.""" - - def __init__(self, identifier: str, name: str, npsso: str): - self.identifier = identifier - self.name = name - self._npsso = npsso - self.state = "OFF" - self._ws = None - self._ws_task = None - self.events = EventEmitter() - - async def connect(self) -> bool: - """Connect to PSN WebSocket.""" - try: - # Manual WebSocket setup - self._ws = await websockets.connect( - "wss://psn-api.example.com/ws", - extra_headers={"Authorization": f"Bearer {self._npsso}"} - ) - - # Manual task management - self._ws_task = asyncio.create_task(self._receive_loop()) - - self.events.emit("connected", self.identifier) - return True - - except Exception as e: - self.events.emit("connection_error", self.identifier, str(e)) - return False - - async def _receive_loop(self): - """Manually manage WebSocket receive loop.""" - try: - while self._ws: - message = await self._ws.recv() - data = json.loads(message) - await self._process_message(data) - except Exception as e: - self.events.emit("connection_error", self.identifier, str(e)) - finally: - await self.disconnect() - - async def disconnect(self) -> None: - """Disconnect from WebSocket.""" - if self._ws_task: - self._ws_task.cancel() - try: - await self._ws_task - except asyncio.CancelledError: - pass - self._ws_task = None - - if self._ws: - await self._ws.close() - self._ws = None - - self.events.emit("disconnected", self.identifier) - - async def _process_message(self, data: dict) -> None: - """Process received message.""" - self.state = data.get("state", "UNKNOWN") - self.events.emit("state_changed", entity_id, self.state) +# device.py - Old approach +class MyDevice: + async def _process_update(self, data): + self.state = data["power"] + # Emitting entity ID + ucapi attribute dict — tightly coupled to entity type! + self.events.emit( + "UPDATE", + self.identifier, + { + media_player.Attributes.STATE: data["power"], + media_player.Attributes.VOLUME: data["volume"], + } + ) ``` -**Problems:** -- ~100+ lines of connection boilerplate -- Manual WebSocket lifecycle management -- Manual task management and cancellation -- Error handling repeated everywhere -- Reconnection logic missing -- Testing difficult due to tight coupling +**Problem:** The device has to know about ucapi entity attribute keys, which means changing entity types (e.g., adding a second entity) requires modifying the device. The device and entity are tightly coupled. -#### After: Inherit WebSocketDevice +#### After: Device Stores Raw State and Calls `push_update()` ```python -# psn.py - New approach -from ucapi_framework_ import WebSocketDevice -import websockets +# device.py - New approach +from ucapi_framework import WebSocketDevice, DeviceEvents import json -class PSNAccount(WebSocketDevice): - """PlayStation Network account using WebSocketDevice base.""" - - def __init__(self, device_config): - super().__init__( - identifier=device_config.identifier, - name=device_config.name - ) - self._npsso = device_config.npsso - self.state = "OFF" - - async def create_websocket(self): - """Create WebSocket connection - called by base class.""" - return await websockets.connect( - "wss://psn-api.example.com/ws", - extra_headers={"Authorization": f"Bearer {self._npsso}"} - ) - +class MyDevice(WebSocketDevice): + def __init__(self, device_config, config_manager=None): + super().__init__(device_config, config_manager=config_manager) + # Raw device state — plain Python values, no ucapi attributes + self.power: str = "OFF" + self.volume: int = 0 + self.source: str = "" + + @property + def identifier(self) -> str: + return self._device_config.identifier + + @property + def name(self) -> str: + return self._device_config.name + + @property + def address(self) -> str: + return self._device_config.host + + @property + def log_id(self) -> str: + return f"MyDevice[{self.identifier}]" + async def handle_message(self, message: str) -> None: - """Handle received WebSocket message - called by base class.""" + """Process incoming message, update state, then notify subscribers.""" data = json.loads(message) - self.state = data.get("state", "UNKNOWN") - self.events.emit("state_changed", entity_id, self.state) + self.power = data.get("power", self.power) + self.volume = data.get("volume", self.volume) + self.source = data.get("source", self.source) + # Signal "something changed" — no entity IDs, no attribute keys + self.push_update() + + async def establish_connection(self) -> None: + """Called by the framework after the connection is established.""" + # Fetch current state so entities have something to sync on connect + state = await self._fetch_state() + self.power = state["power"] + self.volume = state["volume"] + self.push_update() # Push initial state to all subscribed entities ``` -**Benefits:** -- ~30 lines (70% reduction!) -- Automatic WebSocket lifecycle management -- Automatic reconnection logic -- Automatic task management -- Built-in error handling and logging -- Easy to test with mocked WebSocket -- Focus on business logic only +`push_update()` emits `DeviceEvents.UPDATE` with no arguments. Every entity that subscribed via `subscribe_to_device(device)` will have its `sync_state()` called automatically. + +--- + +### 3. Entity Implementation -**Other Device Patterns:** +Entities are responsible for translating device state into ucapi attributes. This is the **coordinator pattern** — the entity _coordinates_ state between the device and the Remote. + +#### Before: Entity as a Passive Attribute Store ```python -# For HTTP REST API devices: -class MyDevice(StatelessHTTPDevice): - async def verify_connection(self) -> bool: - """Test connection.""" - return await self._make_request("/status") - - async def handle_data_from_device(self, data: dict) -> None: - """Process response.""" - pass - -# For polling devices: -class MyDevice(PollingDevice): - def __init__(self, config): +# media_player.py - Old approach +# Entity state was updated externally by the driver routing attribute dicts. +# Entity had no awareness of the device — driver handled everything. +class MyMediaPlayer(MediaPlayer): + def __init__(self, device_config, device): super().__init__( - identifier=config.device_id, - name=config.name, - poll_interval=5.0 # Poll every 5 seconds + identifier=device_config.identifier, + ... + attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, ) - - async def poll_device(self) -> None: - """Fetch and process state.""" - state = await self._fetch_state() - self.events.emit("state_changed", entity_id, state) + self._device = device + # No sync_state(), no subscribe_to_device() — driver pushed state in ``` -### 3. Setup Flow - -#### Before: Manual Setup Flow Implementation +#### After: Entity Subscribes and Owns Its State ```python -# setup_flow.py - Old approach (~200 lines) -import config -from ucapi import SetupDriver, SetupError, SetupComplete +# media_player.py - New approach +from ucapi import media_player +from ucapi_framework import Entity, create_entity_id, EntityTypes -class PSNSetupFlow: - """Manual setup flow implementation.""" - +class MyMediaPlayer(media_player.MediaPlayer, Entity): + """Media player that subscribes to its device and manages its own state.""" + + def __init__(self, device_config, device): + self._device = device + entity_id = create_entity_id(EntityTypes.MEDIA_PLAYER, device_config.identifier) + + super().__init__( + entity_id, + device_config.name, + features=[ + media_player.Features.ON_OFF, + media_player.Features.VOLUME, + media_player.Features.VOLUME_UP_DOWN, + ], + attributes={ + media_player.Attributes.STATE: media_player.States.UNKNOWN, + media_player.Attributes.VOLUME: 0, + }, + cmd_handler=self.handle_command, + ) + + # Wire entity to device: sync_state() is called on every push_update() + self.subscribe_to_device(device) + + async def sync_state(self) -> None: + """ + Translate device state into ucapi attributes and push to Remote. + + Called automatically when the device calls push_update(). + Always pass a FRESH dict or dataclass — never mutate self.attributes. + Change-filtering compares the incoming dict against the last-pushed state, + so mutating self.attributes first defeats filtering entirely. + """ + self.update({ + media_player.Attributes.STATE: self.map_entity_states(self._device.power), + media_player.Attributes.VOLUME: self._device.volume, + media_player.Attributes.SOURCE: self._device.source, + }) + + async def handle_command(self, entity, cmd_id, params): + """Handle commands from the Remote.""" + match cmd_id: + case media_player.Commands.ON: + await self._device.power_on() + case media_player.Commands.OFF: + await self._device.power_off() + case media_player.Commands.VOLUME: + await self._device.set_volume(params["volume"]) +``` + +**The three key methods at a glance:** + +| Method | Called in | What it does | +|---|---|---| +| `subscribe_to_device(device)` | Entity `__init__` | Wires `sync_state()` to the device's `UPDATE` events | +| `push_update()` | Device, after state changes | Emits `DeviceEvents.UPDATE` — triggers all subscribed entities | +| `sync_state()` | Entity (override required) | Reads device state, calls `self.update({...fresh dict...})` | + +--- + +### 4. Setup Flow + +#### Before: Manual State Machine (~200 lines) + +```python +class MySetupFlow: def __init__(self): self._setup_step = "START" - self._pending_device = None - + async def handle_setup_request(self, msg): - """Handle initial setup request.""" if msg.reconfigure: return await self._show_configuration_mode() - else: - config.clear() - return await self._show_manual_entry() - + config.clear() + return await self._show_manual_entry() + async def handle_user_data_response(self, msg): - """Route user responses to appropriate handlers.""" if self._setup_step == "CONFIGURATION_MODE": return await self._handle_configuration_action(msg) elif self._setup_step == "MANUAL_ENTRY": return await self._handle_manual_entry_response(msg) - # ... more manual routing - - async def _show_configuration_mode(self): - """Show configuration mode screen.""" - self._setup_step = "CONFIGURATION_MODE" - devices = config.all_devices() - - choices = [{"id": d["identifier"], "label": d["name"]} for d in devices] - + # ... hundreds more lines +``` + +#### After: Inherit BaseSetupFlow (~30 lines) + +```python +from ucapi_framework import BaseSetupFlow +from ucapi import IntegrationSetupError, RequestUserInput, SetupError + +class MySetupFlow(BaseSetupFlow[MyDeviceConfig]): + + def get_manual_entry_form(self) -> RequestUserInput: + """Define the manual entry form fields.""" return RequestUserInput( - title="PSN Configuration", - settings=[ - { - "id": "choice", - "label": "Configured Devices", - "field": {"dropdown": {"items": choices}} - }, - { - "id": "action", - "label": "Action", - "field": {"dropdown": {"items": [ - {"id": "add", "label": "Add Device"}, - {"id": "remove", "label": "Remove Device"}, - ]}} - } - ] + {"en": "Add Device"}, + [ + {"id": "name", "label": {"en": "Name"}, "field": {"text": {"value": ""}}}, + {"id": "host", "label": {"en": "IP Address"}, "field": {"text": {"value": ""}}}, + ], ) - - async def _handle_configuration_action(self, msg): - """Handle configuration mode actions.""" - action = msg.input_values.get("action") - choice = msg.input_values.get("choice") - - if action == "add": - return await self._show_manual_entry() - elif action == "remove": - if config.remove_device(choice): - return SetupComplete() - return SetupError() - # ... more manual action handling - - async def _show_manual_entry(self): - """Show manual entry form.""" - self._setup_step = "MANUAL_ENTRY" - return RequestUserInput( - title="Add PSN Account", - settings=[ - {"id": "name", "label": "Name", "field": {"text": {"value": ""}}}, - {"id": "npsso", "label": "NPSSO Token", "field": {"text": {"value": ""}}}, - ] + + async def query_device(self, input_values): + """Validate and create device config from user input.""" + host = input_values.get("host", "").strip() + if not host: + return SetupError(error_type=IntegrationSetupError.CONNECTION_REFUSED) + + return MyDeviceConfig( + identifier=host, + name=input_values.get("name", host), + host=host, ) - - async def _handle_manual_entry_response(self, msg): - """Handle manual entry response.""" - name = msg.input_values["name"] - npsso = msg.input_values["npsso"] - - # Create device config - device_config = { - "identifier": npsso[:8], # Use part of token as ID - "name": name, - "npsso": npsso, - } - - # Check for duplicates - if config.get_device(device_config["identifier"]): - return SetupError(error_type=IntegrationSetupError.DEVICE_EXISTS) - - # Save configuration - if not config.add_device(device_config): - return SetupError() - - return SetupComplete() - - # ... more methods for backup/restore, etc. ``` -**Problems:** -- ~200+ lines of repetitive code -- Manual state management (`_setup_step`) -- Manual routing logic -- Duplicate device checking repeated -- Manual configuration screen building -- No reusability across integrations +**You get for free:** Configuration mode (add/update/remove/reset), backup/restore, duplicate detection, pre-discovery screens, multi-screen flows, migration support. + +--- -#### After: Inherit BaseSetupFlow +### 5. Driver Integration + +#### Before: Global State and Manual Wiring (~300 lines) ```python -# setup_flow.py - New approach -from ucapi_framework_ import BaseSetupFlow -from ucapi import IntegrationSetupError -import config +# driver.py - Old approach +_configured_devices: dict[str, MyDevice] = {} -class PSNSetupFlow(BaseSetupFlow[config.PSNDevice]): - """PSN setup flow using BaseSetupFlow.""" - - async def discover_devices(self) -> list: - """PSN doesn't support auto-discovery.""" - return [] - - def get_manual_entry_fields(self) -> list[dict]: - """Define manual entry fields.""" - return [ - { - "id": "name", - "label": {"en": "Account Name"}, - "field": {"text": {"value": ""}}, - }, - { - "id": "npsso", - "label": {"en": "NPSSO Token"}, - "field": {"text": {"value": ""}}, - }, - ] - - def create_device_from_manual_entry( - self, input_values: dict[str, str] - ) -> config.PSNDevice: - """Create device config from manual entry.""" - name = input_values["name"] - npsso = input_values["npsso"] - - return config.PSNDevice( - identifier=npsso[:8], # Use part of token as ID - name=name, - npsso=npsso, - ) - - def create_device_from_discovery( - self, device_id: str, discovery_data: dict - ) -> config.PSNDevice: - """Not used - PSN doesn't support discovery.""" - raise NotImplementedError() - - def get_device_name(self, device_config: config.PSNDevice) -> str: - """Extract device name.""" - return device_config.name +@api.listens_to(ucapi.Events.CONNECT) +async def on_r2_connect_cmd(): + for device in _configured_devices.values(): + await device.connect() + +@api.listens_to(ucapi.Events.SUBSCRIBE_ENTITIES) +async def on_subscribe_entities(entity_ids): + for entity_id in entity_ids: + device_config = config.get_device(entity_id) + device = MyDevice(device_config) + device.events.on("UPDATE", _on_device_update) + _configured_devices[entity_id] = device + # ... manual entity creation, registration, state sync, etc. ``` -**Benefits:** -- ~50 lines (75% reduction!) -- No manual state management -- No manual routing -- Automatic duplicate checking -- Automatic configuration mode -- Built-in backup/restore -- Fully reusable pattern +#### After: Inherit BaseIntegrationDriver (~5 lines) -**Features You Get For Free:** -- Configuration mode (add/update/remove/reset devices) -- Duplicate device detection -- Backup creation and restore -- Multi-screen setup flows -- Error handling and validation -- State management +```python +from ucapi_framework import BaseIntegrationDriver -### 4. Driver Integration +class MyDriver(BaseIntegrationDriver[MyDevice, MyDeviceConfig]): + def __init__(self): + super().__init__( + device_class=MyDevice, + entity_classes=[MyMediaPlayer], + ) +``` -#### Before: Global State and Manual Event Handlers +For hub devices where entities are discovered at runtime, use factory lambdas: ```python -# driver.py - Old approach (~300 lines) -import asyncio -import ucapi -import ucapi.api as uc -from psn import PSNAccount -import config +class MyHubDriver(BaseIntegrationDriver[MyHub, MyHubConfig]): + def __init__(self): + super().__init__( + device_class=MyHub, + entity_classes=[ + lambda cfg, dev: [MyLight(cfg, info, dev) for info in dev.lights], + lambda cfg, dev: [MyCover(cfg, info, dev) for info in dev.covers], + lambda cfg, dev: [MyScene(cfg, info, dev) for info in dev.scenes], + ], + require_connection_before_registry=True, + ) +``` -_LOG = logging.getLogger("driver") -_LOOP = asyncio.get_event_loop() +--- -# Global API and device storage -api = uc.IntegrationAPI(_LOOP) -_configured_accounts: dict[str, PSNAccount] = {} +## Upgrading to 1.9+: The Coordinator Pattern -@api.listens_to(ucapi.Events.CONNECT) -async def on_r2_connect_cmd() -> None: - """Manually connect all devices.""" - _LOG.debug("Connect command") - await api.set_device_state(ucapi.DeviceStates.CONNECTED) - for account in _configured_accounts.values(): - await account.connect() - -@api.listens_to(ucapi.Events.DISCONNECT) -async def on_r2_disconnect_cmd(): - """Manually disconnect all devices.""" - _LOG.debug("Disconnect command") - for account in _configured_accounts.values(): - await account.disconnect() - -@api.listens_to(ucapi.Events.ENTER_STANDBY) -async def on_r2_enter_standby() -> None: - """Manually handle standby.""" - _LOG.debug("Enter standby") - for account in _configured_accounts.values(): - await account.disconnect() +Version 1.9 introduced the **coordinator pattern** — a fundamental shift in how device state flows to entities. Here are the three key changes. -@api.listens_to(ucapi.Events.SUBSCRIBE_ENTITIES) -async def on_subscribe_entities(entity_ids: list[str]) -> None: - """Manually subscribe to entities.""" - _LOG.debug("Subscribe: %s", entity_ids) - for entity_id in entity_ids: - account_id = entity_id # entity_id IS account_id for PSN - - # Check if already configured - if account_id in _configured_accounts: - account = _configured_accounts[account_id] - state = _map_psn_state(account.state) - api.configured_entities.update_attributes( - entity_id, {media_player.Attributes.STATE: state} - ) - continue - - # Load from config - device_config = config.get_device(account_id) - if device_config: - _add_configured_account(device_config) - -def _add_configured_account(device_config: dict) -> None: - """Manually create and wire up account.""" - account = PSNAccount( - identifier=device_config["identifier"], - name=device_config["name"], - npsso=device_config["npsso"], - ) - - # Manual event handler setup - account.events.on("connected", _on_account_connected) - account.events.on("disconnected", _on_account_disconnected) - account.events.on("connection_error", _on_account_error) - account.events.on("state_changed", _on_state_changed) - - _configured_accounts[account.identifier] = account - - # Manual entity creation - entity = _create_media_player_entity(account) - api.available_entities.add(entity) - -def _on_state_changed(account_id: str, state: str) -> None: - """Manually update entity state.""" - mapped_state = _map_psn_state(state) - api.configured_entities.update_attributes( - account_id, {media_player.Attributes.STATE: mapped_state} - ) +### Change 1: Entities Own Their State via `sync_state()` + +**Old pattern (legacy, still works via `on_device_update`):** -def _map_psn_state(psn_state: str) -> media_player.States: - """Manually map states.""" - match psn_state: - case "PLAYING": - return media_player.States.PLAYING - case "ON" | "MENU": - return media_player.States.ON - case "OFF": - return media_player.States.OFF - case _: - return media_player.States.UNKNOWN - -# ... more manual setup +```python +# Device emits entity ID + attribute dict +device.events.emit(DeviceEvents.UPDATE, entity_id, { + media_player.Attributes.STATE: media_player.States.PLAYING, + media_player.Attributes.VOLUME: 50, +}) +# Driver routes this to the right entity automatically ``` -**Problems:** -- ~300 lines of boilerplate -- Global mutable state (`_configured_accounts`) -- Manual event handler registration -- Manual entity creation and registration -- Manual state synchronization -- Manual lifecycle management -- Difficult to test +**New pattern (coordinator):** -#### After: Inherit BaseIntegrationDriver +```python +# Device stores raw state and signals "something changed" +class MyDevice(WebSocketDevice): + async def handle_message(self, msg): + self.state = msg["state"] + self.volume = msg["volume"] + self.push_update() # No args, no entity awareness + +# Entity subscribes and translates +class MyMediaPlayer(media_player.MediaPlayer, Entity): + def __init__(self, cfg, device): + ... + self.subscribe_to_device(device) # Wire to device + + async def sync_state(self) -> None: + self.update({ + media_player.Attributes.STATE: self.map_entity_states(self._device.state), + media_player.Attributes.VOLUME: self._device.volume, + }) +``` + +### Change 2: Always Pass a Fresh Dict to `update()` + +The framework filters unchanged attributes before pushing to the Remote. For this to work, `update()` must receive a **new dict** each time — not `self.attributes`. + +**Wrong — breaks change filtering:** ```python -# driver.py - New approach -import asyncio -import logging -from typing import Any -from ucapi import media_player -from ucapi_framework_ import BaseIntegrationDriver -import config -from config import PSNDevice -from psn import PSNAccount -from media_player import PSNMediaPlayer -from setup_flow import PSNSetupFlow - -_LOG = logging.getLogger("driver") -_LOOP = asyncio.get_event_loop() - -class PSNIntegrationDriver(BaseIntegrationDriver[PSNAccount, PSNDevice]): - """PSN Integration driver.""" - - def __init__(self, loop: asyncio.AbstractEventLoop): - super().__init__( - loop=loop, - device_class=PSNAccount, - entity_classes=[PSNMediaPlayer] - ) - - # ======================================================================== - # Required Methods - Integration-Specific Logic - # ======================================================================== - - def device_from_entity_id(self, entity_id: str) -> str | None: - """Extract device ID from entity ID.""" - return entity_id # For PSN, entity_id IS the device_id - - def get_entity_ids_for_device(self, device_id: str) -> list[str]: - """Get entity IDs for a device.""" - return [device_id] # One media_player per account - - def map_device_state(self, device_state: Any) -> media_player.States: - """Map PSN state to media player state.""" - match device_state: - case "PLAYING": - return media_player.States.PLAYING - case "ON" | "MENU": - return media_player.States.ON - case "OFF": - return media_player.States.OFF - case _: - return media_player.States.UNKNOWN - - def create_entities( - self, device_config: PSNDevice, device: PSNAccount - ) -> list[PSNMediaPlayer]: - """Create entity instances for a device.""" - return [PSNMediaPlayer(device_config, device)] - -# Create driver instance -driver = PSNIntegrationDriver(_LOOP) -driver.register_setup_handler(PSNSetupFlow, config.PSNDeviceManager) +async def sync_state(self): + # BAD: the framework stores attributes by reference. + # self.attributes IS configured_entities.attributes — comparing an object to itself + # always produces an empty diff, so nothing ever gets sent to the Remote. + self.attributes[media_player.Attributes.STATE] = media_player.States.PLAYING + self.update(self.attributes) ``` -**Benefits:** -- ~90 lines (70% reduction!) -- No global state -- Automatic event handler registration -- Automatic entity lifecycle -- Automatic state synchronization -- Clean, testable design -- Focus on integration-specific logic only +**Correct — fresh dict or dataclass:** -**What You Get For Free:** -- Device lifecycle management -- Event handler wiring -- Entity registration -- State synchronization -- Remote Two event handling -- Configuration loading -- Error handling and logging +```python +async def sync_state(self): + # GOOD: new dict each call — framework diffs against last-pushed state correctly + self.update({ + media_player.Attributes.STATE: media_player.States.PLAYING, + media_player.Attributes.VOLUME: self._device.volume, + }) + + # Or with a typed dataclass (None values are automatically filtered): + # from ucapi_framework.helpers import MediaPlayerAttributes + # self.update(MediaPlayerAttributes( + # state=media_player.States.PLAYING, + # volume=self._device.volume, + # )) +``` -### 5. Entity Implementation +### Change 3: Call `push_update()` After Connecting -#### Before: Global References +`on_device_connected` no longer calls `sync_state()` for coordinator-pattern entities. Instead, call `push_update()` at the end of your device's connection setup so entities receive their initial state. ```python -# media_player.py - Old approach -import ucapi -from ucapi import MediaPlayer - -async def create_media_player_entity(account_id: str, name: str) -> MediaPlayer: - """Create media player entity - referenced global state.""" - entity = MediaPlayer( - identifier=account_id, - name=ucapi.EntityName(name, "en"), - features=[], - attributes={}, - device_class=ucapi.media_player.DeviceClasses.TV, - ) - return entity - -# Command handler referenced global _configured_accounts dict -async def media_player_cmd_handler(entity, cmd_id, params): - """Handler that needs global state.""" - import driver # Circular import! - account = driver._configured_accounts.get(entity.id) - if not account: - return ucapi.StatusCodes.NOT_FOUND - # Handle command... +class MyDevice(WebSocketDevice): + async def establish_connection(self) -> None: + """Called by the framework after the connection is established.""" + # Fetch current state from device + state = await self._fetch_initial_state() + self.power = state["power"] + self.volume = state["volume"] + # Push to all subscribed entities + self.push_update() ``` -**Problems:** -- Circular dependencies -- Global state references -- No type safety -- Difficult to test -- Tight coupling +--- + +## Common Patterns + +### Hub with Dynamic Entities -#### After: Instance References +Use `require_connection_before_registry=True` when the hub must be connected before you know what entities exist. The framework will connect the device first, then register entities: ```python -# media_player.py - New approach -import logging -from typing import Any -from ucapi import EntityName, MediaPlayer, StatusCodes, media_player -from config import PSNDevice -from psn import PSNAccount - -_LOG = logging.getLogger(__name__) - -class PSNMediaPlayer(MediaPlayer): - """PSN Media Player entity with device reference.""" - - def __init__(self, device_config: PSNDevice, device: PSNAccount): - """Initialize with device instance - no global state.""" - self._device = device - +class SmartHubDriver(BaseIntegrationDriver[SmartHub, SmartHubConfig]): + def __init__(self): super().__init__( - identifier=device_config.identifier, - name=EntityName(device_config.name, "en"), - features=[ - media_player.Features.ON_OFF, - media_player.Features.TOGGLE, + device_class=SmartHub, + entity_classes=[ + lambda cfg, dev: [HubLight(cfg, light, dev) for light in dev.lights], + lambda cfg, dev: [HubCover(cfg, cover, dev) for cover in dev.covers], ], - attributes={ - media_player.Attributes.STATE: media_player.States.UNKNOWN, - }, - device_class=media_player.DeviceClasses.STREAMING_BOX, - cmd_handler=self.handle_command, + require_connection_before_registry=True, ) - - async def handle_command( - self, entity: MediaPlayer, cmd_id: str, params: dict[str, Any] | None - ) -> StatusCodes: - """Handle media player commands - uses self._device.""" - _LOG.info("Command: %s %s", cmd_id, params) - - # Direct device reference - no global lookup! - if cmd_id == media_player.Commands.ON: - await self._device.turn_on() - return StatusCodes.OK - - if cmd_id == media_player.Commands.OFF: - await self._device.turn_off() - return StatusCodes.OK - - return StatusCodes.NOT_IMPLEMENTED ``` -**Benefits:** -- No circular dependencies -- No global state -- Type-safe device reference -- Easy to test -- Clean separation of concerns - -## Common Patterns - -### Pattern: Multi-Device Integration +### Marking Entities Unavailable on Disconnect -If your integration manages multiple device types: +Override `on_device_disconnected` in your driver, or subscribe to `DeviceEvents.ERROR` in the entity: ```python class MyDriver(BaseIntegrationDriver[MyDevice, MyDeviceConfig]): - def get_entity_ids_for_device(self, device_id: str) -> list[str]: - """Multiple entities per device.""" - return [ - f"{device_id}_player", - f"{device_id}_light", - f"{device_id}_sensor", - ] - - def create_entities(self, device_config, device): - """Create multiple entity types.""" - return [ - MyMediaPlayerEntity(device_config, device), - MyLightEntity(device_config, device), - MySensorEntity(device_config, device), - ] + async def on_device_disconnected(self, device_id: str) -> None: + await super().on_device_disconnected(device_id) + for entity in self._get_framework_entities_for_device(device_id): + entity.set_unavailable() ``` -### Pattern: API Authentication +### Pre-Discovery Credentials -Use pre-discovery screens to collect credentials: +Use `get_pre_discovery_screen()` to collect API keys or server addresses before discovery runs: ```python class MySetupFlow(BaseSetupFlow[MyDeviceConfig]): async def get_pre_discovery_screen(self): - """Collect API credentials before discovery.""" return RequestUserInput( - title="API Configuration", - settings=[ - {"id": "api_key", "label": "API Key", "field": {"text": {...}}}, - ] + {"en": "API Configuration"}, + [{"id": "api_key", "label": {"en": "API Key"}, "field": {"text": {"value": ""}}}], ) - + async def discover_devices(self): - """Use credentials from self._pre_discovery_data.""" api_key = self._pre_discovery_data.get("api_key") - # Perform authenticated discovery... + return await MyDiscovery.run(api_key=api_key) ``` -### Pattern: Complex Setup +### Multi-Screen Setup -Use post-selection screens for additional configuration: +Return `RequestUserInput` from `query_device()` after storing the partial config: ```python -async def get_additional_configuration_screen(self, device_config, previous_input): - """Show zone selection after device chosen.""" +async def query_device(self, input_values): + device = await MyDevice.fetch_info(input_values["host"]) + if not device: + return SetupError(error_type=IntegrationSetupError.NOT_FOUND) + + # Store partial config, show next screen + self._pending_device_config = MyDeviceConfig( + identifier=device.id, + name=device.name, + host=input_values["host"], + ) return RequestUserInput( - title="Zone Configuration", - settings=[ - {"id": "zone", "label": "Zone", "field": {"dropdown": {...}}}, - ] + {"en": "Select Zone"}, + [{"id": "zone", "label": {"en": "Zone"}, "field": {"dropdown": {"items": device.zones}}}], ) async def handle_additional_configuration_response(self, msg): - """Update device config with zone.""" self._pending_device_config.zone = msg.input_values["zone"] - return None # Complete setup + return None # Save and complete ``` -## Testing Your Migration +--- -### Unit Testing +## Testing Your Migration -The new architecture is much easier to test: +### Testing `sync_state()` ```python import pytest -from myintegration.driver import MyDriver +from unittest.mock import MagicMock, AsyncMock from myintegration.config import MyDeviceConfig - -@pytest.fixture -def driver(): - loop = asyncio.get_event_loop() - return MyDriver(loop) +from myintegration.device import MyDevice +from myintegration.media_player import MyMediaPlayer @pytest.fixture def device_config(): - return MyDeviceConfig( - device_id="test123", - name="Test Device", - host="192.168.1.100", - ) - -async def test_device_creation(driver, device_config): - """Test device lifecycle without global state.""" - device = driver._device_class(device_config) - assert device.identifier == "test123" - assert device.name == "Test Device" - -async def test_state_mapping(driver): - """Test state mapping.""" - assert driver.map_device_state("PLAYING") == media_player.States.PLAYING - assert driver.map_device_state("OFF") == media_player.States.OFF + return MyDeviceConfig(identifier="test123", name="Test Device", host="192.168.1.100") + +async def test_sync_state_maps_device_state(device_config): + """Entity reads from device and pushes fresh dict to Remote.""" + device = MyDevice(device_config) + device.power = "PLAYING" + device.volume = 42 + + # Mock the API so update() doesn't fail + entity = MyMediaPlayer(device_config, device) + entity._api = MagicMock() + entity._api.configured_entities.contains.return_value = True + entity._api.configured_entities.get.return_value = MagicMock(attributes={}) + entity._api.configured_entities.update_attributes = MagicMock() + + await entity.sync_state() + + entity._api.configured_entities.update_attributes.assert_called_once() + args = entity._api.configured_entities.update_attributes.call_args[0] + assert media_player.Attributes.STATE in args[1] + assert args[1][media_player.Attributes.STATE] == media_player.States.PLAYING ``` -### Integration Testing - -Test with real Remote Two connection: - -1. Run your integration -2. Add device through Remote Two UI -3. Verify device appears in `config.json` -4. Verify entity shows up in Remote Two -5. Test commands through Remote Two UI - ### Migration Checklist -- [ ] Configuration converted to dataclass + BaseDeviceManager -- [ ] Device inherits from appropriate base class (StatelessHTTPDevice, PollingDevice, etc.) -- [ ] Setup flow inherits from BaseSetupFlow -- [ ] Driver inherits from BaseIntegrationDriver +- [ ] Configuration converted to dataclass + `BaseConfigManager` +- [ ] Device inherits from appropriate base class +- [ ] Device stores raw state (not ucapi attribute keys) +- [ ] Device calls `push_update()` after state changes (no args) +- [ ] Device calls `push_update()` at end of `establish_connection()` / `connect()` +- [ ] Entity inherits from both the ucapi entity class and framework `Entity` +- [ ] Entity calls `subscribe_to_device(device)` in `__init__` +- [ ] Entity overrides `sync_state()` and passes a **fresh dict or dataclass** to `update()` +- [ ] Entity does NOT call `self.attributes[...] = ...; self.update(self.attributes)` +- [ ] Setup flow inherits from `BaseSetupFlow` and implements `get_manual_entry_form()` + `query_device()` +- [ ] Driver inherits from `BaseIntegrationDriver` - [ ] All global state removed -- [ ] Entities reference device instances, not globals -- [ ] Abstract method names updated (no underscores) -- [ ] Manual event handler registration removed -- [ ] Manual entity lifecycle code removed -- [ ] Unit tests updated -- [ ] Integration tests pass -- [ ] Documentation updated +- [ ] Factory lambdas used for hub-based dynamic entity creation (not `create_entities()` override) +- [ ] Tests verify `sync_state()` reads from device and calls `update()` with a fresh dict ## Need Help? -- Check the PSN integration in this repo for a complete example -- Review inline docstrings in ucapi_framework_ modules -- See README.md for detailed API documentation +- Review inline docstrings in `ucapi_framework` modules — they include detailed examples +- See the [Device Patterns](guide/device-patterns.md) guide for connection class details +- See the [Driver Guide](guide/driver.md) for driver configuration - Open an issue on GitHub for questions diff --git a/docs/guide/device-patterns.md b/docs/guide/device-patterns.md index 2af6a00..ad16df6 100644 --- a/docs/guide/device-patterns.md +++ b/docs/guide/device-patterns.md @@ -1,6 +1,28 @@ # Device Patterns -The framework provides four base device classes for different connection patterns. Choose the one that matches your device's communication method. +The framework provides six base device classes for different connection patterns. Choose the one that matches your device's communication method. + +## Separation of Concerns + +The device layer has one job: **manage the connection and store raw hardware state**. It does not know about ucapi entities, attribute keys, or the Remote. When state changes, it calls `push_update()` to signal subscribers. + +Entities subscribe to the device via `subscribe_to_device(device)` and translate raw state into ucapi attributes in their `sync_state()` method. + +``` +Device Entity +────────────────── ────────────────────────────── +self.power = "PLAYING" → subscribe_to_device(device) +self.volume = 42 → sync_state() called automatically +self.push_update() → self.update({Attributes.STATE: ..., Attributes.VOLUME: ...}) +``` + +This separation means: + +- The device can be used with any combination of entity types without changes +- Entities can be tested independently by injecting a mock device +- Adding a second entity type (e.g., a Remote alongside a MediaPlayer) requires zero changes to the device + +--- ## StatelessHTTPDevice @@ -10,12 +32,12 @@ For devices with REST APIs where each request creates a new HTTP session. **You implement:** -- `verify_connection()` - Test device is reachable +- `verify_connection()` — Test device is reachable - Property accessors (`identifier`, `name`, `address`, `log_id`) +- Any methods to send commands or fetch state **Framework handles:** -- HTTP session management - Connection verification - Error handling @@ -26,37 +48,55 @@ from ucapi_framework import StatelessHTTPDevice import aiohttp class MyRESTDevice(StatelessHTTPDevice): + def __init__(self, device_config, config_manager=None): + super().__init__(device_config, config_manager=config_manager) + # Raw device state + self.power: str = "OFF" + self.volume: int = 0 + @property def identifier(self) -> str: return self._device_config.identifier - + @property def name(self) -> str: return self._device_config.name - + @property def address(self) -> str: return self._device_config.host - + @property def log_id(self) -> str: return f"Device[{self.identifier}]" - + async def verify_connection(self) -> None: """Verify device is reachable.""" url = f"http://{self.address}/api/status" async with aiohttp.ClientSession() as session: async with session.get(url) as response: response.raise_for_status() - - async def send_command(self, command: str) -> None: + + async def fetch_state(self) -> None: + """Fetch current state and notify subscribers.""" + url = f"http://{self.address}/api/state" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + data = await response.json() + self.power = data["power"] + self.volume = data["volume"] + self.push_update() # Notify subscribed entities + + async def send_command(self, command: str, params: dict | None = None) -> None: """Send command to device.""" url = f"http://{self.address}/api/command" async with aiohttp.ClientSession() as session: - async with session.post(url, json={"command": command}) as response: + async with session.post(url, json={"command": command, **(params or {})}) as response: response.raise_for_status() ``` +--- + ## PollingDevice For devices that need periodic state checks. @@ -65,8 +105,8 @@ For devices that need periodic state checks. **You implement:** -- `establish_connection()` - Initial connection setup -- `poll_device()` - Periodic state check (emits UPDATE events) +- `establish_connection()` — Initial connection setup +- `poll_device()` — Periodic state check; update raw state and call `push_update()` - Property accessors **Framework handles:** @@ -78,7 +118,7 @@ For devices that need periodic state checks. ### Example ```python -from ucapi_framework import PollingDevice, DeviceEvents +from ucapi_framework import PollingDevice import aiohttp class MyPollingDevice(PollingDevice): @@ -86,45 +126,47 @@ class MyPollingDevice(PollingDevice): super().__init__( device_config, poll_interval=30, # Poll every 30 seconds - config_manager=config_manager + config_manager=config_manager, ) - self._session = None - + self.power: str = "OFF" + self.volume: int = 0 + @property def identifier(self) -> str: return self._device_config.identifier - + @property def name(self) -> str: return self._device_config.name - + @property def address(self) -> str: return self._device_config.host - + @property def log_id(self) -> str: return f"Device[{self.identifier}]" - + async def establish_connection(self) -> None: - """Initial connection.""" - self._session = aiohttp.ClientSession() - + """Initial connection — fetch current state.""" + await self._fetch_and_notify() + async def poll_device(self) -> None: - """Poll device state.""" + """Called on each poll interval — update state and notify subscribers.""" + await self._fetch_and_notify() + + async def _fetch_and_notify(self) -> None: url = f"http://{self.address}/api/state" - async with self._session.get(url) as response: - state = await response.json() - self._state = state["power"] - - # Emit update event - self.events.emit( - DeviceEvents.UPDATE, - self.identifier, - {"state": state["power"], "volume": state["volume"]} - ) + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + data = await response.json() + self.power = data["power"] + self.volume = data["volume"] + self.push_update() # Notify subscribed entities ``` +--- + ## WebSocketDevice For devices with WebSocket APIs providing real-time updates. @@ -133,10 +175,10 @@ For devices with WebSocket APIs providing real-time updates. **You implement:** -- `create_websocket()` - Establish WebSocket connection -- `close_websocket()` - Close WebSocket connection -- `receive_message()` - Receive message from WebSocket -- `handle_message()` - Process received message +- `create_websocket()` — Establish WebSocket connection +- `close_websocket()` — Close WebSocket connection +- `receive_message()` — Receive a single message from the WebSocket +- `handle_message()` — Process received message; update raw state and call `push_update()` - Property accessors **Framework handles:** @@ -149,69 +191,81 @@ For devices with WebSocket APIs providing real-time updates. ### Example ```python -from ucapi_framework import WebSocketDevice, DeviceEvents +from ucapi_framework import WebSocketDevice import websockets +import json class MyWebSocketDevice(WebSocketDevice): def __init__(self, device_config, config_manager=None): super().__init__( device_config, reconnect=True, - ping_interval=30, # Ping every 30 seconds - config_manager=config_manager + ping_interval=30, + config_manager=config_manager, ) - + self.power: str = "OFF" + self.volume: int = 0 + self.source: str = "" + @property def identifier(self) -> str: return self._device_config.identifier - + @property def name(self) -> str: return self._device_config.name - + @property def address(self) -> str: return self._device_config.host - + @property def log_id(self) -> str: return f"Device[{self.identifier}]" - + async def create_websocket(self): - """Create WebSocket connection.""" + """Establish WebSocket connection.""" uri = f"ws://{self.address}/ws" return await websockets.connect(uri) - + async def close_websocket(self) -> None: """Close WebSocket connection.""" if self._ws: await self._ws.close() - + async def receive_message(self): - """Receive message from WebSocket.""" + """Receive a message.""" return await self._ws.recv() - + async def handle_message(self, message: str) -> None: - """Process received message.""" - import json + """ + Process a received message. + + Update raw device state, then call push_update() to notify all + subscribed entities. Entities will call sync_state() to read the + new values and push updated attributes to the Remote. + """ data = json.loads(message) - - if data["type"] == "state_update": - self._state = data["state"] - self.events.emit( - DeviceEvents.UPDATE, - self.identifier, - {"state": data["state"]} - ) + self.power = data.get("power", self.power) + self.volume = data.get("volume", self.volume) + self.source = data.get("source", self.source) + self.push_update() + + async def establish_connection(self) -> None: + """Called after WebSocket is connected — fetch initial state.""" + # Optionally fetch full state before first push + self.push_update() ``` +--- + ## WebSocketPollingDevice Hybrid device combining WebSocket for real-time updates with polling as a fallback. **Good for:** Smart TVs, media players with WebSocket that may disconnect -**You implement:** Same as WebSocketDevice + PollingDevice +**You implement:** Same as `WebSocketDevice` + `PollingDevice` **Framework handles:** @@ -231,20 +285,28 @@ class MyHybridDevice(WebSocketPollingDevice): poll_interval=30, ping_interval=30, keep_polling_on_disconnect=True, - config_manager=config_manager + config_manager=config_manager, ) - - # Implement WebSocket methods + self.power: str = "OFF" + self.volume: int = 0 + + # Implement WebSocket methods (same as WebSocketDevice) async def create_websocket(self): ... async def close_websocket(self): ... async def receive_message(self): ... - async def handle_message(self, message): ... - - # Implement Polling methods + async def handle_message(self, message: str) -> None: + ... + self.push_update() + + # Implement Polling methods (same as PollingDevice) async def establish_connection(self): ... - async def poll_device(self): ... + async def poll_device(self) -> None: + ... + self.push_update() ``` +--- + ## PersistentConnectionDevice For devices with persistent TCP connections or custom protocols. @@ -253,9 +315,9 @@ For devices with persistent TCP connections or custom protocols. **You implement:** -- `establish_connection()` - Create persistent connection -- `close_connection()` - Close connection -- `maintain_connection()` - Keep connection alive (blocking) +- `establish_connection()` — Create persistent connection +- `close_connection()` — Close connection +- `maintain_connection()` — Keep connection alive (blocking receive loop) - Property accessors **Framework handles:** @@ -267,56 +329,55 @@ For devices with persistent TCP connections or custom protocols. ### Example ```python -from ucapi_framework import PersistentConnectionDevice, DeviceEvents +from ucapi_framework import PersistentConnectionDevice +import asyncio class MyTCPDevice(PersistentConnectionDevice): + def __init__(self, device_config, config_manager=None): + super().__init__(device_config, config_manager=config_manager) + self.power: str = "OFF" + @property def identifier(self) -> str: return self._device_config.identifier - + @property def name(self) -> str: return self._device_config.name - + @property def address(self) -> str: return self._device_config.host - + @property def log_id(self) -> str: return f"Device[{self.identifier}]" - + async def establish_connection(self): """Establish TCP connection.""" - reader, writer = await asyncio.open_connection( - self.address, 8080 - ) + reader, writer = await asyncio.open_connection(self.address, 8080) return {"reader": reader, "writer": writer} - + async def close_connection(self) -> None: """Close TCP connection.""" if self._connection: self._connection["writer"].close() await self._connection["writer"].wait_closed() - + async def maintain_connection(self) -> None: - """Maintain connection and process messages.""" + """Receive loop — called by framework, should block until disconnected.""" reader = self._connection["reader"] - while True: data = await reader.readline() if not data: - break # Connection closed - - # Process message + break message = data.decode().strip() - self.events.emit( - DeviceEvents.UPDATE, - self.identifier, - {"message": message} - ) + self.power = message # Parse appropriately + self.push_update() ``` +--- + ## ExternalClientDevice For devices using external client libraries that manage their own connections. @@ -325,10 +386,10 @@ For devices using external client libraries that manage their own connections. **You implement:** -- `create_client()` - Create the external client instance -- `connect_client()` - Connect and set up event handlers -- `disconnect_client()` - Disconnect and remove event handlers -- `check_client_connected()` - Query actual client connection state +- `create_client()` — Create the external client instance +- `connect_client()` — Connect and set up event handlers +- `disconnect_client()` — Disconnect and remove event handlers +- `check_client_connected()` — Query actual client connection state - Property accessors **Framework handles:** @@ -341,67 +402,65 @@ For devices using external client libraries that manage their own connections. ### Example ```python -from ucapi_framework import ExternalClientDevice, DeviceEvents +from ucapi_framework import ExternalClientDevice class MyExternalDevice(ExternalClientDevice): def __init__(self, device_config, config_manager=None): super().__init__( device_config, - enable_watchdog=True, # Monitor connection state - watchdog_interval=30, # Check every 30 seconds - reconnect_delay=5, # Wait 5s between reconnect attempts - max_reconnect_attempts=3, # Give up after 3 failures (0 = infinite) - config_manager=config_manager + enable_watchdog=True, + watchdog_interval=30, + reconnect_delay=5, + max_reconnect_attempts=3, + config_manager=config_manager, ) - + self.power: str = "OFF" + self.volume: int = 0 + @property def identifier(self) -> str: return self._device_config.identifier - + @property def name(self) -> str: return self._device_config.name - + @property def address(self) -> str: return self._device_config.host - + @property def log_id(self) -> str: return f"Device[{self.identifier}]" - + async def create_client(self): - """Create the external client instance.""" from some_library import Client return Client(self.address) - + async def connect_client(self) -> None: - """Connect the client and set up event handlers.""" await self._client.connect() self._client.on("state_changed", self._on_state_changed) - + async def disconnect_client(self) -> None: - """Disconnect and clean up.""" self._client.off("state_changed", self._on_state_changed) await self._client.disconnect() - + def check_client_connected(self) -> bool: - """Check actual client connection state.""" return self._client is not None and self._client.connected - - def _on_state_changed(self, state): - """Handle state changes from the client.""" - self.events.emit( - DeviceEvents.UPDATE, - self.identifier, - {"state": state} - ) + + def _on_state_changed(self, data: dict) -> None: + """Handle state changes from the client library.""" + self.power = data.get("power", self.power) + self.volume = data.get("volume", self.volume) + self.push_update() # Notify subscribed entities ``` +--- + ## Choosing a Pattern | Pattern | Use Case | Complexity | -|---------|----------|------------| +|---|---|---| | **StatelessHTTPDevice** | REST APIs, no real-time updates | ⭐ Simple | | **PollingDevice** | Need periodic state checks | ⭐⭐ Moderate | | **WebSocketDevice** | WebSocket APIs, real-time | ⭐⭐⭐ Complex | diff --git a/docs/guide/driver.md b/docs/guide/driver.md index 9fae2b0..a073047 100644 --- a/docs/guide/driver.md +++ b/docs/guide/driver.md @@ -7,305 +7,214 @@ The driver is the central coordinator of your integration, managing device lifec The `BaseIntegrationDriver` handles: - ✅ Remote Two event handling (connect, disconnect, standby) -- ✅ Entity subscription management +- ✅ Entity subscription and registration management - ✅ Device lifecycle (add, remove, connect, disconnect) - ✅ State propagation from devices to entities - ✅ Event routing and coordination -## Default Implementations +## How State Flows -The driver provides sensible defaults for common patterns. **You typically don't need to override these methods** unless you have specific requirements: +With the coordinator pattern, state flows in one direction: -### 1. create_entities() ✅ Has Default - -**Default behavior**: Creates one instance per entity class passed to `__init__`, calling: `entity_class(device_config, device)` - -```python -# Works automatically for standard entity creation -driver = MyIntegrationDriver( - device_class=MyDevice, - entity_classes=[MyMediaPlayer, MyRemote] -) -# Framework automatically calls: MyMediaPlayer(device_config, device), MyRemote(device_config, device) ``` +Device Entity Remote Two +────── ────── ────────── +push_update() → sync_state() → update_attributes() + self.update({...}) +``` + +1. The device detects a state change and calls `push_update()` +2. Every entity subscribed to that device has `sync_state()` called automatically +3. The entity reads raw values from the device and calls `self.update({...fresh dict...})` +4. The framework diffs against the last-pushed state and sends only changed attributes to the Remote -**Override when you need**: +The driver wires the device and entities together at subscription time. You don't need to manage this manually. -- Variable entity counts (e.g., multi-zone receivers) -- Hub-based discovery -- Conditional entity creation -- Custom parameters beyond `(device_config, device)` +## Minimal Setup -#### Example: Multi-Zone Receiver +Most integrations work with just the constructor: ```python -class AnthemDriver(BaseIntegrationDriver): - def create_entities(self, device_config: AnthemConfig, device: AnthemDevice) -> list[Entity]: - """Create one media player per configured zone.""" - entities = [] - - for zone in device_config.zones: - entity = AnthemMediaPlayer( - entity_id=f"media_player.{device_config.id}_zone_{zone.id}", - device=device, - device_config=device_config, - zone_config=zone, # Custom parameter! - ) - entities.append(entity) - - return entities +from ucapi_framework import BaseIntegrationDriver + +class MyDriver(BaseIntegrationDriver[MyDevice, MyDeviceConfig]): + def __init__(self): + super().__init__( + device_class=MyDevice, + entity_classes=[MyMediaPlayer, MyRemote], + ) ``` -Your entity class accepts the custom parameters: +The framework automatically: + +- Creates entity instances when a device is subscribed to +- Wires device lifecycle events (connected, disconnected, error) +- Calls `sync_state()` on reconnect for coordinator entities +- Propagates legacy `on_device_update` attribute dicts for non-coordinator entities + +## Hub-Based Dynamic Entities + +For integrations where the set of entities is discovered at runtime (hubs, bridges, multi-zone devices), use factory lambdas in `entity_classes` and set `require_connection_before_registry=True`: ```python -class AnthemMediaPlayer(MediaPlayer): - def __init__( - self, - entity_id: str, - device: AnthemDevice, - device_config: AnthemConfig, - zone_config: ZoneConfig, # Custom! - ): - self._device = device - self._zone = zone_config - +class MyHubDriver(BaseIntegrationDriver[SmartHub, SmartHubConfig]): + def __init__(self): super().__init__( - entity_id, - f"{device_config.name} {zone_config.name}", - features=[...], - attributes={...}, + device_class=SmartHub, + entity_classes=[ + lambda cfg, dev: [HubLight(cfg, light, dev) for light in dev.lights], + lambda cfg, dev: [HubCover(cfg, cover, dev) for cover in dev.covers], + lambda cfg, dev: [HubScene(cfg, scene, dev) for scene in dev.scenes], + ], + require_connection_before_registry=True, ) ``` -#### Example: Hub-Based Discovery +When `require_connection_before_registry=True`: -```python -class LutronDriver(BaseIntegrationDriver): - def create_entities(self, device_config: LutronConfig, device: LutronHub) -> list[Entity]: - """Discover and create entities from hub.""" - entities = [] - - # Query hub for available devices - for hub_device in device.discover_devices(): - if hub_device.type == "light": - entity = LutronLight( - entity_id=f"light.{device_config.id}_{hub_device.id}", - device=device, - device_config=device_config, - hub_device=hub_device, # Custom parameter! - ) - elif hub_device.type == "cover": - entity = LutronCover( - entity_id=f"cover.{device_config.id}_{hub_device.id}", - device=device, - device_config=device_config, - hub_device=hub_device, # Custom parameter! - ) - entities.append(entity) - - return entities -``` +1. The device connects first +2. The device's `connect()` populates its entity lists (e.g., `dev.lights`, `dev.covers`) +3. The framework calls the factory lambdas to create entity instances +4. Entities are registered with the Remote + +Each lambda receives `(device_config, device)` and returns a single entity or a list. The return type is `Entity | list[Entity]`. -#### Example: Conditional Creation +## Overridable Methods + +The driver provides sensible defaults for all common patterns. Override only what you need. + +### `create_entities()` — Override for Advanced Cases + +**Default behavior:** Calls each item in `entity_classes`. If the item is a class, calls `entity_class(device_config, device)`. If it's a callable (lambda/factory), calls it with `(device_config, device)`. + +**Override when:** You need logic that can't fit in a lambda — for example, async entity initialization or conditional creation based on external state. ```python -class YamahaDriver(BaseIntegrationDriver): - def create_entities(self, device_config, device) -> list[Entity]: - """Create entities based on device capabilities.""" - entities = [] - - if device.supports_playback: - entities.append(YamahaMediaPlayer(device_config, device)) - - if device.supports_remote: - entities.append(YamahaRemote(device_config, device)) - - return entities +def create_entities(self, device_config: MyConfig, device: MyDevice) -> list[Entity]: + entities = [] + if device.supports_playback: + entities.append(MyMediaPlayer(device_config, device)) + if device.supports_remote: + entities.append(MyRemote(device_config, device)) + return entities ``` -### 2. map_device_state() ✅ Has Default +### `map_device_state()` — Override for Custom State Enums -**Default behavior**: Converts common state strings to `media_player.States`: +**Default behavior:** Maps common state strings to `media_player.States`: -- `"ON"`, `"MENU"`, `"IDLE"` → `States.ON` -- `"OFF"`, `"POWER_OFF"` → `States.OFF` -- `"PLAYING"`, `"PLAY"` → `States.PLAYING` -- `"PAUSED"` → `States.PAUSED` -- `"STANDBY"` → `States.STANDBY` -- `"BUFFERING"` → `States.BUFFERING` +- `"ON"`, `"MENU"`, `"IDLE"`, `"ACTIVE"`, `"READY"` → `States.ON` +- `"OFF"`, `"POWER_OFF"`, `"STOPPED"` → `States.OFF` +- `"PLAYING"`, `"PLAY"`, `"SEEKING"` → `States.PLAYING` +- `"PAUSED"`, `"PAUSE"` → `States.PAUSED` +- `"STANDBY"`, `"SLEEP"` → `States.STANDBY` +- `"BUFFERING"`, `"LOADING"` → `States.BUFFERING` - Everything else → `States.UNKNOWN` -```python -# Works automatically for common device states -device.state = "PLAYING" # Maps to media_player.States.PLAYING -``` +!!! note + If your entity uses `sync_state()` (coordinator pattern), it calls `self.map_entity_states()` on the entity — not this driver method. `map_device_state()` is used by the legacy `on_device_update` path. -**Override only if** you have custom state enums: +**Override when** you have device-specific state enums: ```python -def map_device_state(self, device_state: Any) -> media_player.States: - """Map custom device state enum.""" +def map_device_state(self, device_state) -> media_player.States: if isinstance(device_state, MyDeviceState): match device_state: case MyDeviceState.POWERED_ON: return media_player.States.ON case MyDeviceState.POWERED_OFF: return media_player.States.OFF - case MyDeviceState.PLAYING: - return media_player.States.PLAYING case _: return media_player.States.UNKNOWN - - # Fallback to default for string states return super().map_device_state(device_state) ``` -### 3. device_from_entity_id() ✅ Has Default +### `device_from_entity_id()` — Override for Custom Entity ID Formats -**Default behavior**: Parses standard entity ID format `"entity_type.device_id"` or `"entity_type.device_id.entity_id"`. +**Default behavior:** Parses standard format `"entity_type.device_id"` or `"entity_type.device_id.sub_device_id"`. Returns the second segment. ```python -# Works automatically with create_entity_id() -entity_id = "media_player.receiver_123" -device_id = driver.device_from_entity_id(entity_id) # Returns "receiver_123" +device_id = driver.device_from_entity_id("media_player.receiver_123") +# Returns "receiver_123" + +device_id = driver.device_from_entity_id("light.hub_1.bedroom") +# Returns "hub_1" ``` -**Override only if** you use a custom entity ID format: +**Raises `ValueError`** if the entity ID doesn't contain the expected separator. -```python -def create_entities( - self, device_config: MyDeviceConfig, device: MyDevice -) -> list[Entity]: - """Custom entity ID format.""" - # Entity ID IS the device ID (custom format) - return [MyMediaPlayer(device_config.identifier, ...)] +**Override when** your entity IDs use a non-standard format: +```python def device_from_entity_id(self, entity_id: str) -> str | None: - """Parse custom entity ID format.""" - # For this custom format, entity_id IS the device_id + # For PSN-style: entity_id IS the device_id return entity_id ``` -!!! warning "Important" - If you override `create_entities()` with a custom entity ID format, you **must** also override `device_from_entity_id()` to match. The framework will raise an error if you forget. +### `entity_type_from_entity_id()` and `sub_device_from_entity_id()` -### 5. entity_type_from_entity_id() ✅ Has Default - -**Default behavior**: Extracts entity type from standard format `"entity_type.device_id"`. +Same parsing conventions as `device_from_entity_id()`. Override together if you use a custom entity ID format. ```python -entity_id = "media_player.receiver_123" -entity_type = driver.entity_type_from_entity_id(entity_id) # Returns "media_player" -``` +entity_type = driver.entity_type_from_entity_id("light.hub_1.bedroom") +# Returns "light" -**Override only if** you use a custom entity ID format (same conditions as `device_from_entity_id()`). +sub_device = driver.sub_device_from_entity_id("light.hub_1.bedroom") +# Returns "bedroom" -### 6. sub_device_from_entity_id() ✅ Has Default - -**Default behavior**: Extracts sub-device ID from 3-part format `"entity_type.device_id.sub_device_id"`. - -```python -# 2-part format returns None -entity_id = "media_player.receiver_123" -sub_device = driver.sub_device_from_entity_id(entity_id) # Returns None - -# 3-part format returns the sub-device -entity_id = "light.hub_1.bedroom" -sub_device = driver.sub_device_from_entity_id(entity_id) # Returns "bedroom" +sub_device = driver.sub_device_from_entity_id("media_player.receiver_123") +# Returns None (no sub-device in 2-part format) ``` -Useful for hub-based integrations where one device exposes multiple entities. - -### 7. get_entity_ids_for_device() ✅ Has Default +All three raise `ValueError` if the separator is missing. They return `None` only for empty input or a missing sub-device (which is valid). -**Default behavior**: Queries the API for all entities (both available and configured) and filters by device ID. +### `on_device_update()` — Legacy Attribute Routing -```python -# Works automatically - no override needed -entity_ids = driver.get_entity_ids_for_device("receiver_123") -# Returns ["media_player.receiver_123", "remote.receiver_123"] -``` +**Default behavior:** Routes attribute dicts from `DeviceEvents.UPDATE` to the correct entity by `entity_id`. Supports all entity types. -**Override only if** you need performance optimization: +This method is used by the **legacy pattern** where devices emit `(entity_id, attributes_dict)`: ```python -def __init__(self, loop): - super().__init__(...) - self._entity_cache: dict[str, list[str]] = {} - -def get_entity_ids_for_device(self, device_id: str) -> list[str]: - """Cached entity lookup for performance.""" - if device_id not in self._entity_cache: - self._entity_cache[device_id] = [ - f"media_player.{device_id}", - f"remote.{device_id}", - ] - return self._entity_cache[device_id] -``` - -### 8. on_device_update() ✅ Has Default - -**Default behavior**: Automatically extracts entity-type-specific attributes from the update dict and updates configured/available entities. Supports all entity types (Button, Climate, Cover, Light, Media Player, Remote, Sensor, Switch). - -```python -# Works automatically - device sends update, entities get updated -device.events.emit(DeviceEvents.UPDATE, entity_id, { +# Legacy device emitting attribute dicts (still supported) +self.events.emit(DeviceEvents.UPDATE, entity_id, { "state": "PLAYING", "volume": 50, - "media_title": "Song Name" }) -# Framework automatically updates the configured entity attributes ``` -**Special feature for media players**: When state is `OFF`, all media attributes (title, artist, duration, etc.) are automatically cleared. Control this with the `clear_media_when_off` parameter. +With the coordinator pattern, `on_device_update` is not called for entities that override `sync_state()` — those entities handle their own updates via `push_update()`. -**Override only if** you need custom state mapping or attribute transformation: +**Override when** you need custom attribute transformation: ```python async def on_device_update( - self, device_id: str, update: dict[str, Any] | None + self, entity_id: str | None = None, update: dict | None = None ) -> None: - """Custom update handling with state transformation.""" - if update: - # Transform device-specific values before calling default - if "power_state" in update: - update["state"] = "ON" if update["power_state"] else "OFF" - - await super().on_device_update(device_id, update) + if update and "power_state" in update: + update["state"] = "ON" if update["power_state"] else "OFF" + await super().on_device_update(entity_id, update) ``` ## Event Handlers ### Device Events -Override these to customize device event handling: +Override these to add custom logic around device lifecycle events: ```python async def on_device_connected(self, device_id: str) -> None: - """Device connected.""" await super().on_device_connected(device_id) - # Custom logic... + _LOG.info("Device %s is now online", device_id) async def on_device_disconnected(self, device_id: str) -> None: - """Device disconnected.""" await super().on_device_disconnected(device_id) - # Custom logic... + # Mark entities unavailable + for entity in self._get_framework_entities_for_device(device_id): + entity.set_unavailable() -async def on_device_connection_error( - self, device_id: str, message: str -) -> None: - """Device connection error.""" +async def on_device_connection_error(self, device_id: str, message: str) -> None: await super().on_device_connection_error(device_id, message) - # Custom logic... - -async def on_device_update( - self, device_id: str, update: dict[str, Any] | None, - clear_media_when_off: bool = True -) -> None: - """Device state update.""" - # Default implementation handles all entity types - await super().on_device_update(device_id, update, clear_media_when_off) + _LOG.error("Device %s error: %s", device_id, message) ``` ### Remote Events @@ -314,153 +223,119 @@ Override these to customize Remote Two event handling: ```python async def on_r2_connect_cmd(self) -> None: - """Remote connected.""" - # Custom pre-connect logic... await super().on_r2_connect_cmd() async def on_r2_disconnect_cmd(self) -> None: - """Remote disconnected.""" await super().on_r2_disconnect_cmd() - # Custom cleanup... async def on_r2_enter_standby(self) -> None: - """Remote entering standby.""" await super().on_r2_enter_standby() - # Save power... async def on_r2_exit_standby(self) -> None: - """Remote exiting standby.""" await super().on_r2_exit_standby() - # Wake devices... ``` -## Hub-Based Integrations +## Entity ID Helpers -For integrations where entities are discovered dynamically from a hub device (like smart home bridges or multi-zone receivers), use the `require_connection_before_registry` flag: +Use `create_entity_id()` to build consistent entity IDs: ```python -class MyHubDriver(BaseIntegrationDriver[MyHub, MyHubConfig]): - def __init__(self): - super().__init__( - device_class=MyHub, - entity_classes=[EntityTypes.LIGHT, EntityTypes.SWITCH], - require_connection_before_registry=True # Enable hub mode - ) -``` - -### How It Works - -When `require_connection_before_registry=True`: - -1. **Device Addition**: Uses `async_add_configured_device()` which connects first, then registers entities -2. **Entity Subscription**: Waits for connection before calling `async_register_available_entities()` -3. **Entity Discovery**: Entities are populated from the hub after connection - -### Required Override +from ucapi_framework import create_entity_id +from ucapi import EntityTypes -You must override `async_register_available_entities()` to populate entities from the hub: +# Simple: "media_player.receiver_123" +entity_id = create_entity_id(EntityTypes.MEDIA_PLAYER, "receiver_123") -```python -async def async_register_available_entities( - self, device_config: MyHubConfig, device: MyHub -) -> None: - """Register entities discovered from the hub.""" - # Get entities from connected hub - hub_devices = await device.get_discovered_devices() - - for hub_device in hub_devices: - entity_id = create_entity_id( - EntityTypes.LIGHT, - device_config.identifier, - hub_device.id # Sub-entity ID - ) - entity = Light( - entity_id, - hub_device.name, - features=[light.Features.ON_OFF, light.Features.DIM] - ) - self.api.available_entities.add(entity) +# With sub-device: "light.hub_1.bedroom" +entity_id = create_entity_id(EntityTypes.LIGHT, "hub_1", "bedroom") ``` -### Entity ID Helpers for Hubs - -Use the 3-part entity ID format for hub devices: +And parse them back using the driver methods: ```python -# Create entity ID with sub-device -entity_id = create_entity_id(EntityTypes.LIGHT, "hub_1", "bedroom_light") -# Result: "light.hub_1.bedroom_light" - -# Parse it back -device_id = driver.device_from_entity_id(entity_id) # "hub_1" -entity_type = driver.entity_type_from_entity_id(entity_id) # "light" -sub_device = driver.sub_device_from_entity_id(entity_id) # "bedroom_light" +driver.entity_type_from_entity_id("light.hub_1.bedroom") # "light" +driver.device_from_entity_id("light.hub_1.bedroom") # "hub_1" +driver.sub_device_from_entity_id("light.hub_1.bedroom") # "bedroom" ``` -## Minimal Example +## Complete Example -Most drivers work with just the defaults: +A full coordinator-pattern driver for a hub device: ```python -from ucapi_framework import BaseIntegrationDriver +from ucapi_framework import BaseIntegrationDriver, create_entity_id from ucapi import EntityTypes +import logging -class MyDriver(BaseIntegrationDriver[MyDevice, MyDeviceConfig]): - """Simple integration driver - uses all defaults.""" - +_LOG = logging.getLogger(__name__) + +class SmartHubDriver(BaseIntegrationDriver[SmartHub, SmartHubConfig]): def __init__(self): super().__init__( - device_class=MyDevice, - entity_classes=EntityTypes.MEDIA_PLAYER, # Or list of types + device_class=SmartHub, + entity_classes=[ + # Factory lambdas — called after device connects and populates its lists + lambda cfg, dev: [HubLight(cfg, light, dev) for light in dev.lights], + lambda cfg, dev: [HubCover(cfg, cover, dev) for cover in dev.covers], + ], + require_connection_before_registry=True, ) - - # That's it! The framework handles: - # ✅ Entity creation (one per entity_class) - # ✅ State mapping (common state strings) - # ✅ Entity ID parsing (standard format) - # ✅ Device updates (all entity types) - # ✅ Event propagation -``` -## Custom Example + async def on_device_connected(self, device_id: str) -> None: + await super().on_device_connected(device_id) + _LOG.info("Hub %s connected with %d devices", device_id, + len(self._device_instances.get(device_id).lights or [])) + + async def on_device_disconnected(self, device_id: str) -> None: + await super().on_device_disconnected(device_id) + for entity in self._get_framework_entities_for_device(device_id): + entity.set_unavailable() +``` -Override only what you need: +And the corresponding entity (one per hub device): ```python -from ucapi_framework import BaseIntegrationDriver -from ucapi import EntityTypes, media_player +from ucapi import light +from ucapi_framework import Entity, create_entity_id, EntityTypes + +class HubLight(light.Light, Entity): + def __init__(self, hub_config, light_info, hub): + self._hub = hub + self._light_id = light_info.id + entity_id = create_entity_id(EntityTypes.LIGHT, hub_config.identifier, light_info.id) -class MyDriver(BaseIntegrationDriver[MyDevice, MyDeviceConfig]): - """Custom driver with specific requirements.""" - - def __init__(self): super().__init__( - device_class=MyDevice, - entity_classes=[ - EntityTypes.MEDIA_PLAYER, - EntityTypes.REMOTE, - ] + entity_id, + light_info.name, + features=[light.Features.ON_OFF, light.Features.DIM], + attributes={ + light.Attributes.STATE: light.States.UNKNOWN, + light.Attributes.BRIGHTNESS: 0, + }, + cmd_handler=self.handle_command, ) - - def map_device_state(self, device_state: Any) -> media_player.States: - """Custom state mapping for device-specific enums.""" - if isinstance(device_state, MyDeviceState): - match device_state: - case MyDeviceState.POWERED_ON: - return media_player.States.ON - case MyDeviceState.POWERED_OFF: - return media_player.States.OFF - case MyDeviceState.STREAMING: - return media_player.States.PLAYING - case _: - return super().map_device_state(device_state) - return super().map_device_state(device_state) - - async def on_device_connected(self, device_id: str) -> None: - """Custom logic when device connects.""" - await super().on_device_connected(device_id) - # Send notification or update UI - _LOG.info(f"Device {device_id} is now online!") + + # Subscribe to hub — sync_state() fires on every hub.push_update() + self.subscribe_to_device(hub) + + async def sync_state(self) -> None: + """Read this light's state from the hub and push to Remote.""" + light_state = self._hub.get_light(self._light_id) + if light_state is None: + return + self.update({ + light.Attributes.STATE: light.States.ON if light_state.on else light.States.OFF, + light.Attributes.BRIGHTNESS: light_state.brightness, + }) + + async def handle_command(self, entity, cmd_id, params): + match cmd_id: + case light.Commands.ON: + await self._hub.set_light(self._light_id, on=True) + case light.Commands.OFF: + await self._hub.set_light(self._light_id, on=False) + case light.Commands.BRIGHTNESS: + await self._hub.set_light(self._light_id, brightness=params["brightness"]) ``` See the [API Reference](../api/driver.md) for complete documentation of all methods and event handlers. From b0099f673bca8b71bff9f8decc1b84ea1fddbf6a Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sun, 8 Mar 2026 11:52:40 -0400 Subject: [PATCH 10/13] tests: Improve media_player coverage --- tests/test_media_player_entity.py | 471 ++++++++++++++++++++++++------ 1 file changed, 375 insertions(+), 96 deletions(-) diff --git a/tests/test_media_player_entity.py b/tests/test_media_player_entity.py index 818dd18..50d8e85 100644 --- a/tests/test_media_player_entity.py +++ b/tests/test_media_player_entity.py @@ -1,9 +1,36 @@ """Tests for MediaPlayerEntity with built-in state management.""" +import asyncio import pytest -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from ucapi import media_player from ucapi_framework import MediaPlayerEntity +from ucapi_framework.device import BaseDeviceInterface, DeviceEvents + + +def _make_api(initial_state=media_player.States.UNKNOWN): + """Return a mock API whose configured_entities.contains returns True.""" + api = MagicMock() + api.configured_entities.contains.return_value = True + api.configured_entities.get.return_value = MagicMock( + attributes={media_player.Attributes.STATE: initial_state} + ) + return api + + +def _make_entity(mock_api=None, extra_attrs=None): + """Create a MediaPlayerEntity wired to a mock API.""" + attrs = {media_player.Attributes.STATE: media_player.States.UNKNOWN} + if extra_attrs: + attrs.update(extra_attrs) + entity = MediaPlayerEntity( + "media_player.test", + "Test Player", + features=[media_player.Features.ON_OFF, media_player.Features.VOLUME], + attributes=attrs, + ) + entity._api = mock_api or _make_api() # noqa: SLF001 + return entity class TestMediaPlayerEntity: @@ -11,96 +38,183 @@ class TestMediaPlayerEntity: @pytest.fixture def mock_api(self): - """Create a mock API for testing.""" - api = MagicMock() - api.configured_entities.get.return_value = MagicMock( - attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN} - ) - return api + return _make_api() @pytest.fixture def entity(self, mock_api): - """Create a MediaPlayerEntity for testing.""" - entity = MediaPlayerEntity( - "media_player.test", - "Test Player", - features=[media_player.Features.ON_OFF, media_player.Features.VOLUME], - attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN}, - ) - entity._api = mock_api # noqa: SLF001 - return entity + return _make_entity(mock_api) + + # ------------------------------------------------------------------ + # Property getters — all attributes + # ------------------------------------------------------------------ def test_initial_state(self, entity): - """Test initial state from constructor attributes.""" - # State was set to UNKNOWN in constructor + """All unset properties return None; initial state is UNKNOWN.""" assert entity.state == media_player.States.UNKNOWN - # These were not set, so should be None assert entity.volume is None assert entity.muted is None + assert entity.media_duration is None + assert entity.media_position is None + assert entity.media_position_updated_at is None + assert entity.media_type is None + assert entity.media_image_url is None + assert entity.media_title is None + assert entity.media_artist is None + assert entity.media_album is None + assert entity.repeat is None + assert entity.shuffle is None + assert entity.source is None + assert entity.source_list is None + assert entity.sound_mode is None + assert entity.sound_mode_list is None + + def test_property_getters_are_read_only(self, entity): + """Property setters must not exist — direct assignment raises AttributeError.""" + with pytest.raises(AttributeError): + entity.state = media_player.States.PLAYING # type: ignore[misc] + + # ------------------------------------------------------------------ + # set_state + # ------------------------------------------------------------------ def test_set_state_with_update(self, entity, mock_api): - """Test set_state() calls entity.update() by default.""" + """set_state(update=True) pushes STATE to Remote.""" entity.set_state(media_player.States.PLAYING, update=True) - # Verify internal state was updated assert entity.state == media_player.States.PLAYING - - # Verify update was called assert mock_api.configured_entities.update_attributes.called call_args = mock_api.configured_entities.update_attributes.call_args entity_id, attributes = call_args[0] assert entity_id == "media_player.test" - assert media_player.Attributes.STATE in attributes assert attributes[media_player.Attributes.STATE] == media_player.States.PLAYING def test_set_state_without_update(self, entity, mock_api): - """Test set_state(update=False) does not call entity.update().""" + """set_state(update=False) updates local state but does not push.""" entity.set_state(media_player.States.PLAYING, update=False) - - # Verify internal state was updated assert entity.state == media_player.States.PLAYING - - # Verify update was NOT called assert not mock_api.configured_entities.update_attributes.called - def test_set_volume(self, entity, mock_api): - """Test set_volume() updates state and calls update.""" - entity.set_volume(75, update=True) + def test_set_state_all_states(self, entity): + """set_state accepts every States enum value.""" + for state in media_player.States: + entity.set_state(state, update=False) + assert entity.state == state + + def test_set_state_none(self, entity, mock_api): + """set_state(None) clears the state property.""" + entity.set_state(None, update=False) + assert entity.state is None + + # ------------------------------------------------------------------ + # Individual setters — update=True and update=False paths + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("value,update", [(75, True), (0, False), (100, True)]) + def test_set_volume(self, entity, mock_api, value, update): + entity.set_volume(value, update=update) + assert entity.volume == value + assert mock_api.configured_entities.update_attributes.called == update + + @pytest.mark.parametrize( + "value,update", [(True, True), (False, True), (True, False)] + ) + def test_set_muted(self, entity, mock_api, value, update): + entity.set_muted(value, update=update) + assert entity.muted is value + assert mock_api.configured_entities.update_attributes.called == update + + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_duration(self, entity, mock_api, update): + entity.set_media_duration(300, update=update) + assert entity.media_duration == 300 + assert mock_api.configured_entities.update_attributes.called == update - assert entity.volume == 75 - assert mock_api.configured_entities.update_attributes.called + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_position(self, entity, mock_api, update): + entity.set_media_position(120, update=update) + assert entity.media_position == 120 + assert mock_api.configured_entities.update_attributes.called == update - def test_set_muted(self, entity, mock_api): - """Test set_muted() updates state and calls update.""" - entity.set_muted(True, update=True) + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_position_updated_at(self, entity, mock_api, update): + entity.set_media_position_updated_at("2025-01-01T12:00:00Z", update=update) + assert entity.media_position_updated_at == "2025-01-01T12:00:00Z" + assert mock_api.configured_entities.update_attributes.called == update - assert entity.muted is True - assert mock_api.configured_entities.update_attributes.called + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_type(self, entity, mock_api, update): + entity.set_media_type("music", update=update) + assert entity.media_type == "music" + assert mock_api.configured_entities.update_attributes.called == update - def test_set_media_title(self, entity, mock_api): - """Test set_media_title() updates state and calls update.""" - entity.set_media_title("Test Song", update=True) + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_image_url(self, entity, mock_api, update): + entity.set_media_image_url("https://example.com/art.jpg", update=update) + assert entity.media_image_url == "https://example.com/art.jpg" + assert mock_api.configured_entities.update_attributes.called == update + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_title(self, entity, mock_api, update): + entity.set_media_title("Test Song", update=update) assert entity.media_title == "Test Song" - assert mock_api.configured_entities.update_attributes.called - - def test_set_media_artist(self, entity, mock_api): - """Test set_media_artist() updates state and calls update.""" - entity.set_media_artist("Test Artist", update=True) + assert mock_api.configured_entities.update_attributes.called == update + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_artist(self, entity, mock_api, update): + entity.set_media_artist("Test Artist", update=update) assert entity.media_artist == "Test Artist" - assert mock_api.configured_entities.update_attributes.called + assert mock_api.configured_entities.update_attributes.called == update - def test_set_source_list(self, entity, mock_api): - """Test set_source_list() updates state and calls update.""" - sources = ["HDMI 1", "HDMI 2", "Bluetooth"] - entity.set_source_list(sources, update=True) + @pytest.mark.parametrize("update", [True, False]) + def test_set_media_album(self, entity, mock_api, update): + entity.set_media_album("Test Album", update=update) + assert entity.media_album == "Test Album" + assert mock_api.configured_entities.update_attributes.called == update + @pytest.mark.parametrize("update", [True, False]) + def test_set_repeat(self, entity, mock_api, update): + entity.set_repeat(media_player.RepeatMode.ALL, update=update) + assert entity.repeat == media_player.RepeatMode.ALL + assert mock_api.configured_entities.update_attributes.called == update + + @pytest.mark.parametrize("value,update", [(True, True), (False, False)]) + def test_set_shuffle(self, entity, mock_api, value, update): + entity.set_shuffle(value, update=update) + assert entity.shuffle is value + assert mock_api.configured_entities.update_attributes.called == update + + @pytest.mark.parametrize("update", [True, False]) + def test_set_source(self, entity, mock_api, update): + entity.set_source("Spotify", update=update) + assert entity.source == "Spotify" + assert mock_api.configured_entities.update_attributes.called == update + + @pytest.mark.parametrize("update", [True, False]) + def test_set_source_list(self, entity, mock_api, update): + sources = ["HDMI 1", "HDMI 2", "Bluetooth"] + entity.set_source_list(sources, update=update) assert entity.source_list == sources - assert mock_api.configured_entities.update_attributes.called + assert mock_api.configured_entities.update_attributes.called == update + + @pytest.mark.parametrize("update", [True, False]) + def test_set_sound_mode(self, entity, mock_api, update): + entity.set_sound_mode("Stereo", update=update) + assert entity.sound_mode == "Stereo" + assert mock_api.configured_entities.update_attributes.called == update + + @pytest.mark.parametrize("update", [True, False]) + def test_set_sound_mode_list(self, entity, mock_api, update): + modes = ["Stereo", "Surround", "Night"] + entity.set_sound_mode_list(modes, update=update) + assert entity.sound_mode_list == modes + assert mock_api.configured_entities.update_attributes.called == update + + # ------------------------------------------------------------------ + # set_attributes bulk helper + # ------------------------------------------------------------------ def test_set_attributes_bulk_update(self, entity, mock_api): - """Test set_attributes() updates multiple attributes with single update call.""" + """set_attributes() batches multiple attributes into a single Remote push.""" entity.set_attributes( state=media_player.States.PLAYING, volume=50, @@ -110,71 +224,48 @@ def test_set_attributes_bulk_update(self, entity, mock_api): update=True, ) - # Verify all internal state was updated assert entity.state == media_player.States.PLAYING assert entity.volume == 50 assert entity.muted is False assert entity.media_title == "Song Title" assert entity.media_artist == "Artist Name" - # Verify update was called only once + # Exactly one Remote push for all changes assert mock_api.configured_entities.update_attributes.call_count == 1 - # Verify all attributes were included in the update - call_args = mock_api.configured_entities.update_attributes.call_args - entity_id, attributes = call_args[0] - assert entity_id == "media_player.test" - assert len(attributes) == 5 - assert attributes[media_player.Attributes.STATE] == media_player.States.PLAYING - assert attributes[media_player.Attributes.VOLUME] == 50 - assert attributes[media_player.Attributes.MUTED] is False - assert attributes[media_player.Attributes.MEDIA_TITLE] == "Song Title" - assert attributes[media_player.Attributes.MEDIA_ARTIST] == "Artist Name" - def test_set_attributes_without_update(self, entity, mock_api): - """Test set_attributes(update=False) does not call entity.update().""" + """set_attributes(update=False) updates local state but does not push.""" entity.set_attributes( state=media_player.States.PLAYING, volume=50, update=False ) - - # Verify internal state was updated assert entity.state == media_player.States.PLAYING assert entity.volume == 50 - - # Verify update was NOT called assert not mock_api.configured_entities.update_attributes.called def test_set_attributes_ignores_none_values(self, entity, mock_api): - """Test set_attributes() ignores None values.""" + """set_attributes() skips None kwargs — they don't overwrite existing values.""" entity.set_attributes( state=media_player.States.PLAYING, volume=None, update=True ) - - # Only state should be in internal storage assert entity.state == media_player.States.PLAYING assert entity.volume is None - - # Verify only state was included in update + # Only STATE should have been pushed call_args = mock_api.configured_entities.update_attributes.call_args - entity_id, attributes = call_args[0] + _, attributes = call_args[0] assert len(attributes) == 1 assert media_player.Attributes.STATE in attributes - def test_property_getters_are_read_only(self, entity): - """Test that property getters cannot be set directly.""" - # This should raise AttributeError - with pytest.raises(AttributeError): - entity.state = media_player.States.PLAYING # type: ignore[misc] - - def test_all_media_attributes(self, entity, mock_api): - """Test setting all media-related attributes.""" + def test_set_attributes_all_fields(self, entity, mock_api): + """set_attributes() covers every supported attribute in one call.""" entity.set_attributes( state=media_player.States.PLAYING, volume=75, muted=False, media_duration=300, media_position=120, + media_position_updated_at="2025-01-01T12:00:00Z", media_type="music", + media_image_url="https://example.com/art.jpg", media_title="Test Song", media_artist="Test Artist", media_album="Test Album", @@ -187,13 +278,14 @@ def test_all_media_attributes(self, entity, mock_api): update=True, ) - # Verify all attributes assert entity.state == media_player.States.PLAYING assert entity.volume == 75 assert entity.muted is False assert entity.media_duration == 300 assert entity.media_position == 120 + assert entity.media_position_updated_at == "2025-01-01T12:00:00Z" assert entity.media_type == "music" + assert entity.media_image_url == "https://example.com/art.jpg" assert entity.media_title == "Test Song" assert entity.media_artist == "Test Artist" assert entity.media_album == "Test Album" @@ -204,15 +296,149 @@ def test_all_media_attributes(self, entity, mock_api): assert entity.sound_mode == "Stereo" assert entity.sound_mode_list == ["Stereo", "Surround"] - # Verify single update call + # All in a single Remote push assert mock_api.configured_entities.update_attributes.call_count == 1 + # ------------------------------------------------------------------ + # set_unavailable + # ------------------------------------------------------------------ + + def test_set_unavailable(self, entity, mock_api): + """set_unavailable() pushes STATE=UNAVAILABLE to the Remote.""" + entity.set_unavailable() + + assert mock_api.configured_entities.update_attributes.called + call_args = mock_api.configured_entities.update_attributes.call_args + entity_id, attributes = call_args[0] + assert entity_id == "media_player.test" + assert ( + attributes[media_player.Attributes.STATE] == media_player.States.UNAVAILABLE + ) + + def test_set_unavailable_does_not_change_other_state(self, entity, mock_api): + """set_unavailable() only marks STATE; volume and title are not cleared.""" + entity.set_volume(50, update=False) + entity.set_media_title("Track", update=False) + mock_api.configured_entities.update_attributes.reset_mock() + + entity.set_unavailable() + + # Local attributes still hold previous values + assert entity.volume == 50 + assert entity.media_title == "Track" + # But only UNAVAILABLE state was pushed + call_args = mock_api.configured_entities.update_attributes.call_args + _, attributes = call_args[0] + assert attributes == { + media_player.Attributes.STATE: media_player.States.UNAVAILABLE + } + + +class TestMediaPlayerEntityCoordinator: + """Test coordinator pattern — sync_state, subscribe_to_device.""" + + def _make_device(self): + """Return a minimal mock device backed by a real AsyncIOEventEmitter.""" + from pyee.asyncio import AsyncIOEventEmitter + + device = MagicMock(spec=BaseDeviceInterface) + device.events = AsyncIOEventEmitter() + device.power = True + device.volume = 42 + device.muted = False + device.title = "Coordinator Track" + return device + + @pytest.mark.asyncio + async def test_sync_state_called_on_push_update(self): + """subscribe_to_device() wires sync_state() to fire on every push_update().""" + device = self._make_device() + api = _make_api() + sync_calls = [] + + class TrackingPlayer(MediaPlayerEntity): + def __init__(self): + super().__init__( + "media_player.coord", + "Coord Player", + features=[media_player.Features.ON_OFF], + attributes={ + media_player.Attributes.STATE: media_player.States.UNKNOWN + }, + ) + self._device = device + self.subscribe_to_device(device) + + async def sync_state(self): + sync_calls.append(True) + self.set_state( + media_player.States.ON + if self._device.power + else media_player.States.OFF, + update=False, + ) + + entity = TrackingPlayer() + entity._api = api # noqa: SLF001 + + assert len(sync_calls) == 0 + + # Simulate what push_update() does: emit UPDATE with no args + device.events.emit(DeviceEvents.UPDATE) + await asyncio.sleep(0) # flush the async handler scheduled by pyee + + assert len(sync_calls) == 1 + assert entity.state == media_player.States.ON + + @pytest.mark.asyncio + async def test_sync_state_multiple_pushes(self): + """sync_state() is called once per push_update() emission.""" + device = self._make_device() + api = _make_api() + sync_calls = [] + + class CountingPlayer(MediaPlayerEntity): + def __init__(self): + super().__init__( + "media_player.counting", + "Counting Player", + features=[], + attributes={ + media_player.Attributes.STATE: media_player.States.UNKNOWN + }, + ) + self._dev = device + self.subscribe_to_device(device) + + async def sync_state(self): + sync_calls.append(self._dev.volume) + + entity = CountingPlayer() + entity._api = api # noqa: SLF001 + + for vol in [10, 20, 30]: + device.volume = vol + device.events.emit(DeviceEvents.UPDATE) + await asyncio.sleep(0) # flush handler before changing volume again + + assert sync_calls == [10, 20, 30] + + @pytest.mark.asyncio + async def test_default_sync_state_is_noop(self): + """The base MediaPlayerEntity.sync_state() is a no-op coroutine.""" + import inspect + + entity = _make_entity() + result = entity.sync_state() + assert inspect.iscoroutine(result) + await result # must be awaitable without raising + class TestMediaPlayerEntityInheritance: """Test that MediaPlayerEntity can be subclassed and overridden.""" def test_custom_set_state(self): - """Test that set_state can be overridden.""" + """set_state can be overridden to inject custom logic.""" class CustomMediaPlayer(MediaPlayerEntity): def __init__(self): @@ -224,21 +450,19 @@ def __init__(self): ) self.custom_set_state_called = False - def set_state(self, value, *, update=True): - """Override set_state to add custom logic.""" + def set_state(self, value, *, update=False): self.custom_set_state_called = True super().set_state(value, update=update) entity = CustomMediaPlayer() - entity._api = MagicMock() # noqa: SLF001 - entity._api.configured_entities.get.return_value = entity # noqa: SLF001 + entity._api = _make_api() # noqa: SLF001 entity.set_state(media_player.States.PLAYING, update=False) assert entity.custom_set_state_called is True assert entity.state == media_player.States.PLAYING def test_custom_property_getter(self): - """Test that property getters can be overridden.""" + """Property getters can be overridden to return computed values.""" class CustomMediaPlayer(MediaPlayerEntity): def __init__(self): @@ -251,9 +475,64 @@ def __init__(self): @property def state(self): - """Override state getter to always return PLAYING.""" return media_player.States.PLAYING entity = CustomMediaPlayer() - # Even if internal state is None, getter returns PLAYING assert entity.state == media_player.States.PLAYING + + def test_map_entity_states_default(self): + """map_entity_states() maps common strings to media_player.States.""" + entity = _make_entity() + assert entity.map_entity_states("PLAYING") == media_player.States.PLAYING + assert entity.map_entity_states("PAUSED") == media_player.States.PAUSED + assert entity.map_entity_states("OFF") == media_player.States.OFF + assert entity.map_entity_states("ON") == media_player.States.ON + assert entity.map_entity_states("STANDBY") == media_player.States.STANDBY + assert entity.map_entity_states("BUFFERING") == media_player.States.BUFFERING + assert entity.map_entity_states("UNKNOWN_STATE") == media_player.States.UNKNOWN + assert entity.map_entity_states(None) == media_player.States.UNKNOWN + + def test_map_entity_states_passthrough(self): + """map_entity_states() passes through existing States enum values unchanged.""" + entity = _make_entity() + assert ( + entity.map_entity_states(media_player.States.PLAYING) + == media_player.States.PLAYING + ) + assert ( + entity.map_entity_states(media_player.States.UNAVAILABLE) + == media_player.States.UNAVAILABLE + ) + + def test_map_entity_states_override(self): + """map_entity_states() can be overridden for device-specific state enums.""" + from enum import Enum + + class DeviceState(Enum): + POWERED_ON = "powered_on" + POWERED_OFF = "powered_off" + + class CustomPlayer(MediaPlayerEntity): + def __init__(self): + super().__init__( + "media_player.custom", "Custom", features=[], attributes={} + ) + + def map_entity_states(self, device_state): + if isinstance(device_state, DeviceState): + return ( + media_player.States.ON + if device_state == DeviceState.POWERED_ON + else media_player.States.OFF + ) + return super().map_entity_states(device_state) + + entity = CustomPlayer() + assert ( + entity.map_entity_states(DeviceState.POWERED_ON) == media_player.States.ON + ) + assert ( + entity.map_entity_states(DeviceState.POWERED_OFF) == media_player.States.OFF + ) + # Falls through to default for unknown types + assert entity.map_entity_states("PLAYING") == media_player.States.PLAYING From cffa91443e066062d5224d0faa20b91300d0dd47 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sun, 8 Mar 2026 13:06:52 -0400 Subject: [PATCH 11/13] tests: Improve media_player coverage --- tests/test_media_player_entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_media_player_entity.py b/tests/test_media_player_entity.py index 50d8e85..464474d 100644 --- a/tests/test_media_player_entity.py +++ b/tests/test_media_player_entity.py @@ -2,7 +2,7 @@ import asyncio import pytest -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock from ucapi import media_player from ucapi_framework import MediaPlayerEntity from ucapi_framework.device import BaseDeviceInterface, DeviceEvents From 17ef24dd63e0178a01c2aacb71b812ca343e6ea7 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sun, 8 Mar 2026 13:12:49 -0400 Subject: [PATCH 12/13] chore: Update readme --- README.md | 45 +++++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 9276a00..8abfa22 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Tests](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml/badge.svg)](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml) [![Discord](https://badgen.net/discord/online-members/zGVYf58)](https://discord.gg/zGVYf58) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me_A_Coffee ☕-FFDD00?logo=buy-me-a-coffee&logoColor=white&labelColor=grey)](https://buymeacoffee.com/jackpowell) +[![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?logo=buy-me-a-coffee&logoColor=white&labelColor=grey)](https://buymeacoffee.com/jackpowell) # UCAPI Framework @@ -76,12 +76,12 @@ The driver coordinates everything - device lifecycle, entity management, and Rem The framework provides sensible defaults for: -- **`create_entities()`** - Creates one entity per entity type automatically -- **`map_device_state()`** - Maps common state strings (ON, OFF, PLAYING, etc.) -- **`device_from_entity_id()`** - Parses standard entity ID format +- **`create_entities()`** - Instantiates entities from the `entity_classes` list passed to the constructor; each item can be a class or a factory lambda `(config, device) -> Entity | list[Entity]` +- **`map_device_state()`** - Maps common state strings (ON, OFF, PLAYING, etc.) to `media_player.States` +- **`device_from_entity_id()`** - Parses the standard `entity_type.device_id` format - **`get_entity_ids_for_device()`** - Queries and filters entities by device -**Override only what you need**: Custom state enums? Override `map_device_state()`. Conditional entity creation? Override `create_entities()`. Custom entity ID format? Override `device_from_entity_id()` too. +**Override only what you need**: Custom state enums? Override `map_device_state()`. Logic that can't fit in a lambda? Override `create_entities()`. Custom entity ID format? Override `device_from_entity_id()`. Everything else is automatic. The framework handles Remote connection events (connect, disconnect, standby), entity subscriptions, device lifecycle management, and state synchronization. @@ -109,13 +109,13 @@ All discovery classes handle the protocol details, timeouts, and error handling. The driver base class automatically wires up Remote events (connect, disconnect, standby, subscribe/unsubscribe) with sensible defaults. You can override any of them, but the defaults handle most cases. -Device events (state changes, errors) automatically propagate to entity state updates. You just emit events from your device and the framework keeps the Remote in sync. +State flows from device to Remote via the coordinator pattern: the device stores raw state properties and calls `push_update()` when anything changes. Every entity subscribed via `subscribe_to_device()` has its `sync_state()` method called automatically, which reads from the device and calls `self.update({...})` with a fresh dict. The framework diffs against the last-pushed state and sends only changed attributes to the Remote. ## How It Works You inherit from base classes and override only what you need: -**Driver** - Usually works with defaults! Override only if you need custom state mapping or conditional entity creation. +**Driver** - Usually works with defaults. Pass `entity_classes` to the constructor. Override only if you need custom state mapping or conditional entity creation. **Device** - Implement your connection pattern (verify, poll, handle messages, etc.). @@ -166,34 +166,22 @@ Optional discovery implementations for common protocols: Lazy imports mean you only need the dependencies if you use them. -## Real-World Example - -See the PSN integration in this repository: - -- `intg-psn/driver.py` - 90 lines (was 300) -- `intg-psn/psn.py` - 140 lines (was 240) -- `intg-psn/setup_flow.py` - 50 lines (was 250) -- `intg-psn/config.py` - 15 lines (was 95) - -Total: ~295 lines of integration code vs ~885 lines previously. And the new code is type-safe, testable, and maintainable. ## Migration -If you have an existing integration, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for step-by-step instructions with before/after examples. - -For upgrading from a previous version of ucapi-framework: -- **[Upgrading to 1.6.0](https://jackjpowell.github.io/ucapi-framework/upgrade-to-1.6.0/)** - New dynamic entity management features +If you have an existing integration, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for step-by-step instructions with before/after examples covering the coordinator pattern, `subscribe_to_device`, `sync_state`, and `push_update`. ## Requirements - Python 3.11+ - ucapi - pyee +- aiohttp (required; used by HTTP and WebSocket device base classes) + +Optional (only if you use the corresponding discovery classes): -Optional (only if you use them): -- aiohttp (for HTTP devices) -- websockets (for WebSocket devices) - ssdpy (for SSDP discovery) +- sddp-discovery-protocol (for SDDP discovery) - zeroconf (for mDNS discovery) ## Documentation @@ -221,9 +209,14 @@ Visit to view the docs. uv sync --group dev ``` -Git hooks are automatically active from the `git-hooks/` directory: +A `pre-commit` hook is available in the `git-hooks/` directory. To activate it: + +```bash +cp git-hooks/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` -- **pre-commit**: Runs `ruff check --fix` and `ruff format` via `uv run` +The hook runs `ruff check --fix` and `ruff format` before every commit. All development tools run through `uv` and are configured in `pyproject.toml`. From 7f2f977b7da46733295d53ce659cf0a59ad88731 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sun, 8 Mar 2026 13:13:28 -0400 Subject: [PATCH 13/13] chore: Bump version to 1.9.0 --- pyproject.toml | 2 +- ucapi_framework/__init__.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a53a753..050c43e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ucapi-framework" -version = "1.9.0b4" +version = "1.9.0" description = "ucapi framework that provides core functionality for building integrations." readme = "README.md" requires-python = ">=3.11" diff --git a/ucapi_framework/__init__.py b/ucapi_framework/__init__.py index f37e3f0..bf22e18 100644 --- a/ucapi_framework/__init__.py +++ b/ucapi_framework/__init__.py @@ -128,4 +128,4 @@ "VoiceAssistantEntity", ] -__version__ = "1.8.4" +__version__ = "1.9.0" diff --git a/uv.lock b/uv.lock index a6440ef..191e02b 100644 --- a/uv.lock +++ b/uv.lock @@ -1314,7 +1314,7 @@ wheels = [ [[package]] name = "ucapi-framework" -version = "1.9.0b4" +version = "1.9.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" },