Skip to content

Commit 8fc6dbf

Browse files
authored
feat: remote-entity support (#20)
1 parent ed6b7da commit 8fc6dbf

File tree

9 files changed

+664
-6
lines changed

9 files changed

+664
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
_Changes in the next release_
1111

12+
### Added
13+
- New remote-entity type. Requires remote-core / Core Simulator version 0.43.0 or newer.
14+
1215
---
1316

1417
## v0.1.7 - 2024-03-13

examples/remote.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

examples/remote.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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()

examples/remote_ui_page.json

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

ucapi/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .cover import Cover # noqa: F401
3636
from .light import Light # noqa: F401
3737
from .media_player import MediaPlayer # noqa: F401
38+
from .remote import Remote # noqa: F401
3839
from .sensor import Sensor # noqa: F401
3940
from .switch import Switch # noqa: F401
4041

ucapi/button.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,6 @@ def __init__(
5959
EntityTypes.BUTTON,
6060
["press"],
6161
{Attributes.STATE: States.AVAILABLE},
62-
None,
63-
None,
64-
area,
65-
cmd_handler,
62+
area=area,
63+
cmd_handler=cmd_handler,
6664
)

ucapi/entity.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class EntityTypes(str, Enum):
2323
CLIMATE = "climate"
2424
LIGHT = "light"
2525
MEDIA_PLAYER = "media_player"
26+
REMOTE = "remote"
2627
SENSOR = "sensor"
2728
SWITCH = "switch"
2829

@@ -42,8 +43,8 @@ def __init__(
4243
entity_type: EntityTypes,
4344
features: list[str],
4445
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,
4748
area: str | None = None,
4849
cmd_handler: CommandHandler = None,
4950
):

0 commit comments

Comments
 (0)