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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.