ucapi 0.1.3__tar.gz → 0.2.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 (39) hide show
  1. {ucapi-0.1.3 → ucapi-0.2.0}/CHANGELOG.md +29 -0
  2. {ucapi-0.1.3/ucapi.egg-info → ucapi-0.2.0}/PKG-INFO +2 -2
  3. {ucapi-0.1.3 → ucapi-0.2.0}/docs/code_guidelines.md +5 -2
  4. {ucapi-0.1.3 → ucapi-0.2.0}/examples/README.md +6 -0
  5. ucapi-0.2.0/examples/remote.json +18 -0
  6. ucapi-0.2.0/examples/remote.py +214 -0
  7. ucapi-0.2.0/examples/remote_ui_page.json +65 -0
  8. {ucapi-0.1.3 → ucapi-0.2.0}/examples/setup_flow.py +11 -0
  9. {ucapi-0.1.3 → ucapi-0.2.0}/pyproject.toml +1 -1
  10. {ucapi-0.1.3 → ucapi-0.2.0}/requirements.txt +1 -1
  11. {ucapi-0.1.3 → ucapi-0.2.0}/setup.cfg +1 -0
  12. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/__init__.py +1 -0
  13. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/_version.py +2 -2
  14. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/api.py +58 -19
  15. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/api_definitions.py +2 -0
  16. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/button.py +2 -4
  17. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/entities.py +6 -2
  18. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/entity.py +3 -2
  19. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/media_player.py +40 -0
  20. ucapi-0.2.0/ucapi/remote.py +167 -0
  21. ucapi-0.2.0/ucapi/ui.py +191 -0
  22. {ucapi-0.1.3 → ucapi-0.2.0/ucapi.egg-info}/PKG-INFO +2 -2
  23. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi.egg-info/SOURCES.txt +5 -0
  24. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi.egg-info/requires.txt +1 -1
  25. {ucapi-0.1.3 → ucapi-0.2.0}/CONTRIBUTING.md +0 -0
  26. {ucapi-0.1.3 → ucapi-0.2.0}/LICENSE +0 -0
  27. {ucapi-0.1.3 → ucapi-0.2.0}/README.md +0 -0
  28. {ucapi-0.1.3 → ucapi-0.2.0}/docs/setup.md +0 -0
  29. {ucapi-0.1.3 → ucapi-0.2.0}/examples/hello_integration.json +0 -0
  30. {ucapi-0.1.3 → ucapi-0.2.0}/examples/hello_integration.py +0 -0
  31. {ucapi-0.1.3 → ucapi-0.2.0}/examples/setup_flow.json +0 -0
  32. {ucapi-0.1.3 → ucapi-0.2.0}/test-requirements.txt +0 -0
  33. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/climate.py +0 -0
  34. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/cover.py +0 -0
  35. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/light.py +0 -0
  36. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/sensor.py +0 -0
  37. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/switch.py +0 -0
  38. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi.egg-info/dependency_links.txt +0 -0
  39. {ucapi-0.1.3 → ucapi-0.2.0}/ucapi.egg-info/top_level.txt +0 -0
@@ -11,6 +11,35 @@ _Changes in the next release_
11
11
 
12
12
  ---
13
13
 
