ucapi 0.5.2__tar.gz → 0.6.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 (57) hide show
  1. {ucapi-0.5.2 → ucapi-0.6.0}/CHANGELOG.md +11 -0
  2. {ucapi-0.5.2/ucapi.egg-info → ucapi-0.6.0}/PKG-INFO +2 -2
  3. {ucapi-0.5.2 → ucapi-0.6.0}/pyproject.toml +1 -1
  4. ucapi-0.6.0/tests/test_media_player.py +159 -0
  5. ucapi-0.6.0/tests/test_paging.py +131 -0
  6. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/__init__.py +16 -1
  7. ucapi-0.6.0/ucapi/_version.py +24 -0
  8. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/api.py +212 -17
  9. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/api_definitions.py +145 -18
  10. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/button.py +11 -4
  11. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/climate.py +14 -7
  12. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/cover.py +14 -7
  13. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/entities.py +4 -0
  14. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/entity.py +31 -3
  15. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/ir_emitter.py +13 -7
  16. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/light.py +14 -7
  17. ucapi-0.6.0/ucapi/media_player.py +738 -0
  18. ucapi-0.6.0/ucapi/msg_definitions.py +68 -0
  19. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/remote.py +13 -6
  20. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/select.py +13 -7
  21. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/sensor.py +14 -7
  22. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/switch.py +14 -7
  23. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/ui.py +2 -2
  24. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/voice_assistant.py +15 -8
  25. {ucapi-0.5.2 → ucapi-0.6.0/ucapi.egg-info}/PKG-INFO +2 -2
  26. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi.egg-info/SOURCES.txt +3 -0
  27. ucapi-0.5.2/ucapi/_version.py +0 -34
  28. ucapi-0.5.2/ucapi/media_player.py +0 -233
  29. {ucapi-0.5.2 → ucapi-0.6.0}/CONTRIBUTING.md +0 -0
  30. {ucapi-0.5.2 → ucapi-0.6.0}/LICENSE +0 -0
  31. {ucapi-0.5.2 → ucapi-0.6.0}/README.md +0 -0
  32. {ucapi-0.5.2 → ucapi-0.6.0}/docs/code_guidelines.md +0 -0
  33. {ucapi-0.5.2 → ucapi-0.6.0}/docs/setup.md +0 -0
  34. {ucapi-0.5.2 → ucapi-0.6.0}/examples/README.md +0 -0
  35. {ucapi-0.5.2 → ucapi-0.6.0}/examples/hello_integration.json +0 -0
  36. {ucapi-0.5.2 → ucapi-0.6.0}/examples/hello_integration.py +0 -0
  37. {ucapi-0.5.2 → ucapi-0.6.0}/examples/remote.json +0 -0
  38. {ucapi-0.5.2 → ucapi-0.6.0}/examples/remote.py +0 -0
  39. {ucapi-0.5.2 → ucapi-0.6.0}/examples/remote_ui_page.json +0 -0
  40. {ucapi-0.5.2 → ucapi-0.6.0}/examples/setup_flow.json +0 -0
  41. {ucapi-0.5.2 → ucapi-0.6.0}/examples/setup_flow.py +0 -0
  42. {ucapi-0.5.2 → ucapi-0.6.0}/examples/voice.json +0 -0
  43. {ucapi-0.5.2 → ucapi-0.6.0}/examples/voice.py +0 -0
  44. {ucapi-0.5.2 → ucapi-0.6.0}/requirements.txt +0 -0
  45. {ucapi-0.5.2 → ucapi-0.6.0}/scripts/compile_protos.py +0 -0
  46. {ucapi-0.5.2 → ucapi-0.6.0}/setup.cfg +0 -0
  47. {ucapi-0.5.2 → ucapi-0.6.0}/test-requirements.txt +0 -0
  48. {ucapi-0.5.2 → ucapi-0.6.0}/tests/test_api.py +0 -0
  49. {ucapi-0.5.2 → ucapi-0.6.0}/tests/test_voice_assistant.py +0 -0
  50. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/proto/__init__.py +0 -0
  51. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/proto/ucr_integration_voice.proto +0 -0
  52. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/proto/ucr_integration_voice_pb2.py +0 -0
  53. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/proto/ucr_integration_voice_pb2.pyi +0 -0
  54. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi/voice_stream.py +0 -0
  55. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi.egg-info/dependency_links.txt +0 -0
  56. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi.egg-info/requires.txt +0 -0
  57. {ucapi-0.5.2 → ucapi-0.6.0}/ucapi.egg-info/top_level.txt +0 -0
