pyryse 0.1.0__py3-none-any.whl
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.
- pyryse/__init__.py +25 -0
- pyryse/bridge.py +129 -0
- pyryse/exceptions.py +23 -0
- pyryse/models.py +136 -0
- pyryse-0.1.0.dist-info/METADATA +75 -0
- pyryse-0.1.0.dist-info/RECORD +8 -0
- pyryse-0.1.0.dist-info/WHEEL +4 -0
- pyryse-0.1.0.dist-info/licenses/LICENSE +21 -0
pyryse/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""pyryse — local client for RYSE SmartShade via the RYSE SmartBridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .bridge import RyseBridge
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
RyseConnectionError,
|
|
8
|
+
RyseError,
|
|
9
|
+
RyseNotFoundError,
|
|
10
|
+
RyseResponseError,
|
|
11
|
+
)
|
|
12
|
+
from .models import Device, Gear, GearState
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"RyseBridge",
|
|
16
|
+
"Device",
|
|
17
|
+
"Gear",
|
|
18
|
+
"GearState",
|
|
19
|
+
"RyseError",
|
|
20
|
+
"RyseConnectionError",
|
|
21
|
+
"RyseResponseError",
|
|
22
|
+
"RyseNotFoundError",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
pyryse/bridge.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Async client for the RYSE SmartBridge local HTTP API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
RyseConnectionError,
|
|
12
|
+
RyseNotFoundError,
|
|
13
|
+
RyseResponseError,
|
|
14
|
+
)
|
|
15
|
+
from .models import Device, Gear
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT = 10.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RyseBridge:
|
|
21
|
+
"""Talks to a RYSE SmartBridge over its local HTTP API (no cloud).
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
host:
|
|
26
|
+
The bridge's IP address or hostname on the LAN.
|
|
27
|
+
session:
|
|
28
|
+
An ``aiohttp.ClientSession`` to use for requests.
|
|
29
|
+
serial:
|
|
30
|
+
Optional credential. The bridge only requires it when its local
|
|
31
|
+
security option is enabled; when set it is sent as the
|
|
32
|
+
``Authorization`` header on every request.
|
|
33
|
+
timeout:
|
|
34
|
+
Per-request timeout in seconds.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
host: str,
|
|
40
|
+
session: aiohttp.ClientSession,
|
|
41
|
+
*,
|
|
42
|
+
serial: str | None = None,
|
|
43
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._host = host
|
|
46
|
+
self._session = session
|
|
47
|
+
self._serial = serial
|
|
48
|
+
self._timeout = timeout
|
|
49
|
+
self._device_id: str | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def device_id(self) -> str | None:
|
|
53
|
+
"""The bridge's ``dvc_id``, known after the first fetch."""
|
|
54
|
+
return self._device_id
|
|
55
|
+
|
|
56
|
+
async def async_get_device(self) -> Device:
|
|
57
|
+
"""Fetch the bridge and all its gears."""
|
|
58
|
+
data = await self._request("GET", "/lua/device")
|
|
59
|
+
if isinstance(data, list):
|
|
60
|
+
if not data:
|
|
61
|
+
raise RyseResponseError("Empty /lua/device response")
|
|
62
|
+
data = data[0]
|
|
63
|
+
if not isinstance(data, dict) or "dvc_id" not in data:
|
|
64
|
+
raise RyseResponseError("Unexpected /lua/device response")
|
|
65
|
+
device = Device.from_dict(data)
|
|
66
|
+
self._device_id = device.id
|
|
67
|
+
return device
|
|
68
|
+
|
|
69
|
+
async def async_get_gears(self) -> list[Gear]:
|
|
70
|
+
"""Convenience: just the gears from :meth:`async_get_device`."""
|
|
71
|
+
return (await self.async_get_device()).gears
|
|
72
|
+
|
|
73
|
+
async def async_get_position(self, gear_id: str) -> Gear:
|
|
74
|
+
"""Fetch one gear's position via the per-gear endpoint.
|
|
75
|
+
|
|
76
|
+
Returns a sparse :class:`Gear` carrying ``position`` and
|
|
77
|
+
``position_state`` (the endpoint reports only those two fields).
|
|
78
|
+
"""
|
|
79
|
+
dvc = await self._ensure_device_id()
|
|
80
|
+
data = await self._request(
|
|
81
|
+
"GET", f"/lua/device/{dvc}/gear/{gear_id}/position"
|
|
82
|
+
)
|
|
83
|
+
if not isinstance(data, dict) or "position" not in data:
|
|
84
|
+
raise RyseResponseError("Unexpected position response")
|
|
85
|
+
return Gear.from_dict({"id": gear_id, **data})
|
|
86
|
+
|
|
87
|
+
async def async_set_position(self, gear_id: str, position: int) -> None:
|
|
88
|
+
"""Move a gear to ``position`` (0-100, bridge scale: 0 open, 100 closed).
|
|
89
|
+
|
|
90
|
+
Values are clamped into range.
|
|
91
|
+
"""
|
|
92
|
+
dvc = await self._ensure_device_id()
|
|
93
|
+
position = max(0, min(100, int(position)))
|
|
94
|
+
await self._request(
|
|
95
|
+
"POST",
|
|
96
|
+
f"/lua/device/{dvc}/gear/{gear_id}/position",
|
|
97
|
+
json={"position": position},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def _ensure_device_id(self) -> str:
|
|
101
|
+
if self._device_id is None:
|
|
102
|
+
await self.async_get_device()
|
|
103
|
+
assert self._device_id is not None # noqa: S101
|
|
104
|
+
return self._device_id
|
|
105
|
+
|
|
106
|
+
async def _request(
|
|
107
|
+
self, method: str, path: str, *, json: dict[str, Any] | None = None
|
|
108
|
+
) -> Any:
|
|
109
|
+
url = f"http://{self._host}{path}"
|
|
110
|
+
headers = {"Authorization": self._serial} if self._serial else None
|
|
111
|
+
try:
|
|
112
|
+
async with asyncio.timeout(self._timeout):
|
|
113
|
+
resp = await self._session.request(
|
|
114
|
+
method, url, json=json, headers=headers
|
|
115
|
+
)
|
|
116
|
+
if resp.status in (400, 404):
|
|
117
|
+
raise RyseNotFoundError(
|
|
118
|
+
f"{method} {path} -> {resp.status}", status=resp.status
|
|
119
|
+
)
|
|
120
|
+
if resp.status >= 400:
|
|
121
|
+
raise RyseResponseError(
|
|
122
|
+
f"{method} {path} -> {resp.status}", status=resp.status
|
|
123
|
+
)
|
|
124
|
+
# Position POST returns an empty/non-JSON body on success.
|
|
125
|
+
if method == "POST" and json is not None:
|
|
126
|
+
return None
|
|
127
|
+
return await resp.json(content_type=None)
|
|
128
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
129
|
+
raise RyseConnectionError(f"{method} {path} failed: {err}") from err
|
pyryse/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exceptions for pyryse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RyseError(Exception):
|
|
7
|
+
"""Base error for all pyryse failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RyseConnectionError(RyseError):
|
|
11
|
+
"""Raised when the bridge cannot be reached (network error or timeout)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RyseResponseError(RyseError):
|
|
15
|
+
"""Raised when the bridge returns an unexpected HTTP status or body."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RyseNotFoundError(RyseResponseError):
|
|
23
|
+
"""Raised when a device or gear id is not known to the bridge."""
|
pyryse/models.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Typed models for RYSE SmartBridge data.
|
|
2
|
+
|
|
3
|
+
These mirror the bridge's own representation faithfully. In particular,
|
|
4
|
+
``position`` uses the bridge's native scale, which is the *inverse* of Home
|
|
5
|
+
Assistant's cover convention:
|
|
6
|
+
|
|
7
|
+
* ``0`` = fully open (observed on real hardware, 2026-11)
|
|
8
|
+
* ``100`` = fully closed
|
|
9
|
+
|
|
10
|
+
Consumers that need Home Assistant semantics (100 = open) should invert.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import IntEnum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GearState(IntEnum):
|
|
21
|
+
"""Movement state reported by a gear (``position_state``).
|
|
22
|
+
|
|
23
|
+
Values observed on firmware 5.3.2:
|
|
24
|
+
|
|
25
|
+
* ``IDLE`` (0) — stopped / settled.
|
|
26
|
+
* ``OPENING`` (1) — moving toward position 0.
|
|
27
|
+
* ``CLOSING`` (2) — moving toward position 100.
|
|
28
|
+
|
|
29
|
+
A gear that is offline may report a stale non-idle value, so check
|
|
30
|
+
:attr:`Gear.online` before trusting it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
IDLE = 0
|
|
34
|
+
OPENING = 1
|
|
35
|
+
CLOSING = 2
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def _missing_(cls, value: object) -> GearState:
|
|
39
|
+
# Be forgiving about unknown values from other firmware.
|
|
40
|
+
return cls.IDLE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class Gear:
|
|
45
|
+
"""A single shade (RYSE calls them "gears")."""
|
|
46
|
+
|
|
47
|
+
id: str
|
|
48
|
+
name: str
|
|
49
|
+
position: int
|
|
50
|
+
position_state: GearState
|
|
51
|
+
online: bool
|
|
52
|
+
battery: int | None = None # power_level, 0-100
|
|
53
|
+
speed: int | None = None
|
|
54
|
+
gear_type: int | None = None
|
|
55
|
+
power_type: int | None = None
|
|
56
|
+
obstacle_detection: int | None = None
|
|
57
|
+
machine_state: int | None = None
|
|
58
|
+
fw_version: str | None = None
|
|
59
|
+
hw_version: str | None = None
|
|
60
|
+
msp_fw_version: str | None = None
|
|
61
|
+
serial_number: str | None = None
|
|
62
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_moving(self) -> bool:
|
|
66
|
+
"""Whether the gear is currently in motion."""
|
|
67
|
+
return self.online and self.position_state is not GearState.IDLE
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_dict(cls, data: dict[str, Any]) -> Gear:
|
|
71
|
+
return cls(
|
|
72
|
+
id=str(data["id"]),
|
|
73
|
+
name=str(data.get("name", data["id"])),
|
|
74
|
+
position=int(data.get("position", 0)),
|
|
75
|
+
position_state=GearState(int(data.get("position_state", 0))),
|
|
76
|
+
online=bool(data.get("online", False)),
|
|
77
|
+
battery=_opt_int(data.get("power_level")),
|
|
78
|
+
speed=_opt_int(data.get("speed")),
|
|
79
|
+
gear_type=_opt_int(data.get("gear_type")),
|
|
80
|
+
power_type=_opt_int(data.get("power_type")),
|
|
81
|
+
obstacle_detection=_opt_int(data.get("obstacle_detection")),
|
|
82
|
+
machine_state=_opt_int(data.get("machine_state")),
|
|
83
|
+
fw_version=_opt_str(data.get("fw_version")),
|
|
84
|
+
hw_version=_opt_str(data.get("hw_version")),
|
|
85
|
+
msp_fw_version=_opt_str(data.get("msp_fw_version")),
|
|
86
|
+
serial_number=_opt_str(data.get("serial_number")),
|
|
87
|
+
raw=data,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class Device:
|
|
93
|
+
"""A RYSE SmartBridge and the gears attached to it."""
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
name: str
|
|
97
|
+
mac: str | None
|
|
98
|
+
ip: str | None
|
|
99
|
+
online: bool
|
|
100
|
+
fw_version: str | None
|
|
101
|
+
hw_version: str | None
|
|
102
|
+
app_version: str | None
|
|
103
|
+
local_security_enable: bool
|
|
104
|
+
gears: list[Gear] = field(default_factory=list)
|
|
105
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
106
|
+
|
|
107
|
+
def gear(self, gear_id: str) -> Gear | None:
|
|
108
|
+
"""Return the gear with ``gear_id``, or ``None``."""
|
|
109
|
+
return next((g for g in self.gears if g.id == gear_id), None)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: dict[str, Any]) -> Device:
|
|
113
|
+
return cls(
|
|
114
|
+
id=str(data["dvc_id"]),
|
|
115
|
+
name=str(data.get("dvc_name", "RYSE SmartBridge")),
|
|
116
|
+
mac=_opt_str(data.get("dvc_mac")),
|
|
117
|
+
ip=_opt_str(data.get("dvc_ip")),
|
|
118
|
+
online=bool(data.get("dvc_online", True)),
|
|
119
|
+
fw_version=_opt_str(data.get("dvc_fw_version")),
|
|
120
|
+
hw_version=_opt_str(data.get("dvc_hw_version")),
|
|
121
|
+
app_version=_opt_str(data.get("dvc_app_version")),
|
|
122
|
+
local_security_enable=bool(data.get("local_security_enable", False)),
|
|
123
|
+
gears=[Gear.from_dict(g) for g in data.get("gears", []) if g.get("id")],
|
|
124
|
+
raw=data,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _opt_int(value: Any) -> int | None:
|
|
129
|
+
try:
|
|
130
|
+
return int(value) if value is not None else None
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _opt_str(value: Any) -> str | None:
|
|
136
|
+
return str(value) if value is not None else None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyryse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local (no-cloud) async client for RYSE SmartShade via the RYSE SmartBridge.
|
|
5
|
+
Project-URL: Homepage, https://github.com/johnnyxmas/pyryse
|
|
6
|
+
Project-URL: Issues, https://github.com/johnnyxmas/pyryse/issues
|
|
7
|
+
Author: Johnny Xmas
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: blinds,home-assistant,ryse,shades,smartbridge,smartshade
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: aiohttp>=3.9
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: aioresponses>=0.7.6; extra == 'test'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# pyryse
|
|
27
|
+
|
|
28
|
+
Local (no-cloud) async Python client for [RYSE SmartShade](https://helloryse.com)
|
|
29
|
+
motorized shades, driven through a RYSE **SmartBridge** over its LAN HTTP API.
|
|
30
|
+
|
|
31
|
+
Built to back the Home Assistant integration
|
|
32
|
+
[`ha-ryse-local`](https://github.com/johnnyxmas/ha-ryse-local), but usable on its
|
|
33
|
+
own. Not affiliated with or endorsed by RYSE; "RYSE", "SmartBridge" and
|
|
34
|
+
"SmartShade" are trademarks of their owner.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install pyryse
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import aiohttp
|
|
46
|
+
from pyryse import RyseBridge
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
async with aiohttp.ClientSession() as session:
|
|
50
|
+
bridge = RyseBridge("192.168.0.123", session)
|
|
51
|
+
device = await bridge.async_get_device()
|
|
52
|
+
for gear in device.gears:
|
|
53
|
+
print(gear.name, gear.position, gear.position_state.name, gear.online)
|
|
54
|
+
|
|
55
|
+
# Move a shade. Bridge scale: 0 = open, 100 = closed.
|
|
56
|
+
await bridge.async_set_position(device.gears[0].id, 0) # open
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If your bridge has local security enabled, pass the serial:
|
|
60
|
+
`RyseBridge(host, session, serial="...")`.
|
|
61
|
+
|
|
62
|
+
## Position convention
|
|
63
|
+
|
|
64
|
+
The bridge's `position` is the **inverse** of Home Assistant's cover scale:
|
|
65
|
+
|
|
66
|
+
* `0` = fully open
|
|
67
|
+
* `100` = fully closed
|
|
68
|
+
|
|
69
|
+
`pyryse` reports the bridge's native values unchanged; invert in your consumer
|
|
70
|
+
if you need HA semantics. See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the full
|
|
71
|
+
reverse-engineered API.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pyryse/__init__.py,sha256=T7yYtZ0cRNFwXokgKcNibXlBKhHWuPYTT6o7VKbL0sI,495
|
|
2
|
+
pyryse/bridge.py,sha256=XrE0nkGD5ZFpkzlO1t9jDmMOe3kh3QvANoBoxn5anuw,4534
|
|
3
|
+
pyryse/exceptions.py,sha256=rbet76YaKOkfYpOe-7zHmzj-0hqJxnT5h0skEvVi-Rs,627
|
|
4
|
+
pyryse/models.py,sha256=EF8SwWg8APXesh4k5AxsDfJG3rA-9q7OWCxVOnlZWig,4473
|
|
5
|
+
pyryse-0.1.0.dist-info/METADATA,sha256=KE1_7jUa5HNO6gxBCtXWnyb0X_v7jIicA1cZZbciVsE,2386
|
|
6
|
+
pyryse-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
pyryse-0.1.0.dist-info/licenses/LICENSE,sha256=sYmOWeyyMLyYBvLxgDaAQYmGtUSfTytwnrTQkIpn5YE,1068
|
|
8
|
+
pyryse-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johnny Xmas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|