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.
@@ -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
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
10
+ @eaDir/
11
+ .DS_Store
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()