pyryse 0.1.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.
- pyryse-0.1.0/.github/workflows/ci.yml +16 -0
- pyryse-0.1.0/.gitignore +11 -0
- pyryse-0.1.0/LICENSE +21 -0
- pyryse-0.1.0/PKG-INFO +75 -0
- pyryse-0.1.0/README.md +50 -0
- pyryse-0.1.0/docs/PROTOCOL.md +74 -0
- pyryse-0.1.0/pyproject.toml +44 -0
- pyryse-0.1.0/pyryse/__init__.py +25 -0
- pyryse-0.1.0/pyryse/bridge.py +129 -0
- pyryse-0.1.0/pyryse/exceptions.py +23 -0
- pyryse-0.1.0/pyryse/models.py +136 -0
- pyryse-0.1.0/tests/test_bridge.py +129 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on: [push, pull_request, workflow_dispatch]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with:
|
|
13
|
+
python-version: ${{ matrix.python-version }}
|
|
14
|
+
- run: pip install -e ".[test]" ruff
|
|
15
|
+
- run: ruff check .
|
|
16
|
+
- run: pytest -q
|
pyryse-0.1.0/.gitignore
ADDED
pyryse-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pyryse-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
pyryse-0.1.0/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# pyryse
|
|
2
|
+
|
|
3
|
+
Local (no-cloud) async Python client for [RYSE SmartShade](https://helloryse.com)
|
|
4
|
+
motorized shades, driven through a RYSE **SmartBridge** over its LAN HTTP API.
|
|
5
|
+
|
|
6
|
+
Built to back the Home Assistant integration
|
|
7
|
+
[`ha-ryse-local`](https://github.com/johnnyxmas/ha-ryse-local), but usable on its
|
|
8
|
+
own. Not affiliated with or endorsed by RYSE; "RYSE", "SmartBridge" and
|
|
9
|
+
"SmartShade" are trademarks of their owner.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install pyryse
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import aiohttp
|
|
21
|
+
from pyryse import RyseBridge
|
|
22
|
+
|
|
23
|
+
async def main():
|
|
24
|
+
async with aiohttp.ClientSession() as session:
|
|
25
|
+
bridge = RyseBridge("192.168.0.123", session)
|
|
26
|
+
device = await bridge.async_get_device()
|
|
27
|
+
for gear in device.gears:
|
|
28
|
+
print(gear.name, gear.position, gear.position_state.name, gear.online)
|
|
29
|
+
|
|
30
|
+
# Move a shade. Bridge scale: 0 = open, 100 = closed.
|
|
31
|
+
await bridge.async_set_position(device.gears[0].id, 0) # open
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If your bridge has local security enabled, pass the serial:
|
|
35
|
+
`RyseBridge(host, session, serial="...")`.
|
|
36
|
+
|
|
37
|
+
## Position convention
|
|
38
|
+
|
|
39
|
+
The bridge's `position` is the **inverse** of Home Assistant's cover scale:
|
|
40
|
+
|
|
41
|
+
* `0` = fully open
|
|
42
|
+
* `100` = fully closed
|
|
43
|
+
|
|
44
|
+
`pyryse` reports the bridge's native values unchanged; invert in your consumer
|
|
45
|
+
if you need HA semantics. See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the full
|
|
46
|
+
reverse-engineered API.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# RYSE SmartBridge local HTTP API
|
|
2
|
+
|
|
3
|
+
Reverse-engineered from a RYSE SmartBridge (bridge firmware ~2023, SmartShade
|
|
4
|
+
firmware `5.3.2`) by observing its own setup web page, its responses, and one
|
|
5
|
+
capture of the RYSE iOS app controlling a shade over the LAN. Documented here
|
|
6
|
+
for interoperability; no RYSE code or documentation was used.
|
|
7
|
+
|
|
8
|
+
All endpoints are plain HTTP on port 80. When the bridge's local-security
|
|
9
|
+
option is **off** (the default — there is no app toggle to enable it), no
|
|
10
|
+
authentication is required. When on, requests carry an `Authorization: <serial>`
|
|
11
|
+
header.
|
|
12
|
+
|
|
13
|
+
## Endpoints
|
|
14
|
+
|
|
15
|
+
### `GET /lua/device`
|
|
16
|
+
Returns a one-element JSON array; element 0 is the device object.
|
|
17
|
+
|
|
18
|
+
### `GET /lua/device/<dvc_id>`
|
|
19
|
+
Returns the device object directly. Device fields:
|
|
20
|
+
|
|
21
|
+
| field | meaning |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `dvc_id` | bridge id (used in all gear paths) |
|
|
24
|
+
| `dvc_name`, `dvc_mac`, `dvc_ip` | identity / addressing |
|
|
25
|
+
| `dvc_online` | bridge online |
|
|
26
|
+
| `dvc_fw_version`, `dvc_hw_version`, `dvc_app_version` | versions |
|
|
27
|
+
| `local_security_enable` | whether auth is required (observed `false`) |
|
|
28
|
+
| `dvc_timezone2` | `{dvc_tz_city, dvc_tz_posix, dvc_longitude, dvc_latitude, ...}` |
|
|
29
|
+
| `gears` | array of gear objects |
|
|
30
|
+
|
|
31
|
+
Gear fields: `id`, `name`, `position` (0-100), `position_state`, `online`,
|
|
32
|
+
`power_level` (battery %), `speed`, `gear_type`, `power_type`,
|
|
33
|
+
`obstacle_detection`, `machine_state`, `fw_version`, `hw_version`,
|
|
34
|
+
`msp_fw_version`, `serial_number`, `stats`.
|
|
35
|
+
|
|
36
|
+
### `GET /lua/device/<dvc_id>/gear/<gear_id>/position`
|
|
37
|
+
Returns `{"position_state": N, "position": N}`.
|
|
38
|
+
|
|
39
|
+
### `POST /lua/device/<dvc_id>/gear/<gear_id>/position`
|
|
40
|
+
Body `{"position": 0-100}`. Moves the gear. Returns `200` with an empty body.
|
|
41
|
+
|
|
42
|
+
## Semantics
|
|
43
|
+
|
|
44
|
+
**Position** (bridge-native, the inverse of Home Assistant's cover scale):
|
|
45
|
+
|
|
46
|
+
* `0` = fully **open**
|
|
47
|
+
* `100` = fully **closed**
|
|
48
|
+
|
|
49
|
+
**`position_state`:**
|
|
50
|
+
|
|
51
|
+
* `0` = idle / settled
|
|
52
|
+
* `1` = moving toward `0` (opening)
|
|
53
|
+
* `2` = moving toward `100` (closing)
|
|
54
|
+
|
|
55
|
+
An **offline** gear (`online: false`) may report a stale non-idle
|
|
56
|
+
`position_state` and a stale `position`; trust `online` first.
|
|
57
|
+
|
|
58
|
+
The motor is slow and reports intermediate positions as it travels, so poll
|
|
59
|
+
after issuing a move rather than assuming it completes immediately.
|
|
60
|
+
|
|
61
|
+
## Error behaviour
|
|
62
|
+
|
|
63
|
+
| condition | status |
|
|
64
|
+
|---|---|
|
|
65
|
+
| unknown gear id | `400` |
|
|
66
|
+
| unknown device id | `405` |
|
|
67
|
+
| wrong body key (e.g. `pos`) / non-numeric position | `405` |
|
|
68
|
+
| malformed JSON body | `500` (parsed before routing) |
|
|
69
|
+
|
|
70
|
+
## Other endpoints (not used by pyryse)
|
|
71
|
+
|
|
72
|
+
The setup page also uses `GET /lua/device/<dvc_id>/wifiscan` and
|
|
73
|
+
`POST /lua/device/<dvc_id>/wificonf` for Wi-Fi provisioning. pyryse does not
|
|
74
|
+
touch these.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyryse"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local (no-cloud) async client for RYSE SmartShade via the RYSE SmartBridge."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Johnny Xmas" }]
|
|
13
|
+
keywords = ["ryse", "smartshade", "smartbridge", "home-assistant", "blinds", "shades"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Home Automation",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["aiohttp>=3.9"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/johnnyxmas/pyryse"
|
|
27
|
+
Issues = "https://github.com/johnnyxmas/pyryse/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23", "aioresponses>=0.7.6"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["pyryse"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
37
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py311"
|
|
41
|
+
line-length = 88
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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."""
|
|
@@ -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,129 @@
|
|
|
1
|
+
"""Tests for pyryse against a mocked bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
import pytest
|
|
7
|
+
from aioresponses import aioresponses
|
|
8
|
+
|
|
9
|
+
from pyryse import (
|
|
10
|
+
Device,
|
|
11
|
+
GearState,
|
|
12
|
+
RyseBridge,
|
|
13
|
+
RyseConnectionError,
|
|
14
|
+
RyseNotFoundError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
HOST = "192.168.0.123"
|
|
18
|
+
DVC = "xd7JZVuNW"
|
|
19
|
+
|
|
20
|
+
DEVICE_PAYLOAD = [
|
|
21
|
+
{
|
|
22
|
+
"dvc_name": "RYSE SmartBridge",
|
|
23
|
+
"dvc_id": DVC,
|
|
24
|
+
"dvc_mac": "d0:16:f0:20:64:89",
|
|
25
|
+
"dvc_ip": HOST,
|
|
26
|
+
"dvc_online": True,
|
|
27
|
+
"dvc_fw_version": "1.2.3",
|
|
28
|
+
"local_security_enable": False,
|
|
29
|
+
"gears": [
|
|
30
|
+
{
|
|
31
|
+
"id": "c34ae8d8c52b",
|
|
32
|
+
"name": "2",
|
|
33
|
+
"position": 0,
|
|
34
|
+
"position_state": 0,
|
|
35
|
+
"online": True,
|
|
36
|
+
"power_level": 64,
|
|
37
|
+
"fw_version": "5.3.2",
|
|
38
|
+
"serial_number": "SECRET",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "f4e324d59c02",
|
|
42
|
+
"name": "1",
|
|
43
|
+
"position": 54,
|
|
44
|
+
"position_state": 2,
|
|
45
|
+
"online": False,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
async def session():
|
|
54
|
+
async with aiohttp.ClientSession() as s:
|
|
55
|
+
yield s
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_get_device_parses(session):
|
|
59
|
+
with aioresponses() as m:
|
|
60
|
+
m.get(f"http://{HOST}/lua/device", payload=DEVICE_PAYLOAD)
|
|
61
|
+
bridge = RyseBridge(HOST, session)
|
|
62
|
+
device = await bridge.async_get_device()
|
|
63
|
+
|
|
64
|
+
assert isinstance(device, Device)
|
|
65
|
+
assert device.id == DVC
|
|
66
|
+
assert bridge.device_id == DVC
|
|
67
|
+
assert len(device.gears) == 2
|
|
68
|
+
|
|
69
|
+
g2 = device.gear("c34ae8d8c52b")
|
|
70
|
+
assert g2 is not None
|
|
71
|
+
assert g2.name == "2"
|
|
72
|
+
assert g2.position == 0
|
|
73
|
+
assert g2.position_state is GearState.IDLE
|
|
74
|
+
assert g2.online is True
|
|
75
|
+
assert g2.battery == 64
|
|
76
|
+
assert g2.is_moving is False
|
|
77
|
+
|
|
78
|
+
offline = device.gear("f4e324d59c02")
|
|
79
|
+
assert offline.online is False
|
|
80
|
+
# offline gear reports a stale non-idle state; is_moving must still be False
|
|
81
|
+
assert offline.position_state is GearState.CLOSING
|
|
82
|
+
assert offline.is_moving is False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def test_set_position_clamps_and_posts(session):
|
|
86
|
+
with aioresponses() as m:
|
|
87
|
+
m.get(f"http://{HOST}/lua/device", payload=DEVICE_PAYLOAD)
|
|
88
|
+
url = f"http://{HOST}/lua/device/{DVC}/gear/c34ae8d8c52b/position"
|
|
89
|
+
m.post(url, status=200, body="")
|
|
90
|
+
bridge = RyseBridge(HOST, session)
|
|
91
|
+
await bridge.async_set_position("c34ae8d8c52b", 150) # out of range
|
|
92
|
+
|
|
93
|
+
post_calls = [
|
|
94
|
+
calls for key, calls in m.requests.items() if key[0] == "POST"
|
|
95
|
+
]
|
|
96
|
+
assert post_calls, "no POST was issued"
|
|
97
|
+
# value clamped to 100
|
|
98
|
+
assert post_calls[0][0].kwargs["json"] == {"position": 100}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_authorization_header_sent_with_serial(session):
|
|
102
|
+
with aioresponses() as m:
|
|
103
|
+
m.get(f"http://{HOST}/lua/device", payload=DEVICE_PAYLOAD)
|
|
104
|
+
bridge = RyseBridge(HOST, session, serial="MY-SERIAL")
|
|
105
|
+
await bridge.async_get_device()
|
|
106
|
+
key = next(iter(m.requests))
|
|
107
|
+
headers = m.requests[key][0].kwargs["headers"]
|
|
108
|
+
assert headers == {"Authorization": "MY-SERIAL"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def test_bad_gear_raises_not_found(session):
|
|
112
|
+
with aioresponses() as m:
|
|
113
|
+
m.get(f"http://{HOST}/lua/device", payload=DEVICE_PAYLOAD)
|
|
114
|
+
url = f"http://{HOST}/lua/device/{DVC}/gear/deadbeef/position"
|
|
115
|
+
m.get(url, status=400)
|
|
116
|
+
bridge = RyseBridge(HOST, session)
|
|
117
|
+
with pytest.raises(RyseNotFoundError):
|
|
118
|
+
await bridge.async_get_position("deadbeef")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_connection_error(session):
|
|
122
|
+
with aioresponses() as m:
|
|
123
|
+
m.get(
|
|
124
|
+
f"http://{HOST}/lua/device",
|
|
125
|
+
exception=aiohttp.ClientConnectionError("boom"),
|
|
126
|
+
)
|
|
127
|
+
bridge = RyseBridge(HOST, session)
|
|
128
|
+
with pytest.raises(RyseConnectionError):
|
|
129
|
+
await bridge.async_get_device()
|