ucapi 0.1.7__tar.gz → 0.3.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.7 → ucapi-0.3.0}/CHANGELOG.md +11 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/CONTRIBUTING.md +8 -4
- {ucapi-0.1.7/ucapi.egg-info → ucapi-0.3.0}/PKG-INFO +6 -5
- {ucapi-0.1.7 → ucapi-0.3.0}/README.md +1 -1
- {ucapi-0.1.7 → ucapi-0.3.0}/examples/README.md +6 -0
- ucapi-0.3.0/examples/remote.json +18 -0
- ucapi-0.3.0/examples/remote.py +214 -0
- ucapi-0.3.0/examples/remote_ui_page.json +65 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/pyproject.toml +2 -2
- {ucapi-0.1.7 → ucapi-0.3.0}/requirements.txt +1 -1
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/__init__.py +1 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/_version.py +9 -4
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/api.py +50 -18
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/button.py +2 -4
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/climate.py +2 -1
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/cover.py +2 -1
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/entity.py +4 -2
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/light.py +2 -1
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/media_player.py +3 -1
- ucapi-0.3.0/ucapi/remote.py +168 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/sensor.py +2 -1
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/switch.py +2 -1
- ucapi-0.3.0/ucapi/ui.py +191 -0
- {ucapi-0.1.7 → ucapi-0.3.0/ucapi.egg-info}/PKG-INFO +6 -5
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi.egg-info/SOURCES.txt +5 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi.egg-info/requires.txt +1 -1
- {ucapi-0.1.7 → ucapi-0.3.0}/LICENSE +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/docs/code_guidelines.md +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/docs/setup.md +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/examples/hello_integration.json +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/examples/hello_integration.py +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/examples/setup_flow.json +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/examples/setup_flow.py +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/setup.cfg +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/test-requirements.txt +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/api_definitions.py +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi/entities.py +0 -0
- {ucapi-0.1.7 → ucapi-0.3.0}/ucapi.egg-info/dependency_links.txt +0 -0
- {ucapi-0.1.7 → ucapi-0.3.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.3.0 - 2024-04-25
|
|
15
|
+
### Added
|
|
16
|
+
- New media-player attribute MEDIA_POSITION_UPDATED_AT ([feature-and-bug-tracker#443](https://github.com/unfoldedcircle/feature-and-bug-tracker/issues/443)).
|
|
17
|
+
### Changed
|
|
18
|
+
- Filter out base64 encoded media-player image fields in entity_states response log messages ([#22](https://github.com/unfoldedcircle/integration-python-library/issues/22)).
|
|
19
|
+
- Require websockets version v14 or newer.
|
|
20
|
+
|
|
21
|
+
## v0.2.0 - 2024-04-28
|
|
22
|
+
### Added
|
|
23
|
+
- New remote-entity type. Requires remote-core / Core Simulator version 0.43.0 or newer.
|
|
24
|
+
|
|
14
25
|
## v0.1.7 - 2024-03-13
|
|
15
26
|
### Changed
|
|
16
27
|
- Filter out base64 encoded media-player image fields in log messages ([#17](https://github.com/unfoldedcircle/integration-python-library/issues/17)).
|
|
@@ -25,7 +25,7 @@ We love contributions from everyone.
|
|
|
25
25
|
Either by opening a feature request describing your proposed changes before submitting code, or by contacting us on
|
|
26
26
|
one of the other [feedback channels](#feedback-speech_balloon).
|
|
27
27
|
|
|
28
|
-
Since this library is being used in integration drivers running on the embedded Remote
|
|
28
|
+
Since this library is being used in integration drivers running on the embedded UC Remote devices,
|
|
29
29
|
we have to make sure it remains compatible with the embedded runtime environment and runs smoothly.
|
|
30
30
|
|
|
31
31
|
Submitting pull requests for typos, formatting issues etc. are happily accepted and usually approved relatively quick.
|
|
@@ -36,7 +36,8 @@ With that out of the way, here's the process of creating a pull request and maki
|
|
|
36
36
|
|
|
37
37
|
1. Fork the repo.
|
|
38
38
|
|
|
39
|
-
2. Make your changes or enhancements
|
|
39
|
+
2. Make your changes or enhancements.
|
|
40
|
+
This should be done in a dedicated branch and not on the main branch to easily submit individual pull requests.
|
|
40
41
|
|
|
41
42
|
Contributed code must be licensed under the [Mozilla Public License 2.0](https://choosealicense.com/licenses/mpl-2.0/),
|
|
42
43
|
or a compatible license, if existing parts of other projects are reused (e.g. MIT licensed code).
|
|
@@ -53,13 +54,16 @@ With that out of the way, here's the process of creating a pull request and maki
|
|
|
53
54
|
|
|
54
55
|
3. Make sure your changes follow the project's code style and the lints pass described in [Code Style](docs/code_guidelines.md).
|
|
55
56
|
|
|
56
|
-
4. Push to your fork.
|
|
57
|
+
4. Push to your fork.
|
|
58
|
+
Do not include any project configuration changes in the `.idea` folder! If you are also using an IntelliJ product,
|
|
59
|
+
chances are that you're using a different IDE, version or other settings, which can cause issues.
|
|
60
|
+
For example, between IntelliJ Ultimate and PyCharm.
|
|
57
61
|
|
|
58
62
|
5. Submit a pull request.
|
|
59
63
|
|
|
60
64
|
At this point we will review the PR and give constructive feedback.
|
|
61
65
|
This is a time for discussion and improvements, and making the necessary changes will be required before we can
|
|
62
|
-
merge the contribution.
|
|
66
|
+
merge the contribution. Furthermore, all the automated checks must pass, otherwise the pull request will not be merged.
|
|
63
67
|
|
|
64
68
|
### Feedback :speech_balloon:
|
|
65
69
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: ucapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://www.unfoldedcircle.com/
|
|
|
8
8
|
Project-URL: Source Code, https://github.com/unfoldedcircle/integration-python-library
|
|
9
9
|
Project-URL: Bug Reports, https://github.com/unfoldedcircle/integration-python-library/issues
|
|
10
10
|
Project-URL: Discord, http://unfolded.chat/
|
|
11
|
-
Project-URL: Forum,
|
|
11
|
+
Project-URL: Forum, https://unfolded.community/
|
|
12
12
|
Platform: any
|
|
13
13
|
Classifier: Development Status :: 3 - Alpha
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
@@ -22,7 +22,7 @@ Requires-Python: >=3.10
|
|
|
22
22
|
Description-Content-Type: text/markdown; charset=UTF-8
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: pyee>=9.0
|
|
25
|
-
Requires-Dist: websockets>=
|
|
25
|
+
Requires-Dist: websockets>=14.0
|
|
26
26
|
Requires-Dist: zeroconf>=0.120.0
|
|
27
27
|
Provides-Extra: testing
|
|
28
28
|
Requires-Dist: pylint; extra == "testing"
|
|
@@ -30,13 +30,14 @@ Requires-Dist: flake8-docstrings; extra == "testing"
|
|
|
30
30
|
Requires-Dist: flake8; extra == "testing"
|
|
31
31
|
Requires-Dist: black; extra == "testing"
|
|
32
32
|
Requires-Dist: isort; extra == "testing"
|
|
33
|
+
Dynamic: license-file
|
|
33
34
|
|
|
34
35
|
# Python API wrapper for the UC Integration API
|
|
35
36
|
[](https://pypi.org/project/ucapi)
|
|
36
37
|
[](LICENSE)
|
|
37
38
|
[](https://github.com/psf/black)
|
|
38
39
|
|
|
39
|
-
This library simplifies writing Python
|
|
40
|
+
This library simplifies writing Python-based integrations for the [Unfolded Circle Remote devices](https://www.unfoldedcircle.com/)
|
|
40
41
|
by wrapping the [WebSocket Integration API](https://github.com/unfoldedcircle/core-api/tree/main/integration-api).
|
|
41
42
|
|
|
42
43
|
It's an alpha release (in our eyes). Breaking changes are to be expected and missing features will be continuously added.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
[](https://github.com/psf/black)
|
|
5
5
|
|
|
6
|
-
This library simplifies writing Python
|
|
6
|
+
This library simplifies writing Python-based integrations for the [Unfolded Circle Remote devices](https://www.unfoldedcircle.com/)
|
|
7
7
|
by wrapping the [WebSocket Integration API](https://github.com/unfoldedcircle/core-api/tree/main/integration-api).
|
|
8
8
|
|
|
9
9
|
It's an alpha release (in our eyes). Breaking changes are to be expected and missing features will be continuously added.
|
|
@@ -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
|
+
}
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
requires-python = ">=3.10"
|
|
23
23
|
dependencies = [
|
|
24
24
|
"pyee>=9.0",
|
|
25
|
-
"websockets>=
|
|
25
|
+
"websockets>=14.0",
|
|
26
26
|
"zeroconf>=0.120.0",
|
|
27
27
|
]
|
|
28
28
|
dynamic = ["version"]
|
|
@@ -36,7 +36,7 @@ content-type = "text/markdown; charset=UTF-8"
|
|
|
36
36
|
"Source Code" = "https://github.com/unfoldedcircle/integration-python-library"
|
|
37
37
|
"Bug Reports" = "https://github.com/unfoldedcircle/integration-python-library/issues"
|
|
38
38
|
"Discord" = "http://unfolded.chat/"
|
|
39
|
-
"Forum" = "
|
|
39
|
+
"Forum" = "https://unfolded.community/"
|
|
40
40
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
42
|
testing = [
|
|
@@ -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
|
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
# file generated by
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
2
|
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
|
|
5
|
+
|
|
3
6
|
TYPE_CHECKING = False
|
|
4
7
|
if TYPE_CHECKING:
|
|
5
|
-
from typing import Tuple
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
6
11
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
12
|
else:
|
|
8
13
|
VERSION_TUPLE = object
|
|
@@ -12,5 +17,5 @@ __version__: str
|
|
|
12
17
|
__version_tuple__: VERSION_TUPLE
|
|
13
18
|
version_tuple: VERSION_TUPLE
|
|
14
19
|
|
|
15
|
-
__version__ = version = '0.
|
|
16
|
-
__version_tuple__ = version_tuple = (0,
|
|
20
|
+
__version__ = version = '0.3.0'
|
|
21
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
@@ -16,11 +16,11 @@ from typing import Any, Callable
|
|
|
16
16
|
import websockets
|
|
17
17
|
from pyee.asyncio import AsyncIOEventEmitter
|
|
18
18
|
|
|
19
|
+
# Note: websockets v14 doesn't have websockets.server anymore
|
|
20
|
+
from websockets import serve
|
|
21
|
+
|
|
19
22
|
# workaround for pylint error: E0611: No name 'ConnectionClosedOK' in module 'websockets' (no-name-in-module) # noqa
|
|
20
23
|
from websockets.exceptions import ConnectionClosedOK
|
|
21
|
-
|
|
22
|
-
# workaround for pylint error: E1101: Module 'websockets' has no 'serve' member (no-member) # noqa
|
|
23
|
-
from websockets.server import serve
|
|
24
24
|
from zeroconf import IPVersion
|
|
25
25
|
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
|
|
26
26
|
|
|
@@ -28,6 +28,11 @@ import ucapi.api_definitions as uc
|
|
|
28
28
|
from ucapi import media_player
|
|
29
29
|
from ucapi.entities import Entities
|
|
30
30
|
|
|
31
|
+
try:
|
|
32
|
+
from ._version import version as __version__
|
|
33
|
+
except ImportError:
|
|
34
|
+
__version__ = "unknown"
|
|
35
|
+
|
|
31
36
|
_LOG = logging.getLogger(__name__)
|
|
32
37
|
_LOG.setLevel(logging.DEBUG)
|
|
33
38
|
|
|
@@ -125,9 +130,10 @@ class IntegrationAPI:
|
|
|
125
130
|
)
|
|
126
131
|
|
|
127
132
|
_LOG.info(
|
|
128
|
-
"Driver is up: %s, version: %s, listening on: %s:%d",
|
|
133
|
+
"Driver is up: %s, version: %s, api: %s, listening on: %s:%d",
|
|
129
134
|
self._driver_info["driver_id"],
|
|
130
135
|
self._driver_info["version"],
|
|
136
|
+
__version__,
|
|
131
137
|
host,
|
|
132
138
|
port,
|
|
133
139
|
)
|
|
@@ -163,6 +169,7 @@ class IntegrationAPI:
|
|
|
163
169
|
_LOG.info("WS: Connection closed")
|
|
164
170
|
|
|
165
171
|
except websockets.exceptions.ConnectionClosedError as e:
|
|
172
|
+
# no idea why they made code & reason deprecated...
|
|
166
173
|
_LOG.info("WS: Connection closed with error %d: %s", e.code, e.reason)
|
|
167
174
|
|
|
168
175
|
except websockets.exceptions.WebSocketException as e:
|
|
@@ -210,6 +217,7 @@ class IntegrationAPI:
|
|
|
210
217
|
"""
|
|
211
218
|
await self._send_ws_response(websocket, req_id, "result", msg_data, status_code)
|
|
212
219
|
|
|
220
|
+
# pylint: disable=R0917
|
|
213
221
|
async def _send_ws_response(
|
|
214
222
|
self,
|
|
215
223
|
websocket,
|
|
@@ -240,7 +248,9 @@ class IntegrationAPI:
|
|
|
240
248
|
|
|
241
249
|
if websocket in self._clients:
|
|
242
250
|
data_dump = json.dumps(data)
|
|
243
|
-
_LOG.
|
|
251
|
+
if _LOG.isEnabledFor(logging.DEBUG):
|
|
252
|
+
data_log = json.dumps(data) if filter_log_msg_data(data) else data_dump
|
|
253
|
+
_LOG.debug("[%s] ->: %s", websocket.remote_address, data_log)
|
|
244
254
|
await websocket.send(data_dump)
|
|
245
255
|
else:
|
|
246
256
|
_LOG.error("Error sending response: connection no longer established")
|
|
@@ -261,12 +271,13 @@ class IntegrationAPI:
|
|
|
261
271
|
data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category}
|
|
262
272
|
data_dump = json.dumps(data)
|
|
263
273
|
# filter fields
|
|
274
|
+
data_log = ""
|
|
264
275
|
if _LOG.isEnabledFor(logging.DEBUG):
|
|
265
276
|
data_log = json.dumps(data) if filter_log_msg_data(data) else data_dump
|
|
266
277
|
|
|
267
278
|
for websocket in self._clients:
|
|
268
279
|
if _LOG.isEnabledFor(logging.DEBUG):
|
|
269
|
-
_LOG.debug("[%s]
|
|
280
|
+
_LOG.debug("[%s] =>: %s", websocket.remote_address, data_log)
|
|
270
281
|
try:
|
|
271
282
|
await websocket.send(data_dump)
|
|
272
283
|
except websockets.exceptions.WebSocketException:
|
|
@@ -469,7 +480,7 @@ class IntegrationAPI:
|
|
|
469
480
|
self, websocket, req_id: int, msg_data: dict[str, Any] | None
|
|
470
481
|
) -> None:
|
|
471
482
|
if not msg_data:
|
|
472
|
-
_LOG.warning("Ignoring
|
|
483
|
+
_LOG.warning("Ignoring entity command: called with empty msg_data")
|
|
473
484
|
await self.acknowledge_command(
|
|
474
485
|
websocket, req_id, uc.StatusCodes.BAD_REQUEST
|
|
475
486
|
)
|
|
@@ -635,6 +646,7 @@ class IntegrationAPI:
|
|
|
635
646
|
websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE
|
|
636
647
|
)
|
|
637
648
|
|
|
649
|
+
# pylint: disable=R0917
|
|
638
650
|
async def request_driver_setup_user_confirmation(
|
|
639
651
|
self,
|
|
640
652
|
websocket,
|
|
@@ -858,21 +870,41 @@ def filter_log_msg_data(data: dict[str, Any]) -> bool:
|
|
|
858
870
|
|
|
859
871
|
Attention: the dictionary is modified!
|
|
860
872
|
|
|
861
|
-
- Attributes are filtered in `data["msg_data"]
|
|
873
|
+
- Attributes are filtered in `data["msg_data"]`:
|
|
874
|
+
- dict object: key `attributes`
|
|
875
|
+
- list object: every list item `attributes`
|
|
862
876
|
- Filtered attributes: `MEDIA_IMAGE_URL`
|
|
863
877
|
|
|
864
878
|
:param data: the message data dict
|
|
865
879
|
:return: True if a field was filtered, False otherwise
|
|
866
880
|
"""
|
|
867
881
|
# filter out base64 encoded images in the media player's media_image_url attribute
|
|
868
|
-
if
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
882
|
+
if "msg_data" in data:
|
|
883
|
+
if (
|
|
884
|
+
"attributes" in data["msg_data"]
|
|
885
|
+
and media_player.Attributes.MEDIA_IMAGE_URL
|
|
886
|
+
in data["msg_data"]["attributes"]
|
|
887
|
+
and data["msg_data"]["attributes"][
|
|
888
|
+
media_player.Attributes.MEDIA_IMAGE_URL
|
|
889
|
+
].startswith("data:")
|
|
890
|
+
):
|
|
891
|
+
data["msg_data"]["attributes"][
|
|
892
|
+
media_player.Attributes.MEDIA_IMAGE_URL
|
|
893
|
+
] = "data:***"
|
|
894
|
+
return True
|
|
895
|
+
|
|
896
|
+
if isinstance(data["msg_data"], list):
|
|
897
|
+
for item in data["msg_data"]:
|
|
898
|
+
if (
|
|
899
|
+
"attributes" in item
|
|
900
|
+
and media_player.Attributes.MEDIA_IMAGE_URL in item["attributes"]
|
|
901
|
+
and item["attributes"][
|
|
902
|
+
media_player.Attributes.MEDIA_IMAGE_URL
|
|
903
|
+
].startswith("data:")
|
|
904
|
+
):
|
|
905
|
+
item["attributes"][
|
|
906
|
+
media_player.Attributes.MEDIA_IMAGE_URL
|
|
907
|
+
] = "data:***"
|
|
908
|
+
return True
|
|
909
|
+
|
|
878
910
|
return False
|
|
@@ -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
|
|
|
@@ -35,6 +36,7 @@ class Entity:
|
|
|
35
36
|
for more information.
|
|
36
37
|
"""
|
|
37
38
|
|
|
39
|
+
# pylint: disable=R0917
|
|
38
40
|
def __init__(
|
|
39
41
|
self,
|
|
40
42
|
identifier: str,
|
|
@@ -42,8 +44,8 @@ class Entity:
|
|
|
42
44
|
entity_type: EntityTypes,
|
|
43
45
|
features: list[str],
|
|
44
46
|
attributes: dict[str, Any],
|
|
45
|
-
device_class: str | None,
|
|
46
|
-
options: dict[str, Any] | None,
|
|
47
|
+
device_class: str | None = None,
|
|
48
|
+
options: dict[str, Any] | None = None,
|
|
47
49
|
area: str | None = None,
|
|
48
50
|
cmd_handler: CommandHandler = None,
|
|
49
51
|
):
|
|
@@ -78,6 +78,7 @@ class Attributes(str, Enum):
|
|
|
78
78
|
MUTED = "muted"
|
|
79
79
|
MEDIA_DURATION = "media_duration"
|
|
80
80
|
MEDIA_POSITION = "media_position"
|
|
81
|
+
MEDIA_POSITION_UPDATED_AT = "media_position_updated_at"
|
|
81
82
|
MEDIA_TYPE = "media_type"
|
|
82
83
|
MEDIA_IMAGE_URL = "media_image_url"
|
|
83
84
|
MEDIA_TITLE = "media_title"
|
|
@@ -193,8 +194,9 @@ class MediaPlayer(Entity):
|
|
|
193
194
|
|
|
194
195
|
See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_media_player.md
|
|
195
196
|
for more information.
|
|
196
|
-
"""
|
|
197
|
+
"""
|
|
197
198
|
|
|
199
|
+
# pylint: disable=R0917
|
|
198
200
|
def __init__(
|
|
199
201
|
self,
|
|
200
202
|
identifier: str,
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
"""
|
|
123
|
+
|
|
124
|
+
# pylint: disable=R0917
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
identifier: str,
|
|
128
|
+
name: str | dict[str, str],
|
|
129
|
+
features: list[Features],
|
|
130
|
+
attributes: dict[str, Any],
|
|
131
|
+
simple_commands: list[str] | None = None,
|
|
132
|
+
button_mapping: list[DeviceButtonMapping | dict[str, Any]] | None = None,
|
|
133
|
+
ui_pages: list[UiPage | dict[str, Any]] | None = None,
|
|
134
|
+
area: str | None = None,
|
|
135
|
+
cmd_handler: CommandHandler = None,
|
|
136
|
+
):
|
|
137
|
+
"""
|
|
138
|
+
Create remote entity instance.
|
|
139
|
+
|
|
140
|
+
:param identifier: entity identifier
|
|
141
|
+
:param name: friendly name
|
|
142
|
+
:param features: remote features
|
|
143
|
+
:param attributes: remote attributes
|
|
144
|
+
:param simple_commands: optional list of supported remote command identifiers
|
|
145
|
+
:param button_mapping: optional command mapping of physical buttons
|
|
146
|
+
Either with DeviceButtonMapping items or plain dictionary items.
|
|
147
|
+
:param ui_pages: optional user interface page definitions.
|
|
148
|
+
Either with UiPage items or plain dictionary items.
|
|
149
|
+
:param area: optional area
|
|
150
|
+
:param cmd_handler: handler for entity commands
|
|
151
|
+
"""
|
|
152
|
+
options: dict[str, Any] = {}
|
|
153
|
+
if simple_commands:
|
|
154
|
+
options["simple_commands"] = simple_commands
|
|
155
|
+
if button_mapping:
|
|
156
|
+
options["button_mapping"] = _list_items_asdict(button_mapping)
|
|
157
|
+
if ui_pages:
|
|
158
|
+
options["user_interface"] = {"pages": _list_items_asdict(ui_pages)}
|
|
159
|
+
super().__init__(
|
|
160
|
+
identifier,
|
|
161
|
+
name,
|
|
162
|
+
EntityTypes.REMOTE,
|
|
163
|
+
features,
|
|
164
|
+
attributes,
|
|
165
|
+
options=options,
|
|
166
|
+
area=area,
|
|
167
|
+
cmd_handler=cmd_handler,
|
|
168
|
+
)
|
ucapi-0.3.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
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: ucapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://www.unfoldedcircle.com/
|
|
|
8
8
|
Project-URL: Source Code, https://github.com/unfoldedcircle/integration-python-library
|
|
9
9
|
Project-URL: Bug Reports, https://github.com/unfoldedcircle/integration-python-library/issues
|
|
10
10
|
Project-URL: Discord, http://unfolded.chat/
|
|
11
|
-
Project-URL: Forum,
|
|
11
|
+
Project-URL: Forum, https://unfolded.community/
|
|
12
12
|
Platform: any
|
|
13
13
|
Classifier: Development Status :: 3 - Alpha
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
@@ -22,7 +22,7 @@ Requires-Python: >=3.10
|
|
|
22
22
|
Description-Content-Type: text/markdown; charset=UTF-8
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: pyee>=9.0
|
|
25
|
-
Requires-Dist: websockets>=
|
|
25
|
+
Requires-Dist: websockets>=14.0
|
|
26
26
|
Requires-Dist: zeroconf>=0.120.0
|
|
27
27
|
Provides-Extra: testing
|
|
28
28
|
Requires-Dist: pylint; extra == "testing"
|
|
@@ -30,13 +30,14 @@ Requires-Dist: flake8-docstrings; extra == "testing"
|
|
|
30
30
|
Requires-Dist: flake8; extra == "testing"
|
|
31
31
|
Requires-Dist: black; extra == "testing"
|
|
32
32
|
Requires-Dist: isort; extra == "testing"
|
|
33
|
+
Dynamic: license-file
|
|
33
34
|
|
|
34
35
|
# Python API wrapper for the UC Integration API
|
|
35
36
|
[](https://pypi.org/project/ucapi)
|
|
36
37
|
[](LICENSE)
|
|
37
38
|
[](https://github.com/psf/black)
|
|
38
39
|
|
|
39
|
-
This library simplifies writing Python
|
|
40
|
+
This library simplifies writing Python-based integrations for the [Unfolded Circle Remote devices](https://www.unfoldedcircle.com/)
|
|
40
41
|
by wrapping the [WebSocket Integration API](https://github.com/unfoldedcircle/core-api/tree/main/integration-api).
|
|
41
42
|
|
|
42
43
|
It's an alpha release (in our eyes). Breaking changes are to be expected and missing features will be continuously added.
|
|
@@ -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
|