14
+ ## v0.2.0 - 2024-04-28
15
+ ### Added
16
+ - New remote-entity type. Requires remote-core / Core Simulator version 0.43.0 or newer.
17
+
18
+ ## v0.1.7 - 2024-03-13
19
+ ### Changed
20
+ - Filter out base64 encoded media-player image fields in log messages ([#17](https://github.com/unfoldedcircle/integration-python-library/issues/17)).
21
+
22
+ ## v0.1.6 - 2024-03-04
23
+ ### Added
24
+ - Media-player RepeatMode enum and new features: context_menu, settings
25
+
26
+ ## v0.1.5 - 2024-02-28
27
+ ### Changed
28
+ - Allow newer zeroconf versions than 0.120.0 (e.g. pyatv 0.14.5 requires 0.131.0).
29
+
30
+ ## v0.1.4 - 2024-02-27
31
+ ### Added
32
+ - Media-player entity features ([core-api/#32](https://github.com/unfoldedcircle/core-api/issues/32)):
33
+ - new features: numpad, guide, info, eject, open_close, audio_track, subtitle, record.
34
+ - new option: simple_commands for any additional commands not covered by a feature.
35
+
36
+ ### Fixed
37
+ - Return entity options in `get_available_entities` response message.
38
+
39
+ ### Changed
40
+ - Add `reconfigure` flag in `DriverSetupRequest` message to reconfigure a driver.
41
+ - Always notify clients when setting a new device state, even if the state doesn't change.
42
+
14
43
  ## v0.1.3 - 2023-11-08
15
44
  ### Fixed
16
45
  - Environment variable `UC_INTEGRATION_HTTP_PORT` to override server port.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ucapi
3
- Version: 0.1.3
3
+ Version: 0.2.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
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown; charset=UTF-8
23
23
  License-File: LICENSE
24
24
  Requires-Dist: pyee>=9.0
25
25
  Requires-Dist: websockets>=11.0
26
- Requires-Dist: zeroconf~=0.120.0
26
+ Requires-Dist: zeroconf>=0.120.0
27
27
  Provides-Extra: testing
28
28
  Requires-Dist: pylint; extra == "testing"
29
29
  Requires-Dist: flake8-docstrings; extra == "testing"
@@ -3,8 +3,11 @@
3
3
  This project uses the [PEP 8 – Style Guide for Python Code](https://peps.python.org/pep-0008/) as coding convention, with the
4
4
  following customization:
5
5
 
6
- - Code line length: 120
6
+ - Code line length: 88
7
7
  - Use double quotes as default (don't mix and match for simple quoting, checked with pylint).
8
+ - Configuration:
9
+ - `pyproject.toml` for pylint and isort
10
+ - `setup.cfg` for flake8
8
11
 
9
12
  ## Tooling
10
13
 
@@ -44,7 +47,7 @@ python3 -m isort ucapi/.
44
47
  Source code is formatted with the [Black code formatting tool](https://github.com/psf/black):
45
48
 
46
49
  ```shell
47
- python3 -m black ucapi --line-length 120
50
+ python3 -m black ucapi
48
51
  ```
49
52
 
50
53
  PyCharm/IntelliJ IDEA integration:
@@ -19,6 +19,12 @@ to start with an integration driver for the Remote Two.
19
19
 
20
20
  It defines a single push button with a callback handler. When pushed, it just prints a message in the console.
21
21
 
22
+ ## remote
23
+
24
+ The [remote.py](remote.py) example shows how to use the [remote-entity](https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_remote.md).
25
+
26
+ It defines some simple commands, a custom button mapping and user interface pages for the available commands.
27
+
22
28
  ## setup_flow
23
29
 
24
30
  The [setup_flow](setup_flow.py) example shows how to define a dynamic setup flow for the driver setup.
@@ -0,0 +1,18 @@
1
+ {
2
+ "driver_id": "remote_test",
3
+ "version": "0.0.1",
4
+ "min_core_api": "0.20.0",
5
+ "name": { "en": "Remote test" },
6
+ "icon": "uc:integration",
7
+ "description": {
8
+ "en": "Minimal Python integration driver example with a remote entity."
9
+ },
10
+ "port": 9084,
11
+ "developer": {
12
+ "name": "Unfolded Circle ApS",
13
+ "email": "hello@unfoldedcircle.com",
14
+ "url": "https://www.unfoldedcircle.com"
15
+ },
16
+ "home_page": "https://www.unfoldedcircle.com",
17
+ "release_date": "2024-04-08"
18
+ }
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Remote entity integration example. Bare minimum of an integration driver."""
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import sys
7
+ from typing import Any
8
+
9
+ import ucapi
10
+ from ucapi import remote
11
+ from ucapi.remote import *
12
+ from ucapi.remote import create_send_cmd, create_sequence_cmd
13
+ from ucapi.ui import (
14
+ Buttons,
15
+ DeviceButtonMapping,
16
+ Size,
17
+ UiPage,
18
+ create_btn_mapping,
19
+ create_ui_icon,
20
+ create_ui_text,
21
+ )
22
+
23
+ loop = asyncio.get_event_loop()
24
+ api = ucapi.IntegrationAPI(loop)
25
+
26
+ # Simple commands which are supported by this example remote-entity
27
+ supported_commands = [
28
+ "VOLUME_UP",
29
+ "VOLUME_DOWN",
30
+ "HOME",
31
+ "GUIDE",
32
+ "CONTEXT_MENU",
33
+ "CURSOR_UP",
34
+ "CURSOR_DOWN",
35
+ "CURSOR_LEFT",
36
+ "CURSOR_RIGHT",
37
+ "CURSOR_ENTER",
38
+ "MY_RECORDINGS",
39
+ "MY_APPS",
40
+ "REVERSE",
41
+ "PLAY",
42
+ "PAUSE",
43
+ "FORWARD",
44
+ "RECORD",
45
+ ]
46
+
47
+
48
+ async def cmd_handler(
49
+ entity: ucapi.Remote, cmd_id: str, params: dict[str, Any] | None
50
+ ) -> ucapi.StatusCodes:
51
+ """
52
+ Remote command handler.
53
+
54
+ Called by the integration-API if a command is sent to a configured remote-entity.
55
+
56
+ :param entity: remote entity
57
+ :param cmd_id: command
58
+ :param params: optional command parameters
59
+ :return: status of the command
60
+ """
61
+ print(f"Got {entity.id} command request: {cmd_id}")
62
+
63
+ state = None
64
+ match cmd_id:
65
+ case remote.Commands.ON:
66
+ state = remote.States.ON
67
+ case remote.Commands.OFF:
68
+ state = remote.States.OFF
69
+ case remote.Commands.TOGGLE:
70
+ if entity.attributes[remote.Attributes.STATE] == remote.States.OFF:
71
+ state = remote.States.ON
72
+ else:
73
+ state = remote.States.OFF
74
+ case remote.Commands.SEND_CMD:
75
+ command = params.get("command")
76
+ # It's up to the integration what to do with an unknown command.
77
+ # If the supported commands are provided as simple_commands, then it's
78
+ # easy to validate.
79
+ if command not in supported_commands:
80
+ print(f"Unknown command: {command}", file=sys.stderr)
81
+ return ucapi.StatusCodes.BAD_REQUEST
82
+
83
+ repeat = params.get("repeat", 1)
84
+ delay = params.get("delay", 0)
85
+ hold = params.get("hold", 0)
86
+ print(f"Command: {command} (repeat={repeat}, delay={delay}, hold={hold})")
87
+ case remote.Commands.SEND_CMD_SEQUENCE:
88
+ sequence = params.get("sequence")
89
+ repeat = params.get("repeat", 1)
90
+ delay = params.get("delay", 0)
91
+ hold = params.get("hold", 0)
92
+ print(
93
+ f"Command sequence: {sequence} (repeat={repeat}, delay={delay}, hold={hold})"
94
+ )
95
+ case _:
96
+ print(f"Unsupported command: {cmd_id}", file=sys.stderr)
97
+ return ucapi.StatusCodes.BAD_REQUEST
98
+
99
+ if state:
100
+ api.configured_entities.update_attributes(
101
+ entity.id, {remote.Attributes.STATE: state}
102
+ )
103
+
104
+ return ucapi.StatusCodes.OK
105
+
106
+
107
+ @api.listens_to(ucapi.Events.CONNECT)
108
+ async def on_connect() -> None:
109
+ """When the UCR2 connects, send the device state."""
110
+ # This example is ready all the time!
111
+ await api.set_device_state(ucapi.DeviceStates.CONNECTED)
112
+
113
+
114
+ def create_button_mappings() -> list[DeviceButtonMapping | dict[str, Any]]:
115
+ """Create a demo button mapping showing different composition options."""
116
+ return [
117
+ # simple short- and long-press mapping
118
+ create_btn_mapping(Buttons.HOME, "HOME", "GUIDE"),
119
+ # use channel buttons for volume control
120
+ create_btn_mapping(Buttons.CHANNEL_DOWN, "VOLUME_DOWN"),
121
+ create_btn_mapping(Buttons.CHANNEL_UP, "VOLUME_UP"),
122
+ create_btn_mapping(Buttons.DPAD_UP, "CURSOR_UP"),
123
+ create_btn_mapping(Buttons.DPAD_DOWN, "CURSOR_DOWN"),
124
+ create_btn_mapping(Buttons.DPAD_LEFT, "CURSOR_LEFT"),
125
+ create_btn_mapping(Buttons.DPAD_RIGHT, "CURSOR_RIGHT"),
126
+ # use a send command
127
+ create_btn_mapping(
128
+ Buttons.DPAD_MIDDLE, create_send_cmd("CONTEXT_MENU", hold=1000)
129
+ ),
130
+ # use a sequence command
131
+ create_btn_mapping(
132
+ Buttons.BLUE,
133
+ create_sequence_cmd(
134
+ [
135
+ "CURSOR_UP",
136
+ "CURSOR_RIGHT",
137
+ "CURSOR_DOWN",
138
+ "CURSOR_LEFT",
139
+ ],
140
+ delay=200,
141
+ ),
142
+ ),
143
+ # Safety off: don't use a DeviceButtonMapping data class but a dictionary.
144
+ # This is useful for directly reading a json configuration file.
145
+ {"button": "POWER", "short_press": {"cmd_id": "remote.toggle"}},
146
+ ]
147
+
148
+
149
+ def create_ui() -> list[UiPage | dict[str, Any]]:
150
+ """Create a demo user interface showing different composition options."""
151
+ # Safety off again: directly use json structure to read a configuration file
152
+ with open("remote_ui_page.json", "r", encoding="utf-8") as file:
153
+ main_page = json.load(file)
154
+
155
+ # On-the-fly UI composition
156
+ ui_page1 = UiPage("page1", "Main")
157
+ ui_page1.add(create_ui_text("Hello remote entity", 0, 0, size=Size(4, 1)))
158
+ ui_page1.add(create_ui_icon("uc:home", 0, 2, cmd="HOME"))
159
+ ui_page1.add(create_ui_icon("uc:up-arrow-bold", 2, 2, cmd="CURSOR_UP"))
160
+ ui_page1.add(create_ui_icon("uc:down-arrow-bold", 2, 4, cmd="CURSOR_DOWN"))
161
+ ui_page1.add(create_ui_icon("uc:left-arrow", 1, 3, cmd="CURSOR_LEFT"))
162
+ ui_page1.add(create_ui_icon("uc:right-arrow", 3, 3, cmd="CURSOR_RIGHT"))
163
+ ui_page1.add(create_ui_text("Ok", 2, 3, cmd="CURSOR_ENTER"))
164
+
165
+ ui_page2 = UiPage("page2", "Page 2")
166
+ ui_page2.add(
167
+ create_ui_text(
168
+ "Pump up the volume!",
169
+ 0,
170
+ 0,
171
+ size=Size(4, 2),
172
+ cmd=create_send_cmd("VOLUME_UP", repeat=5),
173
+ )
174
+ )
175
+ ui_page2.add(
176
+ create_ui_text(
177
+ "Test sequence",
178
+ 0,
179
+ 4,
180
+ size=Size(4, 1),
181
+ cmd=create_sequence_cmd(
182
+ [
183
+ "CURSOR_UP",
184
+ "CURSOR_RIGHT",
185
+ "CURSOR_DOWN",
186
+ "CURSOR_LEFT",
187
+ ],
188
+ delay=200,
189
+ ),
190
+ )
191
+ )
192
+ ui_page2.add(create_ui_text("On", 0, 5, cmd="on"))
193
+ ui_page2.add(create_ui_text("Off", 1, 5, cmd="off"))
194
+
195
+ return [main_page, ui_page1, ui_page2]
196
+
197
+
198
+ if __name__ == "__main__":
199
+ logging.basicConfig()
200
+
201
+ entity = ucapi.Remote(
202
+ "remote1",
203
+ "Demo remote",
204
+ [remote.Features.ON_OFF, remote.Features.TOGGLE],
205
+ {remote.Attributes.STATE: remote.States.OFF},
206
+ simple_commands=supported_commands,
207
+ button_mapping=create_button_mappings(),
208
+ ui_pages=create_ui(),
209
+ cmd_handler=cmd_handler,
210
+ )
211
+ api.available_entities.add(entity)
212
+
213
+ loop.run_until_complete(api.init("remote.json"))
214
+ loop.run_forever()
@@ -0,0 +1,65 @@
1
+ {
2
+ "page_id": "media",
3
+ "name": "Media",
4
+ "grid": { "width": 4, "height": 6 },
5
+ "items": [
6
+ {
7
+ "type": "text",
8
+ "text": "Recordings",
9
+ "command": {
10
+ "cmd_id": "MY_RECORDINGS"
11
+ },
12
+ "location": { "x": 0, "y": 2 },
13
+ "size": { "width": 2, "height": 1 }
14
+ },
15
+ {
16
+ "type": "text",
17
+ "text": "Apps",
18
+ "command": {
19
+ "cmd_id": "MY_APPS"
20
+ },
21
+ "location": { "x": 2, "y": 2 },
22
+ "size": { "width": 2, "height": 1 }
23
+ },
24
+ {
25
+ "type": "icon",
26
+ "icon": "uc:bw",
27
+ "command": {
28
+ "cmd_id": "REVERSE"
29
+ },
30
+ "location": { "x": 0, "y": 5 }
31
+ },
32
+ {
33
+ "type": "icon",
34
+ "icon": "uc:play",
35
+ "command": {
36
+ "cmd_id": "PLAY"
37
+ },
38
+ "location": { "x": 1, "y": 5 }
39
+ },
40
+ {
41
+ "type": "icon",
42
+ "icon": "uc:pause",
43
+ "command": {
44
+ "cmd_id": "PAUSE"
45
+ },
46
+ "location": { "x": 2, "y": 5 }
47
+ },
48
+ {
49
+ "type": "icon",
50
+ "icon": "uc:ff",
51
+ "command": {
52
+ "cmd_id": "FORWARD"
53
+ },
54
+ "location": { "x": 3, "y": 5 }
55
+ },
56
+ {
57
+ "type": "icon",
58
+ "icon": "uc:rec",
59
+ "command": {
60
+ "cmd_id": "RECORD"
61
+ },
62
+ "location": { "x": 2, "y": 4 }
63
+ }
64
+ ]
65
+ }
@@ -43,6 +43,10 @@ async def handle_driver_setup(
43
43
  :param msg: value(s) of input fields in the first setup screen.
44
44
  :return: the setup action on how to continue
45
45
  """
46
+ # No support for reconfiguration :-)
47
+ if msg.reconfigure:
48
+ print("Ignoring driver reconfiguration request")
49
+
46
50
  # For our demo we simply clear everything!
47
51
  # A real driver might have to handle this differently
48
52
  api.available_entities.clear()
@@ -51,6 +55,13 @@ async def handle_driver_setup(
51
55
  # check if user selected the expert option in the initial setup screen
52
56
  # please note that all values are returned as strings!
53
57
  if "expert" not in msg.setup_data or msg.setup_data["expert"] != "true":
58
+ # add a single button as default action
59
+ button = ucapi.Button(
60
+ "button",
61
+ "Button",
62
+ cmd_handler=cmd_handler,
63
+ )
64
+ api.available_entities.add(button)
54
65
  return ucapi.SetupComplete()
55
66
 
56
67
  # Dropdown selections are usually set dynamically, e.g. with found devices etc.
@@ -23,7 +23,7 @@ requires-python = ">=3.10"
23
23
  dependencies = [
24
24
  "pyee>=9.0",
25
25
  "websockets>=11.0",
26
- "zeroconf~=0.120.0",
26
+ "zeroconf>=0.120.0",
27
27
  ]
28
28
  dynamic = ["version"]
29
29
 
@@ -4,4 +4,4 @@
4
4
 
5
5
  pyee>=9.0
6
6
  websockets>=11.0
7
- zeroconf~=0.120.0
7
+ zeroconf>=0.120.0
@@ -1,5 +1,6 @@
1
1
  [flake8]
2
2
  max-line-length = 88
3
+ extend-ignore = E501
3
4
 
4
5
  [egg_info]
5
6
  tag_build =
@@ -35,6 +35,7 @@ from .climate import Climate # noqa: F401
35
35
  from .cover import Cover # noqa: F401
36
36
  from .light import Light # noqa: F401
37
37
  from .media_player import MediaPlayer # noqa: F401
38
+ from .remote import Remote # noqa: F401
38
39
  from .sensor import Sensor # noqa: F401
39
40
  from .switch import Switch # noqa: F401
40
41
 
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.1.3'
16
- __version_tuple__ = version_tuple = (0, 1, 3)
15
+ __version__ = version = '0.2.0'
16
+ __version_tuple__ = version_tuple = (0, 2, 0)
@@ -25,6 +25,7 @@ from zeroconf import IPVersion
25
25
  from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
26
26
 
27
27
  import ucapi.api_definitions as uc
28
+ from ucapi import media_player
28
29
  from ucapi.entities import Entities
29
30
 
30
31
  _LOG = logging.getLogger(__name__)
@@ -258,10 +259,14 @@ class IntegrationAPI:
258
259
  :param category: event category
259
260
  """
260
261
  data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category}
262
+ data_dump = json.dumps(data)
263
+ # filter fields
264
+ if _LOG.isEnabledFor(logging.DEBUG):
265
+ data_log = json.dumps(data) if filter_log_msg_data(data) else data_dump
261
266
 
262
267
  for websocket in self._clients:
263
- data_dump = json.dumps(data)
264
- _LOG.debug("[%s] ->: %s", websocket.remote_address, data_dump)
268
+ if _LOG.isEnabledFor(logging.DEBUG):
269
+ _LOG.debug("[%s] ->: %s", websocket.remote_address, data_log)
265
270
  try:
266
271
  await websocket.send(data_dump)
267
272
  except websockets.exceptions.WebSocketException:
@@ -282,10 +287,12 @@ class IntegrationAPI:
282
287
  websockets.ConnectionClosed: When the connection is closed.
283
288
  """
284
289
  data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category}
290
+ data_dump = json.dumps(data)
285
291
 
286
292
  if websocket in self._clients:
287
- data_dump = json.dumps(data)
288
- _LOG.debug("[%s] ->: %s", websocket.remote_address, data_dump)
293
+ if _LOG.isEnabledFor(logging.DEBUG):
294
+ data_log = json.dumps(data) if filter_log_msg_data(data) else data_dump
295
+ _LOG.debug("[%s] ->: %s", websocket.remote_address, data_log)
289
296
  await websocket.send(data_dump)
290
297
  else:
291
298
  _LOG.error("Error sending event: connection no longer established")
@@ -378,8 +385,8 @@ class IntegrationAPI:
378
385
  elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP:
379
386
  if not self._setup_handler:
380
387
  _LOG.warning(
381
- "Received abort_driver_setup event, but no setup handler provided by the driver!" # noqa
382
- )
388
+ "Received abort_driver_setup event, but no setup handler provided by the driver!"
389
+ ) # noqa
383
390
  return
384
391
 
385
392
  if "error" in msg_data:
@@ -413,15 +420,19 @@ class IntegrationAPI:
413
420
  }
414
421
 
415
422
  async def set_device_state(self, state: uc.DeviceStates) -> None:
416
- """Set new device state and notify all connected clients."""
417
- if self._state != state:
418
- self._state = state
423
+ """
424
+ Set new device state and notify all connected clients.
419
425
 
420
- await self._broadcast_ws_event(
421
- uc.WsMsgEvents.DEVICE_STATE,
422
- {"state": self.device_state},
423
- uc.EventCategory.DEVICE,
424
- )
426
+ Attention: clients are always notified, even if the current state is the same as
427
+ the new state!
428
+ """
429
+ self._state = state
430
+
431
+ await self._broadcast_ws_event(
432
+ uc.WsMsgEvents.DEVICE_STATE,
433
+ {"state": self.device_state},
434
+ uc.EventCategory.DEVICE,
435
+ )
425
436
 
426
437
  async def _subscribe_events(self, msg_data: dict[str, Any] | None) -> None:
427
438
  if msg_data is None:
@@ -501,14 +512,16 @@ class IntegrationAPI:
501
512
  # make sure integration driver installed a setup handler
502
513
  if not self._setup_handler:
503
514
  _LOG.error(
504
- "Received setup_driver request, but no setup handler provided by the driver!" # noqa
505
- )
515
+ "Received setup_driver request, but no setup handler provided by the driver!"
516
+ ) # noqa
506
517
  return False
507
518
 
508
519
  result = False
509
520
  try:
510
521
  action = await self._setup_handler(
511
- uc.DriverSetupRequest(msg_data["setup_data"])
522
+ uc.DriverSetupRequest(
523
+ msg_data.get("reconfigure") or False, msg_data["setup_data"]
524
+ )
512
525
  )
513
526
 
514
527
  if isinstance(action, uc.RequestUserInput):
@@ -543,8 +556,8 @@ class IntegrationAPI:
543
556
 
544
557
  if not self._setup_handler:
545
558
  _LOG.error(
546
- "Received set_driver_user_data request, but no setup handler provided by the driver!" # noqa
547
- )
559
+ "Received set_driver_user_data request, but no setup handler provided by the driver!"
560
+ ) # noqa
548
561
  return False
549
562
 
550
563
  if "input_values" in msg_data or "confirm" in msg_data:
@@ -837,3 +850,29 @@ def local_hostname() -> str:
837
850
  os.getenv("UC_MDNS_LOCAL_HOSTNAME")
838
851
  or f"{socket.gethostname().split('.', 1)[0]}.local."
839
852
  )
853
+
854
+
855
+ def filter_log_msg_data(data: dict[str, Any]) -> bool:
856
+ """
857
+ Filter attribute fields to exclude for log messages in the given msg data dict.
858
+
859
+ Attention: the dictionary is modified!
860
+
861
+ - Attributes are filtered in `data["msg_data"]["attributes"]`
862
+ - Filtered attributes: `MEDIA_IMAGE_URL`
863
+
864
+ :param data: the message data dict
865
+ :return: True if a field was filtered, False otherwise
866
+ """
867
+ # filter out base64 encoded images in the media player's media_image_url attribute
868
+ if (
869
+ "msg_data" in data
870
+ and "attributes" in data["msg_data"]
871
+ and media_player.Attributes.MEDIA_IMAGE_URL in data["msg_data"]["attributes"]
872
+ and data["msg_data"]["attributes"][
873
+ media_player.Attributes.MEDIA_IMAGE_URL
874
+ ].startswith("data:")
875
+ ):
876
+ data["msg_data"]["attributes"][media_player.Attributes.MEDIA_IMAGE_URL] = "***"
877
+ return True
878
+ return False
@@ -4,6 +4,7 @@ API definitions.
4
4
  :copyright: (c) 2023 by Unfolded Circle ApS.
5
5
  :license: MPL-2.0, see LICENSE for more details.
6
6
  """
7
+
7
8
  from dataclasses import dataclass
8
9
  from enum import Enum, IntEnum
9
10
  from typing import Any, Awaitable, Callable, TypeAlias
@@ -118,6 +119,7 @@ class DriverSetupRequest(SetupDriver):
118
119
  identifier, value contains the input value.
119
120
  """
120
121
 
122
+ reconfigure: bool
121
123
  setup_data: dict[str, str]
122
124
 
123
125
 
@@ -59,8 +59,6 @@ class Button(Entity):
59
59
  EntityTypes.BUTTON,
60
60
  ["press"],
61
61
  {Attributes.STATE: States.AVAILABLE},
62
- None,
63
- None,
64
- area,
65
- cmd_handler,
62
+ area=area,
63
+ cmd_handler=cmd_handler,
66
64
  )
@@ -102,9 +102,13 @@ class Entities:
102
102
  "device_id": entity.device_id,
103
103
  "features": entity.features,
104
104
  "name": entity.name,
105
- "area": entity.area,
106
- "device_class": entity.device_class,
107
105
  }
