ucapi-framework 1.8.4__tar.gz → 1.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/PKG-INFO +20 -27
  2. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/README.md +19 -26
  3. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/pyproject.toml +1 -1
  4. ucapi_framework-1.9.0/tests/test_button_entity.py +154 -0
  5. ucapi_framework-1.9.0/tests/test_climate_entity.py +230 -0
  6. ucapi_framework-1.9.0/tests/test_cover_entity.py +228 -0
  7. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_driver.py +115 -0
  8. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_entity.py +105 -1
  9. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_helpers.py +13 -5
  10. ucapi_framework-1.9.0/tests/test_ir_emitter_entity.py +120 -0
  11. ucapi_framework-1.9.0/tests/test_light_entity.py +234 -0
  12. ucapi_framework-1.9.0/tests/test_media_player_entity.py +538 -0
  13. ucapi_framework-1.9.0/tests/test_remote_entity.py +125 -0
  14. ucapi_framework-1.9.0/tests/test_select_entity.py +195 -0
  15. ucapi_framework-1.9.0/tests/test_sensor_entity.py +206 -0
  16. ucapi_framework-1.9.0/tests/test_switch_entity.py +128 -0
  17. ucapi_framework-1.9.0/tests/test_voice_assistant_entity.py +128 -0
  18. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/__init__.py +25 -1
  19. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/device.py +20 -0
  20. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/driver.py +108 -35
  21. ucapi_framework-1.9.0/ucapi_framework/entities/__init__.py +35 -0
  22. ucapi_framework-1.9.0/ucapi_framework/entities/button.py +89 -0
  23. ucapi_framework-1.9.0/ucapi_framework/entities/climate.py +236 -0
  24. ucapi_framework-1.9.0/ucapi_framework/entities/cover.py +161 -0
  25. ucapi_framework-1.9.0/ucapi_framework/entities/ir_emitter.py +89 -0
  26. ucapi_framework-1.9.0/ucapi_framework/entities/light.py +201 -0
  27. ucapi_framework-1.9.0/ucapi_framework/entities/media_player.py +488 -0
  28. ucapi_framework-1.9.0/ucapi_framework/entities/remote.py +95 -0
  29. ucapi_framework-1.9.0/ucapi_framework/entities/select.py +165 -0
  30. ucapi_framework-1.9.0/ucapi_framework/entities/sensor.py +155 -0
  31. ucapi_framework-1.9.0/ucapi_framework/entities/switch.py +99 -0
  32. ucapi_framework-1.9.0/ucapi_framework/entities/voice_assistant.py +93 -0
  33. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/entity.py +105 -1
  34. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/helpers.py +1 -1
  35. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework.egg-info/PKG-INFO +20 -27
  36. ucapi_framework-1.9.0/ucapi_framework.egg-info/SOURCES.txt +48 -0
  37. ucapi_framework-1.8.4/ucapi_framework.egg-info/SOURCES.txt +0 -25
  38. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/setup.cfg +0 -0
  39. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_config.py +0 -0
  40. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_device.py +0 -0
  41. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_discovery.py +0 -0
  42. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_factory_pattern.py +0 -0
  43. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_migration.py +0 -0
  44. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/tests/test_setup.py +0 -0
  45. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/config.py +0 -0
  46. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/discovery.py +0 -0
  47. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/migration.py +0 -0
  48. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework/setup.py +0 -0
  49. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework.egg-info/dependency_links.txt +0 -0
  50. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework.egg-info/requires.txt +0 -0
  51. {ucapi_framework-1.8.4 → ucapi_framework-1.9.0}/ucapi_framework.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi-framework
3
- Version: 1.8.4
3
+ Version: 1.9.0
4
4
  Summary: ucapi framework that provides core functionality for building integrations.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ Requires-Dist: aiohttp>=3.9.0
10
10
 
11
11
  [![Tests](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml/badge.svg)](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml)