@@ -11,6 +11,17 @@ _Changes in the next release_
11
11
 
12
12
  ---
13
13
 
14
+ ## v0.6.0 - 2026-04-10
15
+ ### Breaking Changes
16
+ - Renamed `MediaType` to `MediaContentType` and changed enums to lowercase. See media-player entity documentation for more information ([#50](https://github.com/unfoldedcircle/integration-python-library/pull/50)).
17
+ - Changed `str, Enum` to new Python 3.11 `StrEnum` class ([#54](https://github.com/unfoldedcircle/integration-python-library/pull/54)).
18
+ - All entity constructors require named parameters for the optional fields ([#49](https://github.com/unfoldedcircle/integration-python-library/pull/49), [#54](https://github.com/unfoldedcircle/integration-python-library/pull/54)).
19
+
20
+ ### Added
21
+ - Media browsing and searching features to media-player entity ([#50](https://github.com/unfoldedcircle/integration-python-library/pull/50)).
22
+ - Allow integrations to provide entity icon and description ([#55](https://github.com/unfoldedcircle/integration-python-library/pull/55)).
23
+
24
+
14
25
  ## v0.5.2 - 2026-01-30
15
26
  ### Added
16
27
  - Add Select Entity support by @JackJPowell ([#44](https://github.com/unfoldedcircle/integration-python-library/pull/44)).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Python wrapper for the Unfolded Circle Integration API
5
5
  Author-email: Unfolded Circle ApS <hello@unfoldedcircle.com>
6
6
  License: MPL-2.0
@@ -10,7 +10,7 @@ Project-URL: Bug Reports, https://github.com/unfoldedcircle/integration-python-l
10
10
  Project-URL: Discord, http://unfolded.chat/
11
11
  Project-URL: Forum, https://unfolded.community/
12
12
  Platform: any
13
- Classifier: Development Status :: 4 - Beta
13
+ Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
16
16
  Classifier: Operating System :: OS Independent
@@ -10,7 +10,7 @@ authors = [
10
10
  license = {text = "MPL-2.0"}
11
11
  description = "Python wrapper for the Unfolded Circle Integration API"
12
12
  classifiers = [
13
- "Development Status :: 4 - Beta",
13
+ "Development Status :: 5 - Production/Stable",
14
14
  "Intended Audience :: Developers",
15
15
  "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
16
16
  "Operating System :: OS Independent",
@@ -0,0 +1,159 @@
1
+ """
2
+ Tests for media player entity.
3
+ """
4
+
5
+ import unittest
6
+ from ucapi.media_player import SearchMediaFilter, MediaClass, BrowseMediaItem
7
+
8
+
9
+ class TestMediaPlayer(unittest.TestCase):
10
+ """Media player tests."""
11
+
12
+ def test_search_media_filter_media_classes(self):
13
+ """Test SearchMediaFilter media_classes with standard and custom values."""
14
+ # Test with standard MediaClass
15
+ smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, MediaClass.TRACK])
16
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK])
17
+
18
+ # Test with strings that match MediaClass
19
+ smf = SearchMediaFilter(media_classes=["album", "track"])
20
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK])
21
+
22
+ # Test with custom string values
23
+ try:
24
+ smf = SearchMediaFilter(media_classes=["custom_class", "another_one"])
25
+ self.assertEqual(smf.media_classes, ["custom_class", "another_one"])
26
+ except ValueError as e:
27
+ self.fail(
28
+ f"SearchMediaFilter raised ValueError for custom media classes: {e}"
29
+ )
30
+
31
+ def test_search_media_filter_mixed_classes(self):
32
+ """Test SearchMediaFilter with a mix of MediaClass and custom strings."""
33
+ try:
34
+ smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, "custom_class"])
35
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"])
36
+ except ValueError as e:
37
+ self.fail(
38
+ f"SearchMediaFilter raised ValueError for mixed media classes: {e}"
39
+ )
40
+
41
+ def test_search_media_filter_none(self):
42
+ """Test SearchMediaFilter with None media_classes."""
43
+ smf = SearchMediaFilter(media_classes=None)
44
+ self.assertIsNone(smf.media_classes)
45
+
46
+ def test_search_media_filter_from_dict(self):
47
+ """Test SearchMediaFilter.from_dict with custom values."""
48
+ data = {
49
+ "media_classes": ["album", "custom_class"],
50
+ "artist": "Some Artist",
51
+ "album": "Some Album",
52
+ }
53
+ try:
54
+ smf = SearchMediaFilter.from_dict(data)
55
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"])
56
+ self.assertEqual(smf.artist, "Some Artist")
57
+ self.assertEqual(smf.album, "Some Album")
58
+ except ValueError as e:
59
+ self.fail(
60
+ f"SearchMediaFilter.from_dict raised ValueError for custom media classes: {e}"
61
+ )
62
+
63
+ def test_browse_media_item_validation_mandatory(self):
64
+ """Test BrowseMediaItem mandatory field validation."""
65
+ # Valid mandatory fields
66
+ item = BrowseMediaItem(media_id="id1", title="Title")
67
+ self.assertEqual(item.media_id, "id1")
68
+ self.assertEqual(item.title, "Title")
69
+
70
+ # media_id empty
71
+ with self.assertRaisesRegex(
72
+ ValueError, "media_id must be at least 1 characters"
73
+ ):
74
+ BrowseMediaItem(media_id="", title="Title")
75
+
76
+ # media_id too long
77
+ with self.assertRaisesRegex(
78
+ ValueError, "media_id must be at most 255 characters"
79
+ ):
80
+ BrowseMediaItem(media_id="a" * 256, title="Title")
81
+
82
+ # media_id wrong type
83
+ with self.assertRaisesRegex(TypeError, "media_id must be str, got int"):
84
+ BrowseMediaItem(media_id=123, title="Title")
85
+
86
+ # title empty
87
+ with self.assertRaisesRegex(ValueError, "title must be at least 1 characters"):
88
+ BrowseMediaItem(media_id="id1", title="")
89
+
90
+ # title too long
91
+ with self.assertRaisesRegex(ValueError, "title must be at most 255 characters"):
92
+ BrowseMediaItem(media_id="id1", title="a" * 256)
93
+
94
+ def test_browse_media_item_validation_optional(self):
95
+ """Test BrowseMediaItem optional field validation."""
96
+ # subtitle
97
+ with self.assertRaisesRegex(
98
+ ValueError, "subtitle must be at least 1 characters"
99
+ ):
100
+ BrowseMediaItem(media_id="id1", title="Title", subtitle="")
101
+ with self.assertRaisesRegex(
102
+ ValueError, "subtitle must be at most 255 characters"
103
+ ):
104
+ BrowseMediaItem(media_id="id1", title="Title", subtitle="a" * 256)
105
+
106
+ # artist
107
+ with self.assertRaisesRegex(ValueError, "artist must be at least 1 characters"):
108
+ BrowseMediaItem(media_id="id1", title="Title", artist="")
109
+ with self.assertRaisesRegex(
110
+ ValueError, "artist must be at most 255 characters"
111
+ ):
112
+ BrowseMediaItem(media_id="id1", title="Title", artist="a" * 256)
113
+
114
+ # album
115
+ with self.assertRaisesRegex(ValueError, "album must be at least 1 characters"):
116
+ BrowseMediaItem(media_id="id1", title="Title", album="")
117
+ with self.assertRaisesRegex(ValueError, "album must be at most 255 characters"):
118
+ BrowseMediaItem(media_id="id1", title="Title", album="a" * 256)
119
+
120
+ # media_class (only when it's a string)
121
+ # Note: media class is allowed to be empty!
122
+ BrowseMediaItem(media_id="id1", title="Title", media_class="")
123
+
124
+ with self.assertRaisesRegex(
125
+ ValueError, "media_class must be at most 255 characters"
126
+ ):
127
+ BrowseMediaItem(media_id="id1", title="Title", media_class="a" * 256)
128
+ # Verify it accepts MediaClass enum without error
129
+ BrowseMediaItem(media_id="id1", title="Title", media_class=MediaClass.ALBUM)
130
+
131
+ # media_type (only when it's a string)
132
+ # Note: media type is allowed to be empty!
133
+ BrowseMediaItem(media_id="id1", title="Title", media_type="")
134
+
135
+ with self.assertRaisesRegex(
136
+ ValueError, "media_type must be at most 255 characters"
137
+ ):
138
+ BrowseMediaItem(media_id="id1", title="Title", media_type="a" * 256)
139
+
140
+ def test_browse_media_item_validation_thumbnail(self):
141
+ """Test BrowseMediaItem thumbnail field validation."""
142
+ # Valid length
143
+ BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32768)
144
+
145
+ # Too short (empty)
146
+ with self.assertRaisesRegex(
147
+ ValueError, "thumbnail must be at least 1 characters"
148
+ ):
149
+ BrowseMediaItem(media_id="id1", title="Title", thumbnail="")
150
+
151
+ # Too long
152
+ with self.assertRaisesRegex(
153
+ ValueError, "thumbnail must be at most 32768 characters"
154
+ ):
155
+ BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32769)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ unittest.main()
@@ -0,0 +1,131 @@
1
+ """Unit tests for Paging and Pagination classes."""
2
+
3
+ import json
4
+ import unittest
5
+ from dataclasses import asdict
6
+
7
+ from ucapi.api_definitions import Pagination, Paging
8
+
9
+
10
+ class TestPaging(unittest.TestCase):
11
+ """Test cases for the Paging class."""
12
+
13
+ def test_paging_default(self):
14
+ """Test default Paging instantiation."""
15
+ paging = Paging()
16
+ self.assertEqual(paging.page, 1)
17
+ self.assertEqual(paging.limit, 10)
18
+ self.assertEqual(paging.offset, 0)
19
+
20
+ def test_paging_custom(self):
21
+ """Test custom Paging instantiation."""
22
+ paging = Paging(page=2, limit=20)
23
+ self.assertEqual(paging.page, 2)
24
+ self.assertEqual(paging.limit, 20)
25
+ self.assertEqual(paging.offset, 20)
26
+
27
+ def test_paging_invalid_page(self):
28
+ """Test validation for invalid page number."""
29
+ with self.assertRaises(ValueError) as cm:
30
+ Paging(page=0)
31
+ self.assertIn("Invalid page: 0", str(cm.exception))
32
+
33
+ with self.assertRaises(ValueError):
34
+ Paging(page=-1)
35
+
36
+ def test_paging_invalid_limit(self):
37
+ """Test validation for invalid limit."""
38
+ with self.assertRaises(ValueError) as cm:
39
+ Paging(limit=0)
40
+ self.assertIn("Invalid limit: 0", str(cm.exception))
41
+
42
+ with self.assertRaises(ValueError):
43
+ Paging(limit=-1)
44
+ with self.assertRaises(ValueError):
45
+ Paging(limit=10000)
46
+
47
+ def test_paging_from_dict(self):
48
+ """Test constructing Paging from a dictionary."""
49
+ data = {"page": 3, "limit": 50}
50
+ paging = Paging.from_dict(data)
51
+ self.assertEqual(paging.page, 3)
52
+ self.assertEqual(paging.limit, 50)
53
+
54
+ def test_paging_from_dict_defaults(self):
55
+ """Test constructing Paging from an empty dictionary using defaults."""
56
+ paging = Paging.from_dict({})
57
+ self.assertEqual(paging.page, 1)
58
+ self.assertEqual(paging.limit, 10)
59
+
60
+ def test_paging_serialization(self):
61
+ """Test Paging JSON serialization."""
62
+ paging = Paging(page=2, limit=25)
63
+ serialized = asdict(paging)
64
+ self.assertEqual(serialized, {"page": 2, "limit": 25})
65
+
66
+ # Verify JSON round-trip
67
+ json_str = json.dumps(serialized)
68
+ self.assertEqual(json.loads(json_str), {"page": 2, "limit": 25})
69
+
70
+
71
+ class TestPagination(unittest.TestCase):
72
+ """Test cases for the Pagination class."""
73
+
74
+ def test_pagination_instantiation(self):
75
+ """Test Pagination instantiation with all fields."""
76
+ pagination = Pagination(page=1, limit=10, count=100)
77
+ self.assertEqual(pagination.page, 1)
78
+ self.assertEqual(pagination.limit, 10)
79
+ self.assertEqual(pagination.count, 100)
80
+
81
+ def test_pagination_no_count(self):
82
+ """Test Pagination instantiation without count."""
83
+ pagination = Pagination(page=2, limit=20)
84
+ self.assertEqual(pagination.page, 2)
85
+ self.assertEqual(pagination.limit, 20)
86
+ self.assertIsNone(pagination.count)
87
+
88
+ def test_pagination_invalid_page(self):
89
+ """Test validation for invalid page number."""
90
+ with self.assertRaises(ValueError) as cm:
91
+ Pagination(page=0, limit=10)
92
+ self.assertIn("page must be >= 1", str(cm.exception))
93
+
94
+ def test_pagination_invalid_limit(self):
95
+ """Test validation for invalid limit."""
96
+ with self.assertRaises(ValueError) as cm:
97
+ Pagination(page=1, limit=-1)
98
+ self.assertIn("Invalid limit", str(cm.exception))
99
+
100
+ def test_pagination_limit_out_of_range(self):
101
+ """Test validation for invalid limit."""
102
+ with self.assertRaises(ValueError) as cm:
103
+ Pagination(page=1, limit=10000)
104
+ self.assertIn("Invalid limit", str(cm.exception))
105
+
106
+ def test_pagination_invalid_count(self):
107
+ """Test validation for invalid count."""
108
+ with self.assertRaises(ValueError) as cm:
109
+ Pagination(page=1, limit=10, count=-1)
110
+ self.assertIn("count cannot be negative", str(cm.exception))
111
+
112
+ def test_pagination_serialization(self):
113
+ """Test Pagination JSON serialization."""
114
+ pagination = Pagination(page=1, limit=10, count=100)
115
+ serialized = asdict(pagination)
116
+ self.assertEqual(serialized, {"page": 1, "limit": 10, "count": 100})
117
+
118
+ def test_pagination_serialization_no_count(self):
119
+ """Test Pagination JSON serialization when count is None."""
120
+ pagination = Pagination(page=2, limit=20)
121
+ json_data = asdict(pagination)
122
+
123
+ # Note: In JS/TS, undefined keys are omitted during JSON.stringify.
124
+ # In Python, None becomes `null` in JSON.
125
+ # This is not an issue: the Remote core service treats `null` as "not existing".
126
+ self.assertIn("count", json_data)
127
+ self.assertIsNone(json_data["count"])
128
+
129
+
130
+ if __name__ == "__main__":
131
+ unittest.main()
@@ -19,6 +19,8 @@ from .api_definitions import ( # isort:skip # noqa: F401
19
19
  DriverSetupRequest,
20
20
  Events,
21
21
  IntegrationSetupError,
22
+ Paging,
23
+ Pagination,
22
24
  RequestUserConfirmation,
23
25
  RequestUserInput,
24
26
  SetupAction,
@@ -42,7 +44,20 @@ from .button import Button # noqa: F401
42
44
  from .climate import Climate # noqa: F401
43
45
  from .cover import Cover # noqa: F401
44
46
  from .light import Light # noqa: F401
45
- from .media_player import MediaPlayer # noqa: F401
47
+ from .media_player import ( # noqa: F401
48
+ BrowseMediaItem,
49
+ BrowseOptions,
50
+ BrowseResults,
51
+ MediaClass,
52
+ MediaContentType,
53
+ MediaPlayAction,
54
+ MediaPlayer,
55
+ RepeatMode,
56
+ SearchMediaFilter,
57
+ SearchMediaItem,
58
+ SearchOptions,
59
+ SearchResults,
60
+ )
46
61
  from .remote import Remote # noqa: F401
47
62
  from .select import Select # noqa: F401
48
63
  from .sensor import Sensor # noqa: F401
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
+
24
+ __commit_id__ = commit_id = 'g755510031'