106
+ if entity.device_class:
107
+ res["device_class"] = entity.device_class
108
+ if entity.options:
109
+ res["options"] = entity.options
110
+ if entity.area:
111
+ res["area"] = entity.area
108
112
 
109
113
  entities.append(res)
110
114
 
@@ -23,6 +23,7 @@ class EntityTypes(str, Enum):
23
23
  CLIMATE = "climate"
24
24
  LIGHT = "light"
25
25
  MEDIA_PLAYER = "media_player"
26
+ REMOTE = "remote"
26
27
  SENSOR = "sensor"
27
28
  SWITCH = "switch"
28
29
 
@@ -42,8 +43,8 @@ class Entity:
42
43
  entity_type: EntityTypes,
43
44
  features: list[str],
44
45
  attributes: dict[str, Any],
45
- device_class: str | None,
46
- options: dict[str, Any] | None,
46
+ device_class: str | None = None,
47
+ options: dict[str, Any] | None = None,
47
48
  area: str | None = None,
48
49
  cmd_handler: CommandHandler = None,
49
50
  ):
@@ -52,12 +52,22 @@ class Features(str, Enum):
52
52
  MEDIA_IMAGE_URL = "media_image_url"
53
53
  MEDIA_TYPE = "media_type"
54
54
  DPAD = "dpad"
