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.
- {ucapi-0.1.3 → ucapi-0.2.0}/CHANGELOG.md +29 -0
- {ucapi-0.1.3/ucapi.egg-info → ucapi-0.2.0}/PKG-INFO +2 -2
- {ucapi-0.1.3 → ucapi-0.2.0}/docs/code_guidelines.md +5 -2
- {ucapi-0.1.3 → ucapi-0.2.0}/examples/README.md +6 -0
- ucapi-0.2.0/examples/remote.json +18 -0
- ucapi-0.2.0/examples/remote.py +214 -0
- ucapi-0.2.0/examples/remote_ui_page.json +65 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/examples/setup_flow.py +11 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/pyproject.toml +1 -1
- {ucapi-0.1.3 → ucapi-0.2.0}/requirements.txt +1 -1
- {ucapi-0.1.3 → ucapi-0.2.0}/setup.cfg +1 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/__init__.py +1 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/_version.py +2 -2
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/api.py +58 -19
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/api_definitions.py +2 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/button.py +2 -4
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/entities.py +6 -2
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/entity.py +3 -2
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/media_player.py +40 -0
- ucapi-0.2.0/ucapi/remote.py +167 -0
- ucapi-0.2.0/ucapi/ui.py +191 -0
- {ucapi-0.1.3 → ucapi-0.2.0/ucapi.egg-info}/PKG-INFO +2 -2
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi.egg-info/SOURCES.txt +5 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi.egg-info/requires.txt +1 -1
- {ucapi-0.1.3 → ucapi-0.2.0}/CONTRIBUTING.md +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/LICENSE +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/README.md +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/docs/setup.md +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/examples/hello_integration.json +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/examples/hello_integration.py +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/examples/setup_flow.json +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/test-requirements.txt +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/climate.py +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/cover.py +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/light.py +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/sensor.py +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi/switch.py +0 -0
- {ucapi-0.1.3 → ucapi-0.2.0}/ucapi.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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!"
|
|
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
|
-
"""
|
|
417
|
-
|
|
418
|
-
self._state = state
|
|
423
|
+
"""
|
|
424
|
+
Set new device state and notify all connected clients.
|
|
419
425
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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!"
|
|
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(
|
|
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!"
|
|
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
|
|
|
@@ -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
|
+
)
|
ucapi-0.2.0/ucapi/ui.py
ADDED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|