12
12
  [![Discord](https://badgen.net/discord/online-members/zGVYf58)](https://discord.gg/zGVYf58)
13
- [![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)
13
+ [![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)
14
14
 
15
15
  # UCAPI Framework
16
16
 
@@ -86,12 +86,12 @@ The driver coordinates everything - device lifecycle, entity management, and Rem
86
86
 
87
87
  The framework provides sensible defaults for:
88
88
 
89
- - **`create_entities()`** - Creates one entity per entity type automatically
90
- - **`map_device_state()`** - Maps common state strings (ON, OFF, PLAYING, etc.)
91
- - **`device_from_entity_id()`** - Parses standard entity ID format
89
+ - **`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]`
90
+ - **`map_device_state()`** - Maps common state strings (ON, OFF, PLAYING, etc.) to `media_player.States`
91
+ - **`device_from_entity_id()`** - Parses the standard `entity_type.device_id` format
92
92
  - **`get_entity_ids_for_device()`** - Queries and filters entities by device
93
93
 
94
- **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.
94
+ **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()`.
95
95
 
96
96
  Everything else is automatic. The framework handles Remote connection events (connect, disconnect, standby), entity subscriptions, device lifecycle management, and state synchronization.
97
97
 
@@ -119,13 +119,13 @@ All discovery classes handle the protocol details, timeouts, and error handling.
119
119
 
120
120
  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.
121
121
 
122
- 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.
122
+ 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.
123
123
 
124
124
  ## How It Works
125
125
 
126
126
  You inherit from base classes and override only what you need:
127
127
 
128
- **Driver** - Usually works with defaults! Override only if you need custom state mapping or conditional entity creation.
128
+ **Driver** - Usually works with defaults. Pass `entity_classes` to the constructor. Override only if you need custom state mapping or conditional entity creation.
129
129
 
130
130
  **Device** - Implement your connection pattern (verify, poll, handle messages, etc.).
131
131
 
@@ -176,34 +176,22 @@ Optional discovery implementations for common protocols:
176
176
 
177
177
  Lazy imports mean you only need the dependencies if you use them.
178
178
 
179
- ## Real-World Example
180
-
181
- See the PSN integration in this repository:
182
-
183
- - `intg-psn/driver.py` - 90 lines (was 300)
184
- - `intg-psn/psn.py` - 140 lines (was 240)
185
- - `intg-psn/setup_flow.py` - 50 lines (was 250)
186
- - `intg-psn/config.py` - 15 lines (was 95)
187
-
188
- Total: ~295 lines of integration code vs ~885 lines previously. And the new code is type-safe, testable, and maintainable.
189
179
 
190
180
  ## Migration
191
181
 
192
- If you have an existing integration, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for step-by-step instructions with before/after examples.
193
-
194
- For upgrading from a previous version of ucapi-framework:
195
- - **[Upgrading to 1.6.0](https://jackjpowell.github.io/ucapi-framework/upgrade-to-1.6.0/)** - New dynamic entity management features
182
+ 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`.
196
183
 
197
184
  ## Requirements
198
185
 
199
186
  - Python 3.11+
200
187
  - ucapi
201
188
  - pyee
189
+ - aiohttp (required; used by HTTP and WebSocket device base classes)
190
+
191
+ Optional (only if you use the corresponding discovery classes):
202
192
 
203
- Optional (only if you use them):
204
- - aiohttp (for HTTP devices)
205
- - websockets (for WebSocket devices)
206
193
  - ssdpy (for SSDP discovery)
194
+ - sddp-discovery-protocol (for SDDP discovery)
207
195
  - zeroconf (for mDNS discovery)
208
196
 
209
197
  ## Documentation
@@ -231,9 +219,14 @@ Visit <http://127.0.0.1:8000> to view the docs.
231
219
  uv sync --group dev
232
220
  ```
233
221
 
234
- Git hooks are automatically active from the `git-hooks/` directory:
222
+ A `pre-commit` hook is available in the `git-hooks/` directory. To activate it:
223
+
224
+ ```bash
225
+ cp git-hooks/pre-commit .git/hooks/pre-commit
226
+ chmod +x .git/hooks/pre-commit
227
+ ```
235
228
 
236
- - **pre-commit**: Runs `ruff check --fix` and `ruff format` via `uv run`
229
+ The hook runs `ruff check --fix` and `ruff format` before every commit.
237
230
 
238
231
  All development tools run through `uv` and are configured in `pyproject.toml`.
239
232
 
@@ -1,6 +1,6 @@
1
1
  [![Tests](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml/badge.svg)](https://github.com/jackjpowell/ucapi-framework/actions/workflows/test.yml)
2
2
  [![Discord](https://badgen.net/discord/online-members/zGVYf58)](https://discord.gg/zGVYf58)
3
- [![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me_A_Coffee&nbsp;☕-FFDD00?logo=buy-me-a-coffee&logoColor=white&labelColor=grey)](https://buymeacoffee.com/jackpowell)
3
+ [![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)
4
4
 
5
5
  # UCAPI Framework
6
6
 
@@ -76,12 +76,12 @@ The driver coordinates everything - device lifecycle, entity management, and Rem
76
76
 
77
77
  The framework provides sensible defaults for:
78
78
 
79
- - **`create_entities()`** - Creates one entity per entity type automatically
80
- - **`map_device_state()`** - Maps common state strings (ON, OFF, PLAYING, etc.)
81
- - **`device_from_entity_id()`** - Parses standard entity ID format
79
+ - **`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]`
80
+ - **`map_device_state()`** - Maps common state strings (ON, OFF, PLAYING, etc.) to `media_player.States`
81
+ - **`device_from_entity_id()`** - Parses the standard `entity_type.device_id` format
82
82
  - **`get_entity_ids_for_device()`** - Queries and filters entities by device
83
83
 
84
- **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.
84
+ **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()`.
85
85
 
86
86
  Everything else is automatic. The framework handles Remote connection events (connect, disconnect, standby), entity subscriptions, device lifecycle management, and state synchronization.
87
87
 
@@ -109,13 +109,13 @@ All discovery classes handle the protocol details, timeouts, and error handling.
109
109
 
110
110
  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.
111
111
 
112
- 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.
112
+ 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.
113
113
 
114
114
  ## How It Works
115
115
 
116
116
  You inherit from base classes and override only what you need:
117
117
 
118
- **Driver** - Usually works with defaults! Override only if you need custom state mapping or conditional entity creation.
118
+ **Driver** - Usually works with defaults. Pass `entity_classes` to the constructor. Override only if you need custom state mapping or conditional entity creation.
119
119
 
120
120
  **Device** - Implement your connection pattern (verify, poll, handle messages, etc.).
121
121
 
@@ -166,34 +166,22 @@ Optional discovery implementations for common protocols:
166
166
 
167
167
  Lazy imports mean you only need the dependencies if you use them.
168
168
 
169
- ## Real-World Example
170
-
171
- See the PSN integration in this repository:
172
-
173
- - `intg-psn/driver.py` - 90 lines (was 300)
174
- - `intg-psn/psn.py` - 140 lines (was 240)
175
- - `intg-psn/setup_flow.py` - 50 lines (was 250)
176
- - `intg-psn/config.py` - 15 lines (was 95)
177
-
178
- Total: ~295 lines of integration code vs ~885 lines previously. And the new code is type-safe, testable, and maintainable.
179
169
 
180
170
  ## Migration
181
171
 
182
- If you have an existing integration, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for step-by-step instructions with before/after examples.
183
-
184
- For upgrading from a previous version of ucapi-framework:
185
- - **[Upgrading to 1.6.0](https://jackjpowell.github.io/ucapi-framework/upgrade-to-1.6.0/)** - New dynamic entity management features
172
+ 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`.
186
173
 
187
174
  ## Requirements
188
175
 
189
176
  - Python 3.11+
190
177
  - ucapi
191
178
  - pyee
179
+ - aiohttp (required; used by HTTP and WebSocket device base classes)
180
+
181
+ Optional (only if you use the corresponding discovery classes):
192
182
 
193
- Optional (only if you use them):
194
- - aiohttp (for HTTP devices)
195
- - websockets (for WebSocket devices)
196
183
  - ssdpy (for SSDP discovery)
184
+ - sddp-discovery-protocol (for SDDP discovery)
197
185
  - zeroconf (for mDNS discovery)
198
186
 
199
187
  ## Documentation
@@ -221,9 +209,14 @@ Visit <http://127.0.0.1:8000> to view the docs.
221
209
  uv sync --group dev
222
210
  ```
223
211
 
224
- Git hooks are automatically active from the `git-hooks/` directory:
212
+ A `pre-commit` hook is available in the `git-hooks/` directory. To activate it:
213
+
214
+ ```bash
215
+ cp git-hooks/pre-commit .git/hooks/pre-commit
216
+ chmod +x .git/hooks/pre-commit
217
+ ```
225
218
 
226
- - **pre-commit**: Runs `ruff check --fix` and `ruff format` via `uv run`
219
+ The hook runs `ruff check --fix` and `ruff format` before every commit.
227
220
 
228
221
  All development tools run through `uv` and are configured in `pyproject.toml`.
229
222
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ucapi-framework"
3
- version = "1.8.4"
3
+ version = "1.9.0"
4
4
  description = "ucapi framework that provides core functionality for building integrations."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,154 @@
1
+ """Tests for ButtonEntity with built-in state management."""
2
+
3
+ import pytest
4
+ from unittest.mock import MagicMock
5
+ from ucapi import button
6
+ from ucapi_framework import ButtonEntity
7
+
8
+
9
+ class TestButtonEntity:
10
+ """Test ButtonEntity state management."""
11
+
12
+ @pytest.fixture
13
+ def mock_api(self):
14
+ """Create a mock API for testing."""
15
+ api = MagicMock()
16
+ api.configured_entities.get.return_value = MagicMock(
17
+ attributes={button.Attributes.STATE: button.States.AVAILABLE}
18
+ )
19
+ return api
20
+
21
+ @pytest.fixture
22
+ def entity(self, mock_api):
23
+ """Create a ButtonEntity for testing."""
24
+ entity = ButtonEntity(
25
+ "button.test",
26
+ "Test Button",
27
+ )
28
+ entity._api = mock_api # noqa: SLF001
29
+ return entity
30
+
31
+ def test_initial_state(self, entity):
32
+ """Test initial state from constructor attributes."""
33
+ assert entity.state == button.States.AVAILABLE
34
+
35
+ def test_initial_state_always_available(self, mock_api):
36
+ """Test that button always initializes STATE to AVAILABLE (ucapi hardcodes this)."""
37
+ entity = ButtonEntity(
38
+ "button.unset",
39
+ "Unset Button",
40
+ )
41
+ entity._api = mock_api # noqa: SLF001
42
+ # ucapi.button.Button hardcodes {Attributes.STATE: States.AVAILABLE} in __init__
43
+ assert entity.state == button.States.AVAILABLE
44
+
45
+ def test_set_state_with_update(self, entity, mock_api):
46
+ """Test set_state() calls entity.update() by default."""
47
+ entity.set_state(button.States.UNAVAILABLE, update=True)
48
+
49
+ # Verify internal state was updated
50
+ assert entity.state == button.States.UNAVAILABLE
51
+
52
+ # Verify update was called
53
+ assert mock_api.configured_entities.update_attributes.called
54
+ call_args = mock_api.configured_entities.update_attributes.call_args
55
+ entity_id, attributes = call_args[0]
56
+ assert entity_id == "button.test"
57
+ assert button.Attributes.STATE in attributes
58
+ assert attributes[button.Attributes.STATE] == button.States.UNAVAILABLE
59
+
60
+ def test_set_state_without_update(self, entity, mock_api):
61
+ """Test set_state(update=False) does not call entity.update()."""
62
+ entity.set_state(button.States.UNAVAILABLE, update=False)
63
+
64
+ # Verify internal state was updated
65
+ assert entity.state == button.States.UNAVAILABLE
66
+
67
+ # Verify update was NOT called
68
+ assert not mock_api.configured_entities.update_attributes.called
69
+
70
+ def test_set_state_available(self, entity, mock_api):
71
+ """Test transitioning back to AVAILABLE."""
72
+ entity.set_state(button.States.UNAVAILABLE, update=False)
73
+ assert entity.state == button.States.UNAVAILABLE
74
+
75
+ entity.set_state(button.States.AVAILABLE, update=False)
76
+ assert entity.state == button.States.AVAILABLE
77
+
78
+ def test_set_state_none_does_not_update_remote(self, entity, mock_api):
79
+ """Test set_state(None) stores None but update filter strips it."""
80
+ # set_state(None) writes None into attributes dict
81
+ entity.set_state(None, update=True)
82
+
83
+ # Internal state stores None
84
+ assert entity.state is None
85
+
86
+ # The update should have been called but the None filter in
87
+ # update_attributes strips the None value — so update_attributes
88
+ # is called with an empty dict (or not called if filter removes all)
89
+ # Either way, the Remote should not receive a None value
90
+ if mock_api.configured_entities.update_attributes.called:
91
+ call_args = mock_api.configured_entities.update_attributes.call_args
92
+ _, attributes = call_args[0]
93
+ assert button.Attributes.STATE not in attributes
94
+
95
+ def test_property_getter_is_read_only(self, entity):
96
+ """Test that the state property cannot be set directly."""
97
+ with pytest.raises(AttributeError):
98
+ entity.state = button.States.AVAILABLE # type: ignore[misc]
99
+
100
+ def test_only_one_attribute(self, entity, mock_api):
101
+ """Test that Button only has the STATE attribute."""
102
+ entity.set_state(button.States.UNAVAILABLE, update=True)
103
+
104
+ call_args = mock_api.configured_entities.update_attributes.call_args
105
+ _, attributes = call_args[0]
106
+ # Should only contain STATE
107
+ assert list(attributes.keys()) == [button.Attributes.STATE]
108
+
109
+
110
+ class TestButtonEntityInheritance:
111
+ """Test that ButtonEntity can be subclassed and overridden."""
112
+
113
+ def test_custom_set_state(self):
114
+ """Test that set_state can be overridden."""
115
+
116
+ class CustomButton(ButtonEntity):
117
+ def __init__(self):
118
+ super().__init__(
119
+ "button.custom",
120
+ "Custom Button",
121
+ )
122
+ self.custom_set_state_called = False
123
+
124
+ def set_state(self, value, *, update=True):
125
+ """Override set_state to add custom logic."""
126
+ self.custom_set_state_called = True
127
+ super().set_state(value, update=update)
128
+
129
+ entity = CustomButton()
130
+ entity._api = MagicMock() # noqa: SLF001
131
+ entity._api.configured_entities.get.return_value = entity # noqa: SLF001
132
+
133
+ entity.set_state(button.States.AVAILABLE, update=False)
134
+ assert entity.custom_set_state_called is True
135
+ assert entity.state == button.States.AVAILABLE
136
+
137
+ def test_custom_property_getter(self):
138
+ """Test that the state property getter can be overridden."""
139
+
140
+ class CustomButton(ButtonEntity):
141
+ def __init__(self):
142
+ super().__init__(
143
+ "button.custom",
144
+ "Custom Button",
145
+ )
146
+
147
+ @property
148
+ def state(self):
149
+ """Override state getter to always return AVAILABLE."""
150
+ return button.States.AVAILABLE
151
+
152
+ entity = CustomButton()
153
+ # Property override always returns AVAILABLE
154
+ assert entity.state == button.States.AVAILABLE
@@ -0,0 +1,230 @@
1
+ """Tests for ClimateEntity with built-in state management."""
2
+
3
+ import pytest
4
+ from unittest.mock import MagicMock
5
+ from ucapi import climate
6
+ from ucapi_framework import ClimateEntity
7
+
8
+
9
+ class TestClimateEntity:
10
+ """Test ClimateEntity state management."""
11
+
12
+ @pytest.fixture
13
+ def mock_api(self):
14
+ """Create a mock API for testing."""
15
+ api = MagicMock()
16
+ api.configured_entities.get.return_value = MagicMock(
17
+ attributes={climate.Attributes.STATE: climate.States.OFF}
18
+ )
19
+ return api
20
+
21
+ @pytest.fixture
22
+ def entity(self, mock_api):
23
+ """Create a ClimateEntity for testing."""
24
+ entity = ClimateEntity(
25
+ "climate.test",
26
+ "Test Thermostat",
27
+ features=[climate.Features.ON_OFF, climate.Features.HEAT],
28
+ attributes={climate.Attributes.STATE: climate.States.OFF},
29
+ )
30
+ entity._api = mock_api # noqa: SLF001
31
+ return entity
32
+
33
+ def test_initial_state(self, entity):
34
+ """Test initial state from constructor attributes."""
35
+ assert entity.state == climate.States.OFF
36
+ assert entity.current_temperature is None
37
+ assert entity.target_temperature is None
38
+ assert entity.target_temperature_high is None
39
+ assert entity.target_temperature_low is None
40
+ assert entity.fan_mode is None
41
+
42
+ def test_set_state_with_update(self, entity, mock_api):
43
+ """Test set_state() calls entity.update() by default."""
44
+ entity.set_state(climate.States.HEAT, update=True)
45
+
46
+ assert entity.state == climate.States.HEAT
47
+
48
+ assert mock_api.configured_entities.update_attributes.called
49
+ call_args = mock_api.configured_entities.update_attributes.call_args
50
+ entity_id, attributes = call_args[0]
51
+ assert entity_id == "climate.test"
52
+ assert climate.Attributes.STATE in attributes
53
+ assert attributes[climate.Attributes.STATE] == climate.States.HEAT
54
+
55
+ def test_set_state_without_update(self, entity, mock_api):
56
+ """Test set_state(update=False) does not call entity.update()."""
57
+ entity.set_state(climate.States.COOL, update=False)
58
+
59
+ assert entity.state == climate.States.COOL
60
+ assert not mock_api.configured_entities.update_attributes.called
61
+
62
+ def test_set_current_temperature(self, entity, mock_api):
63
+ """Test set_current_temperature() updates state and calls update."""
64
+ entity.set_current_temperature(22.5, update=True)
65
+
66
+ assert entity.current_temperature == 22.5
67
+ assert mock_api.configured_entities.update_attributes.called
68
+
69
+ def test_set_current_temperature_without_update(self, entity, mock_api):
70
+ """Test set_current_temperature(update=False) does not call update."""
71
+ entity.set_current_temperature(22.5, update=False)
72
+
73
+ assert entity.current_temperature == 22.5
74
+ assert not mock_api.configured_entities.update_attributes.called
75
+
76
+ def test_set_target_temperature(self, entity, mock_api):
77
+ """Test set_target_temperature() updates state and calls update."""
78
+ entity.set_target_temperature(21.0, update=True)
79
+
80
+ assert entity.target_temperature == 21.0
81
+ assert mock_api.configured_entities.update_attributes.called
82
+
83
+ def test_set_target_temperature_without_update(self, entity, mock_api):
84
+ """Test set_target_temperature(update=False) does not call update."""
85
+ entity.set_target_temperature(21.0, update=False)
86
+
87
+ assert entity.target_temperature == 21.0
88
+ assert not mock_api.configured_entities.update_attributes.called
89
+
90
+ def test_set_target_temperature_high(self, entity, mock_api):
91
+ """Test set_target_temperature_high() updates state and calls update."""
92
+ entity.set_target_temperature_high(25.0, update=True)
93
+
94
+ assert entity.target_temperature_high == 25.0
95
+ assert mock_api.configured_entities.update_attributes.called
96
+
97
+ def test_set_target_temperature_low(self, entity, mock_api):
98
+ """Test set_target_temperature_low() updates state and calls update."""
99
+ entity.set_target_temperature_low(18.0, update=True)
100
+
101
+ assert entity.target_temperature_low == 18.0
102
+ assert mock_api.configured_entities.update_attributes.called
103
+
104
+ def test_set_fan_mode(self, entity, mock_api):
105
+ """Test set_fan_mode() updates state and calls update."""
106
+ entity.set_fan_mode("AUTO", update=True)
107
+
108
+ assert entity.fan_mode == "AUTO"
109
+ assert mock_api.configured_entities.update_attributes.called
110
+
111
+ def test_set_fan_mode_without_update(self, entity, mock_api):
112
+ """Test set_fan_mode(update=False) does not call update."""
113
+ entity.set_fan_mode("HIGH", update=False)
114
+
115
+ assert entity.fan_mode == "HIGH"
116
+ assert not mock_api.configured_entities.update_attributes.called
117
+
118
+ def test_set_attributes_bulk_update(self, entity, mock_api):
119
+ """Test set_attributes() updates multiple attributes with single update call."""
120
+ entity.set_attributes(
121
+ state=climate.States.HEAT,
122
+ current_temperature=20.5,
123
+ target_temperature=22.0,
124
+ fan_mode="AUTO",
125
+ update=True,
126
+ )
127
+
128
+ assert entity.state == climate.States.HEAT
129
+ assert entity.current_temperature == 20.5
130
+ assert entity.target_temperature == 22.0
131
+ assert entity.fan_mode == "AUTO"
132
+
133
+ # Verify update was called only once
134
+ assert mock_api.configured_entities.update_attributes.call_count == 1
135
+
136
+ call_args = mock_api.configured_entities.update_attributes.call_args
137
+ entity_id, attributes = call_args[0]
138
+ assert entity_id == "climate.test"
139
+ assert len(attributes) == 4
140
+ assert attributes[climate.Attributes.STATE] == climate.States.HEAT
141
+ assert attributes[climate.Attributes.CURRENT_TEMPERATURE] == 20.5
142
+ assert attributes[climate.Attributes.TARGET_TEMPERATURE] == 22.0
143
+ assert attributes[climate.Attributes.FAN_MODE] == "AUTO"
144
+
145
+ def test_set_attributes_without_update(self, entity, mock_api):
146
+ """Test set_attributes(update=False) does not call entity.update()."""
147
+ entity.set_attributes(
148
+ state=climate.States.COOL,
149
+ target_temperature=19.0,
150
+ update=False,
151
+ )
152
+
153
+ assert entity.state == climate.States.COOL
154
+ assert entity.target_temperature == 19.0
155
+ assert not mock_api.configured_entities.update_attributes.called
156
+
157
+ def test_set_attributes_ignores_none_values(self, entity, mock_api):
158
+ """Test set_attributes() ignores None values."""
159
+ entity.set_attributes(
160
+ state=climate.States.HEAT, current_temperature=None, update=True
161
+ )
162
+
163
+ assert entity.state == climate.States.HEAT
164
+ assert entity.current_temperature is None
165
+
166
+ call_args = mock_api.configured_entities.update_attributes.call_args
167
+ entity_id, attributes = call_args[0]
168
+ assert len(attributes) == 1
169
+ assert climate.Attributes.STATE in attributes
170
+
171
+ def test_set_attributes_all_six(self, entity, mock_api):
172
+ """Test setting all six climate attributes at once."""
173
+ entity.set_attributes(
174
+ state=climate.States.HEAT_COOL,
175
+ current_temperature=21.0,
176
+ target_temperature=22.0,
177
+ target_temperature_high=25.0,
178
+ target_temperature_low=18.0,
179
+ fan_mode="AUTO",
180
+ update=True,
181
+ )
182
+
183
+ assert entity.state == climate.States.HEAT_COOL
184
+ assert entity.current_temperature == 21.0
185
+ assert entity.target_temperature == 22.0
186
+ assert entity.target_temperature_high == 25.0
187
+ assert entity.target_temperature_low == 18.0
188
+ assert entity.fan_mode == "AUTO"
189
+
190
+ assert mock_api.configured_entities.update_attributes.call_count == 1
191
+
192
+ def test_property_getters_are_read_only(self, entity):
193
+ """Test that property getters cannot be set directly."""
194
+ with pytest.raises(AttributeError):
195
+ entity.state = climate.States.HEAT # type: ignore[misc]
196
+
197
+ with pytest.raises(AttributeError):
198
+ entity.current_temperature = 20.0 # type: ignore[misc]
199
+
200
+ with pytest.raises(AttributeError):
201
+ entity.target_temperature = 21.0 # type: ignore[misc]
202
+
203
+
204
+ class TestClimateEntityInheritance:
205
+ """Test that ClimateEntity can be subclassed and overridden."""
206
+
207
+ def test_custom_set_state(self):
208
+ """Test that set_state can be overridden."""
209
+
210
+ class CustomClimate(ClimateEntity):
211
+ def __init__(self):
212
+ super().__init__(
213
+ "climate.custom",
214
+ "Custom Thermostat",
215
+ features=[],
216
+ attributes={},
217
+ )
218
+ self.custom_called = False
219
+
220
+ def set_state(self, value, *, update=True):
221
+ self.custom_called = True
222
+ super().set_state(value, update=update)
223
+
224
+ entity = CustomClimate()
225
+ entity._api = MagicMock() # noqa: SLF001
226
+ entity._api.configured_entities.get.return_value = entity # noqa: SLF001
227
+
228
+ entity.set_state(climate.States.COOL, update=False)
229
+ assert entity.custom_called is True
230
+ assert entity.state == climate.States.COOL