55
+ NUMPAD = "numpad"
55
56
  HOME = "home"
56
57
  MENU = "menu"
58
+ CONTEXT_MENU = "context_menu"
59
+ GUIDE = "guide"
60
+ INFO = "info"
57
61
  COLOR_BUTTONS = "color_buttons"
58
62
  CHANNEL_SWITCHER = "channel_switcher"
59
63
  SELECT_SOURCE = "select_source"
60
64
  SELECT_SOUND_MODE = "select_sound_mode"
65
+ EJECT = "eject"
66
+ OPEN_CLOSE = "open_close"
67
+ AUDIO_TRACK = "audio_track"
68
+ SUBTITLE = "subtitle"
69
+ RECORD = "record"
70
+ SETTINGS = "settings"
61
71
 
62
72
 
63
73
  class Attributes(str, Enum):
@@ -109,15 +119,36 @@ class Commands(str, Enum):
109
119
  CURSOR_LEFT = "cursor_left"
110
120
  CURSOR_RIGHT = "cursor_right"
111
121
  CURSOR_ENTER = "cursor_enter"
122
+ DIGIT_0 = "digit_0"
123
+ DIGIT_1 = "digit_1"
124
+ DIGIT_2 = "digit_2"
125
+ DIGIT_3 = "digit_3"
126
+ DIGIT_4 = "digit_4"
127
+ DIGIT_5 = "digit_5"
128
+ DIGIT_6 = "digit_6"
129
+ DIGIT_7 = "digit_7"
130
+ DIGIT_8 = "digit_8"
131
+ DIGIT_9 = "digit_9"
112
132
  FUNCTION_RED = "function_red"
113
133
  FUNCTION_GREEN = "function_green"
114
134
  FUNCTION_YELLOW = "function_yellow"
115
135
  FUNCTION_BLUE = "function_blue"
116
136
  HOME = "home"
117
137
  MENU = "menu"
138
+ CONTEXT_MENU = "context_menu"
139
+ GUIDE = "guide"
140
+ INFO = "info"
118
141
  BACK = "back"
119
142
  SELECT_SOURCE = "select_source"
120
143
  SELECT_SOUND_MODE = "select_sound_mode"
144
+ RECORD = "record"
145
+ MY_RECORDINGS = "my_recordings"
146
+ LIVE = "live"
147
+ EJECT = "eject"
148
+ OPEN_CLOSE = "open_close"
149
+ AUDIO_TRACK = "audio_track"
150
+ SUBTITLE = "subtitle"
151
+ SETTINGS = "settings"
121
152
  SEARCH = "search"
122
153
 
123
154
 
@@ -134,6 +165,7 @@ class DeviceClasses(str, Enum):
134
165
  class Options(str, Enum):
135
166
  """Media-player entity options."""
136
167
 
168
+ SIMPLE_COMMANDS = "simple_commands"
137
169
  VOLUME_STEPS = "volume_steps"
138
170
 
139
171
 
@@ -147,6 +179,14 @@ class MediaType(str, Enum):
147
179
  VIDEO = "VIDEO"
148
180
 
149
181
 
182
+ class RepeatMode(str, Enum):
183
+ """Repeat modes."""
184
+
185
+ OFF = "OFF"
186
+ ALL = "ALL"
187
+ ONE = "ONE"
188
+
189
+
150
190
  class MediaPlayer(Entity):
151
191
  """
152
192
  Media-player entity class.
@@ -0,0 +1,167 @@
1
+ """
2
+ Remote entity definitions.
3
+
4
+ :copyright: (c) 2024 by Unfolded Circle ApS.
5
+ :license: MPL-2.0, see LICENSE for more details.
6
+ """
7
+
8
+ import dataclasses
9
+ from enum import Enum
10
+ from typing import Any
11
+
12
+ from ucapi.api_definitions import CommandHandler
13
+ from ucapi.entity import Entity, EntityTypes
14
+ from ucapi.ui import DeviceButtonMapping, EntityCommand, UiPage
15
+
16
+
17
+ class States(str, Enum):
18
+ """Remote entity states."""
19
+
20
+ UNAVAILABLE = "UNAVAILABLE"
21
+ UNKNOWN = "UNKNOWN"
22
+ ON = "ON"
23
+ OFF = "OFF"
24
+
25
+
26
+ class Features(str, Enum):
27
+ """Remote entity features."""
28
+
29
+ ON_OFF = "on_off"
30
+ TOGGLE = "toggle"
31
+ SEND_CMD = "send_cmd"
32
+
33
+
34
+ class Attributes(str, Enum):
35
+ """Remote entity attributes."""
36
+
37
+ STATE = "state"
38
+
39
+
40
+ class Commands(str, Enum):
41
+ """Remote entity commands."""
42
+
43
+ ON = "on"
44
+ OFF = "off"
45
+ TOGGLE = "toggle"
46
+ SEND_CMD = "send_cmd"
47
+ SEND_CMD_SEQUENCE = "send_cmd_sequence"
48
+
49
+
50
+ class Options(str, Enum):
51
+ """Remote entity options."""
52
+
53
+ SIMPLE_COMMANDS = "simple_commands"
54
+ BUTTON_MAPPING = "button_mapping"
55
+ USER_INTERFACE = "user_interface"
56
+
57
+
58
+ def create_send_cmd(
59
+ command: str,
60
+ delay: int | None = None,
61
+ repeat: int | None = None,
62
+ hold: int | None = None,
63
+ ) -> EntityCommand:
64
+ """
65
+ Create a remote send command.
66
+
67
+ :param command: command to send.
68
+ :param delay: optional delay in milliseconds after the command or between repeats.
69
+ :param repeat: optional repeat count of the command.
70
+ :param hold: optional hold time in milliseconds.
71
+ :return: the created EntityCommand.
72
+ """
73
+ params = {"command": command}
74
+ if delay:
75
+ params["delay"] = delay
76
+ if repeat:
77
+ params["repeat"] = repeat
78
+ if hold:
79
+ params["hold"] = hold
80
+ return EntityCommand(Commands.SEND_CMD.value, params)
81
+
82
+
83
+ def create_sequence_cmd(
84
+ sequence: list[str],
85
+ delay: int | None = None,
86
+ repeat: int | None = None,
87
+ ) -> EntityCommand:
88
+ """
89
+ Create a remote send sequence command.
90
+
91
+ :param sequence: list of simple commands.
92
+ :param delay: optional delay in milliseconds between the commands in the sequence.
93
+ :param repeat: optional repeat count of the sequence.
94
+ :return: the created EntityCommand.
95
+ """
96
+ params = {"sequence": sequence}
97
+ if delay:
98
+ params["delay"] = delay
99
+ if repeat:
100
+ params["repeat"] = repeat
101
+ return EntityCommand(Commands.SEND_CMD_SEQUENCE.value, params)
102
+
103
+
104
+ def _list_items_asdict(obj: list[Any]):
105
+ """Convert a list with (mixed) dataclass items to dictionary mapping items."""
106
+ return list(
107
+ map(
108
+ lambda item: (
109
+ dataclasses.asdict(item) if dataclasses.is_dataclass(item) else item
110
+ ),
111
+ obj,
112
+ )
113
+ )
114
+
115
+
116
+ class Remote(Entity):
117
+ """
118
+ Remote entity class.
119
+
120
+ See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_remote.md
121
+ for more information.
122
+ """ # noqa
123
+
124
+ def __init__(
125
+ self,
126
+ identifier: str,
127
+ name: str | dict[str, str],
128
+ features: list[Features],
129
+ attributes: dict[str, Any],
130
+ simple_commands: list[str] | None = None,
131
+ button_mapping: list[DeviceButtonMapping | dict[str, Any]] | None = None,
132
+ ui_pages: list[UiPage | dict[str, Any]] | None = None,
133
+ area: str | None = None,
134
+ cmd_handler: CommandHandler = None,
135
+ ):
136
+ """
137
+ Create remote entity instance.
138
+
139
+ :param identifier: entity identifier
140
+ :param name: friendly name
141
+ :param features: remote features
142
+ :param attributes: remote attributes
143
+ :param simple_commands: optional list of supported remote command identifiers
144
+ :param button_mapping: optional command mapping of physical buttons
145
+ Either with DeviceButtonMapping items or plain dictionary items.
146
+ :param ui_pages: optional user interface page definitions.
147
+ Either with UiPage items or plain dictionary items.
148
+ :param area: optional area
149
+ :param cmd_handler: handler for entity commands
150
+ """
151
+ options: dict[str, Any] = {}
152
+ if simple_commands:
153
+ options["simple_commands"] = simple_commands
154
+ if button_mapping:
155
+ options["button_mapping"] = _list_items_asdict(button_mapping)
156
+ if ui_pages:
157
+ options["user_interface"] = {"pages": _list_items_asdict(ui_pages)}
158
+ super().__init__(
159
+ identifier,
160
+ name,
161
+ EntityTypes.REMOTE,
162
+ features,
163
+ attributes,
164
+ options=options,
165
+ area=area,
166
+ cmd_handler=cmd_handler,
167
+ )
@@ -0,0 +1,191 @@
1
+ """
2
+ User interface definitions.
3
+
4
+ :copyright: (c) 2024 by Unfolded Circle ApS.
5
+ :license: MPL-2.0, see LICENSE for more details.
6
+ """
7
+
8
+ from dataclasses import KW_ONLY, dataclass
9
+ from enum import Enum
10
+
11
+
12
+ @dataclass
13
+ class EntityCommand:
14
+ """Remote command definition for a button mapping or UI page definition."""
15
+
16
+ cmd_id: str
17
+ params: dict[str, str | int | list[str]] | None = None
18
+
19
+
20
+ class Buttons(str, Enum):
21
+ """Physical buttons."""
22
+
23
+ BACK = "BACK"
24
+ HOME = "HOME"
25
+ VOICE = "VOICE"
26
+ VOLUME_UP = "VOLUME_UP"
27
+ VOLUME_DOWN = "VOLUME_DOWN"
28
+ MUTE = "MUTE"
29
+ DPAD_UP = "DPAD_UP"
30
+ DPAD_DOWN = "DPAD_DOWN"
31
+ DPAD_LEFT = "DPAD_LEFT"
32
+ DPAD_RIGHT = "DPAD_RIGHT"
33
+ DPAD_MIDDLE = "DPAD_MIDDLE"
34
+ GREEN = "GREEN"
35
+ YELLOW = "YELLOW"
36
+ RED = "RED"
37
+ BLUE = "BLUE"
38
+ CHANNEL_UP = "CHANNEL_UP"
39
+ CHANNEL_DOWN = "CHANNEL_DOWN"
40
+ PREV = "PREV"
41
+ PLAY = "PLAY"
42
+ NEXT = "NEXT"
43
+ POWER = "POWER"
44
+
45
+
46
+ @dataclass
47
+ class DeviceButtonMapping:
48
+ """Physical button command mapping."""
49
+
50
+ button: str
51
+ """Physical button identifier. See Buttons for Remote Two identifiers."""
52
+ short_press: EntityCommand | None = None
53
+ """Short press command of the button."""
54
+ long_press: EntityCommand | None = None
55
+ """Long press command of the button."""
56
+
57
+
58
+ def create_btn_mapping(
59
+ button: Buttons,
60
+ short: str | EntityCommand | None = None,
61
+ long: str | EntityCommand | None = None,
62
+ ) -> DeviceButtonMapping:
63
+ """
64
+ Create a physical button command mapping.
65
+
66
+ :param button: physical button identifier.
67
+ :param short: associated short-press command to the physical button.
68
+ A string parameter corresponds to a simple command, whereas an
69
+ ``EntityCommand`` allows to customize the command.
70
+ :param long: associated long-press command to the physical button
71
+ :return: the created DeviceButtonMapping
72
+ """
73
+ if isinstance(short, str):
74
+ short = EntityCommand(short)
75
+ if isinstance(long, str):
76
+ long = EntityCommand(long)
77
+ return DeviceButtonMapping(button.value, short_press=short, long_press=long)
78
+
79
+
80
+ @dataclass
81
+ class Size:
82
+ """Item size in the button grid. Default size if not specified: 1x1."""
83
+
84
+ width: int = 1
85
+ height: int = 1
86
+
87
+
88
+ @dataclass
89
+ class Location:
90
+ """Button placement in the grid with 0-based coordinates."""
91
+
92
+ x: int
93
+ y: int
94
+
95
+
96
+ @dataclass
97
+ class UiItem:
98
+ """
99
+ A user interface item is either an icon or text.
100
+
101
+ - Icon and text items can be static or linked to a command specified in the
102
+ `command` field.
103
+ - Default size is 1x1 if not specified.
104
+ """
105
+
106
+ type: str
107
+ location: Location
108
+ size: Size | None = None
109
+ icon: str | None = None
110
+ text: str | None = None
111
+ command: EntityCommand | None = None
112
+
113
+
114
+ def create_ui_text(
115
+ text: str,
116
+ x: int,
117
+ y: int,
118
+ size: Size | None = None,
119
+ cmd: str | EntityCommand | None = None,
120
+ ) -> UiItem:
121
+ """
122
+ Create a text UI item.
123
+
124
+ :param text: the text to show in the UI item.
125
+ :param x: x-position, 0-based.
126
+ :param y: y-position, 0-based.
127
+ :param size: item size, defaults to 1 x 1 if not specified.
128
+ :param cmd: associated command to the text item. A string parameter corresponds to
129
+ a simple command, whereas an ``EntityCommand`` allows to customize the
130
+ command for example with number of repeats.
131
+ :return: the created UiItem
132
+ """
133
+ if isinstance(cmd, str):
134
+ cmd = EntityCommand(cmd)
135
+ return UiItem("text", Location(x, y), size=size, text=text, command=cmd)
136
+
137
+
138
+ def create_ui_icon(
139
+ icon: str,
140
+ x: int,
141
+ y: int,
142
+ size: Size | None = None,
143
+ cmd: str | EntityCommand | None = None,
144
+ ) -> UiItem:
145
+ """
146
+ Create an icon UI item.
147
+
148
+ The icon identifier consists of a prefix and a resource identifier,
149
+ separated by `:`. Available prefixes:
150
+ - `uc:` - integrated icon font
151
+ - `custom:` - custom resource
152
+
153
+ :param icon: the icon identifier of the icon to show in the UI item.
154
+ :param x: x-position, 0-based.
155
+ :param y: y-position, 0-based.
156
+ :param size: item size, defaults to 1 x 1 if not specified.
157
+ :param cmd: associated command to the text item. A string parameter corresponds to
158
+ a simple command, whereas an ``EntityCommand`` allows to customize the
159
+ command for example with number of repeats.
160
+ :return: the created UiItem
161
+ """
162
+ if isinstance(cmd, str):
163
+ cmd = EntityCommand(cmd)
164
+ return UiItem("icon", Location(x, y), size=size, icon=icon, command=cmd)
165
+
166
+
167
+ @dataclass
168
+ class UiPage:
169
+ """
170
+ Definition of a complete user interface page.
171
+
172
+ Default grid size is 4x6 if not specified.
173
+ """
174
+
175
+ page_id: str
176
+ name: str
177
+ _: KW_ONLY
178
+ grid: Size = None
179
+ items: list[UiItem] = None
180
+
181
+ def __post_init__(self):
182
+ """Post initialization to set required fields."""
183
+ # grid and items are required Integration-API fields
184
+ if self.grid is None:
185
+ self.grid = Size(4, 6)
186
+ if self.items is None:
187
+ self.items = []
188
+
189
+ def add(self, item: UiItem):
190
+ """Append the given UiItem to the page items."""
191
+ self.items.append(item)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ucapi
3
- Version: 0.1.3
3
+ Version: 0.2.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
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown; charset=UTF-8
23
23
  License-File: LICENSE
24
24
  Requires-Dist: pyee>=9.0
25
25
  Requires-Dist: websockets>=11.0
26
- Requires-Dist: zeroconf~=0.120.0
26
+ Requires-Dist: zeroconf>=0.120.0
27
27
  Provides-Extra: testing
28
28
  Requires-Dist: pylint; extra == "testing"
29
29
  Requires-Dist: flake8-docstrings; extra == "testing"
@@ -11,6 +11,9 @@ docs/setup.md
11
11
  examples/README.md
12
12
  examples/hello_integration.json
13
13
  examples/hello_integration.py
14
+ examples/remote.json
15
+ examples/remote.py
16
+ examples/remote_ui_page.json
14
17
  examples/setup_flow.json
15
18
  examples/setup_flow.py
16
19
  ucapi/__init__.py
@@ -24,8 +27,10 @@ ucapi/entities.py
24
27
  ucapi/entity.py
25
28
  ucapi/light.py
26
29
  ucapi/media_player.py
30
+ ucapi/remote.py
27
31
  ucapi/sensor.py
28
32
  ucapi/switch.py
33
+ ucapi/ui.py
29
34
  ucapi.egg-info/PKG-INFO
30
35
  ucapi.egg-info/SOURCES.txt
31
36
  ucapi.egg-info/dependency_links.txt
@@ -1,6 +1,6 @@
1
1
  pyee>=9.0
2
2
  websockets>=11.0
3
- zeroconf~=0.120.0
3
+ zeroconf>=0.120.0
4
4
 
5
5
  [testing]
6
6
  pylint
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes