python-sn2 0.2.1__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.
- python_sn2-0.2.1.dist-info/METADATA +164 -0
- python_sn2-0.2.1.dist-info/RECORD +9 -0
- python_sn2-0.2.1.dist-info/WHEEL +5 -0
- python_sn2-0.2.1.dist-info/licenses/LICENSE +21 -0
- python_sn2-0.2.1.dist-info/top_level.txt +1 -0
- sn2/__init__.py +26 -0
- sn2/device.py +727 -0
- sn2/json_model.py +78 -0
- sn2/py.typed +2 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-sn2
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Python library for SystemNexa2 device integration
|
|
5
|
+
Author: Claes Nordmark
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/konsulten/python-sn2
|
|
8
|
+
Project-URL: Repository, https://github.com/konsulten/python-sn2
|
|
9
|
+
Project-URL: Issues, https://github.com/konsulten/python-sn2/issues
|
|
10
|
+
Keywords: systemnexa,home automation,iot
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: aiohttp>=3.13.2
|
|
19
|
+
Requires-Dist: websockets>=15.0.1
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff==0.14.2; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest==8.4.2; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio==1.2.0; extra == "dev"
|
|
26
|
+
Requires-Dist: bump-my-version>=0.16.0; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# python-sn2
|
|
30
|
+
|
|
31
|
+
Python library for SystemNexa2 device integration.
|
|
32
|
+
|
|
33
|
+
This package provides a client library for communicating with SystemNexa2 smart home
|
|
34
|
+
devices over WebSocket and REST APIs. It supports device discovery, real-time state
|
|
35
|
+
updates, brightness control, and configuration management.
|
|
36
|
+
|
|
37
|
+
Supported Devices
|
|
38
|
+
-----------------
|
|
39
|
+
- Switches: WBR-01
|
|
40
|
+
- Plugs: WPR-01, WPO-01
|
|
41
|
+
- Lights: WBD-01, WPD-01
|
|
42
|
+
|
|
43
|
+
Key Features
|
|
44
|
+
------------
|
|
45
|
+
- Asynchronous communication via WebSocket and REST
|
|
46
|
+
- Real-time device state updates
|
|
47
|
+
- Brightness control for dimmable devices
|
|
48
|
+
- Device settings management (433MHz, LED, DIY mode, etc.)
|
|
49
|
+
- Automatic reconnection handling
|
|
50
|
+
- Error handling and logging
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install python-sn2
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
"""Example usage of the python-sn2 library."""
|
|
62
|
+
|
|
63
|
+
import asyncio
|
|
64
|
+
import logging
|
|
65
|
+
|
|
66
|
+
from sn2.device import Device
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def main() -> None:
|
|
72
|
+
"""Demonstrate device usage."""
|
|
73
|
+
# Create a device instance
|
|
74
|
+
device = Device(host="192.168.1.144")
|
|
75
|
+
|
|
76
|
+
# Initialize the device
|
|
77
|
+
await device.initialize()
|
|
78
|
+
|
|
79
|
+
# Connect to the device
|
|
80
|
+
await device.connect()
|
|
81
|
+
|
|
82
|
+
# Set brightness
|
|
83
|
+
await device.set_brightness(0.75)
|
|
84
|
+
|
|
85
|
+
# Get device information
|
|
86
|
+
info = await device.get_info()
|
|
87
|
+
if info:
|
|
88
|
+
logger.info("Device: %s", info.information.name)
|
|
89
|
+
|
|
90
|
+
# Disconnect
|
|
91
|
+
await device.disconnect()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
logging.basicConfig(level=logging.INFO)
|
|
96
|
+
asyncio.run(main())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
Install development dependencies:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e ".[dev]"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Run tests:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pytest
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Run linting:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
ruff check .
|
|
117
|
+
ruff format .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Release Process
|
|
121
|
+
|
|
122
|
+
This project uses automated versioning and releases. To create a new release:
|
|
123
|
+
|
|
124
|
+
### Automated (Recommended)
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Bump patch version (0.1.0 -> 0.1.1)
|
|
128
|
+
./scripts/release.sh patch
|
|
129
|
+
|
|
130
|
+
# Bump minor version (0.1.0 -> 0.2.0)
|
|
131
|
+
./scripts/release.sh minor
|
|
132
|
+
|
|
133
|
+
# Bump major version (0.1.0 -> 1.0.0)
|
|
134
|
+
./scripts/release.sh major
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This will:
|
|
138
|
+
1. Run tests and linting
|
|
139
|
+
2. Bump version in `pyproject.toml` and `sn2/__init__.py`
|
|
140
|
+
3. Create a git commit and tag
|
|
141
|
+
4. Push to GitHub
|
|
142
|
+
5. Trigger GitHub Actions to build and publish to PyPI
|
|
143
|
+
|
|
144
|
+
### Manual
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# Install bump-my-version
|
|
148
|
+
pip install bump-my-version
|
|
149
|
+
|
|
150
|
+
# Bump version
|
|
151
|
+
bump-my-version bump patch # or minor/major
|
|
152
|
+
|
|
153
|
+
# Push changes and tags
|
|
154
|
+
git push origin main --tags
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The GitHub Actions workflow will automatically:
|
|
158
|
+
- Create a GitHub release with release notes
|
|
159
|
+
- Build the package
|
|
160
|
+
- Publish to PyPI (via Trusted Publishing)
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT License
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
python_sn2-0.2.1.dist-info/licenses/LICENSE,sha256=dO-uIJ3qbt6PTrpwdVEKBJIHaAFVhEXR91P7dAANu0Y,1066
|
|
2
|
+
sn2/__init__.py,sha256=PGkACASzvxYyfdiXfEbsXE6zO_vIn7lNH_v1KYCLMs8,524
|
|
3
|
+
sn2/device.py,sha256=asydQy1kyyz93_Rsa667hSq6_sQ2a2z004dqNrD1bjo,24421
|
|
4
|
+
sn2/json_model.py,sha256=evgpRK2jafS-V5oUGtEKn4jqQEk9jVZmyGzV6raHbFY,2237
|
|
5
|
+
sn2/py.typed,sha256=7RYCdAoNrE5MnGtH_-zfMYAB_onfUNl8j0Awal5nEZw,92
|
|
6
|
+
python_sn2-0.2.1.dist-info/METADATA,sha256=11oyvROQtAcBkcLh_JlTqmcTDEL_UKY6a7dihwi4ISo,3643
|
|
7
|
+
python_sn2-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
python_sn2-0.2.1.dist-info/top_level.txt,sha256=knk8J0MpPhxSRgnB7Ee0dlHP640pnfU6xuN7THxpg4E,4
|
|
9
|
+
python_sn2-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 konsulten
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sn2
|
sn2/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Python library for SystemNexa2 device integration."""
|
|
2
|
+
|
|
3
|
+
from sn2.device import (
|
|
4
|
+
ConnectionStatus,
|
|
5
|
+
Device,
|
|
6
|
+
DeviceInitializationError,
|
|
7
|
+
DeviceUnsupportedError,
|
|
8
|
+
InformationData,
|
|
9
|
+
InformationUpdate,
|
|
10
|
+
OnOffSetting,
|
|
11
|
+
SettingsUpdate,
|
|
12
|
+
StateChange,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.1"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ConnectionStatus",
|
|
18
|
+
"Device",
|
|
19
|
+
"DeviceInitializationError",
|
|
20
|
+
"DeviceUnsupportedError",
|
|
21
|
+
"InformationData",
|
|
22
|
+
"InformationUpdate",
|
|
23
|
+
"OnOffSetting",
|
|
24
|
+
"SettingsUpdate",
|
|
25
|
+
"StateChange",
|
|
26
|
+
]
|
sn2/device.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Device client for SystemNexa2 integration.
|
|
3
|
+
|
|
4
|
+
Handles connection, message processing, and lifecycle events for devices.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Final
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
import websockets
|
|
17
|
+
|
|
18
|
+
from sn2.json_model import DeviceInformation, Settings
|
|
19
|
+
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DeviceInitializationError(Exception):
|
|
24
|
+
"""Exception raised when device initialization fails."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str = "Failed to initialize device") -> None:
|
|
27
|
+
"""Initialize the exception with an optional message."""
|
|
28
|
+
self.message = message
|
|
29
|
+
super().__init__(self.message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeviceUnsupportedError(Exception):
|
|
33
|
+
"""Exception raised when device is unsupported."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str = "Device not supported") -> None:
|
|
36
|
+
"""Initialize the exception with an optional message."""
|
|
37
|
+
self.message = message
|
|
38
|
+
super().__init__(self.message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotConnectedError(Exception):
|
|
42
|
+
"""Exception raised when device has not been connected before running commands."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str = "Device not connected") -> None:
|
|
45
|
+
"""Initialize the exception with an optional message."""
|
|
46
|
+
self.message = message
|
|
47
|
+
super().__init__(self.message)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SWITCH_MODELS: Final = ["WBR-01"]
|
|
51
|
+
PLUG_MODELS: Final = ["WPR-01", "WPO-01"]
|
|
52
|
+
LIGHT_MODELS: Final = ["WBD-01", "WPD-01"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class InformationData:
|
|
57
|
+
"""
|
|
58
|
+
Device information data container.
|
|
59
|
+
|
|
60
|
+
Attributes
|
|
61
|
+
----------
|
|
62
|
+
dimmable : bool
|
|
63
|
+
Whether the device supports dimming.
|
|
64
|
+
model : str | None
|
|
65
|
+
The hardware model of the device.
|
|
66
|
+
sw_version : str | None
|
|
67
|
+
The software version of the device.
|
|
68
|
+
hw_version : str | None
|
|
69
|
+
The hardware version of the device.
|
|
70
|
+
name : str | None
|
|
71
|
+
The name of the device.
|
|
72
|
+
wifi_dbm : int | None
|
|
73
|
+
The WiFi signal strength in dBm.
|
|
74
|
+
wifi_ssid : str | None
|
|
75
|
+
The WiFi SSID the device is connected to.
|
|
76
|
+
unique_id : str | None
|
|
77
|
+
The unique identifier of the device.
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
dimmable: bool = False
|
|
82
|
+
model: str | None = None
|
|
83
|
+
sw_version: str | None = None
|
|
84
|
+
hw_version: str | None = None
|
|
85
|
+
name: str | None = None
|
|
86
|
+
wifi_dbm: int | None = None
|
|
87
|
+
wifi_ssid: str | None = None
|
|
88
|
+
unique_id: str | None = None
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def convert_device_information_to_data(
|
|
92
|
+
info: DeviceInformation,
|
|
93
|
+
) -> "InformationData":
|
|
94
|
+
"""
|
|
95
|
+
Create InformationData from a DeviceInformation object.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
info : DeviceInformation
|
|
100
|
+
The device information object to convert.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
InformationData
|
|
105
|
+
A new InformationData instance populated with the device information.
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
d = InformationData()
|
|
109
|
+
if info.hwm in LIGHT_MODELS:
|
|
110
|
+
d.dimmable = True
|
|
111
|
+
d.model = info.hwm
|
|
112
|
+
d.sw_version = info.nswv
|
|
113
|
+
d.hw_version = str(info.nhwv)
|
|
114
|
+
d.name = info.n
|
|
115
|
+
d.wifi_dbm = info.wr
|
|
116
|
+
d.wifi_ssid = info.ws
|
|
117
|
+
d.unique_id = info.lcu
|
|
118
|
+
|
|
119
|
+
return d
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ConnectionStatus:
|
|
124
|
+
"""Connection status event."""
|
|
125
|
+
|
|
126
|
+
connected: bool
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class InformationUpdate:
|
|
131
|
+
"""Information status event."""
|
|
132
|
+
|
|
133
|
+
information: InformationData
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class StateChange:
|
|
138
|
+
"""
|
|
139
|
+
State change event.
|
|
140
|
+
|
|
141
|
+
Attributes
|
|
142
|
+
----------
|
|
143
|
+
state : float
|
|
144
|
+
The new state value of the device.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
state: float
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Setting:
|
|
152
|
+
"""
|
|
153
|
+
Base class for device settings.
|
|
154
|
+
|
|
155
|
+
Attributes
|
|
156
|
+
----------
|
|
157
|
+
name : str
|
|
158
|
+
The display name of the setting.
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
name: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class OnOffSetting(Setting):
|
|
166
|
+
"""
|
|
167
|
+
A setting that represents an on/off state with configurable values.
|
|
168
|
+
|
|
169
|
+
This class extends the Setting base class to provide a binary state setting
|
|
170
|
+
that can be toggled between two predefined values (on and off).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
name (str): The display name for this setting.
|
|
174
|
+
param_key (str): The parameter key to use when communicating with the device.
|
|
175
|
+
current: The current value/state of the setting.
|
|
176
|
+
on_value: The value that represents the enabled/on state.
|
|
177
|
+
off_value: The value that represents the disabled/off state.
|
|
178
|
+
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self, name: str, param_key: str, current: Any, on_value: Any, off_value: Any
|
|
183
|
+
) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Initialize a Device instance.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
name (str): The name of the device.
|
|
189
|
+
param_key (str): The parameter key used to identify the device
|
|
190
|
+
parameter.
|
|
191
|
+
current (Any): The current state/value of the device.
|
|
192
|
+
on_value (Any): The value that represents the device being in an
|
|
193
|
+
"on" state.
|
|
194
|
+
off_value (Any): The value that represents the device being in an
|
|
195
|
+
"off" state.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
None
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
self.name = name
|
|
202
|
+
self._param_key = param_key
|
|
203
|
+
self._enable_value = on_value
|
|
204
|
+
self._disable_value = off_value
|
|
205
|
+
self._current_state = current
|
|
206
|
+
|
|
207
|
+
async def enable(self, device: "Device") -> None:
|
|
208
|
+
"""
|
|
209
|
+
Enable a setting with the enable value.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
device (Device): The device instance to which the setting should be enabled.
|
|
213
|
+
|
|
214
|
+
"""
|
|
215
|
+
await device.update_setting({self._param_key: self._enable_value})
|
|
216
|
+
|
|
217
|
+
async def disable(self, device: "Device") -> None:
|
|
218
|
+
"""
|
|
219
|
+
Disable the setting.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
device (Device): The device instance to which the setting should be
|
|
223
|
+
disabled.
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
await device.update_setting({self._param_key: self._disable_value})
|
|
227
|
+
|
|
228
|
+
def is_enabled(self) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
Check if the setting is currently enabled.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
bool: True if the device's current state matches the enable value,
|
|
234
|
+
False otherwise.
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
return self._current_state == self._enable_value
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class SettingsUpdate:
|
|
242
|
+
"""Settings update event."""
|
|
243
|
+
|
|
244
|
+
settings: list[Setting]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
UpdateEvent = ConnectionStatus | InformationUpdate | SettingsUpdate | StateChange
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Device:
|
|
251
|
+
"""
|
|
252
|
+
Represents a client for SystemNexa2 device integration.
|
|
253
|
+
|
|
254
|
+
Handles connection, message processing, and lifecycle events for devices.
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _is_version_compatible(version: str | None, min_version: str) -> bool:
|
|
260
|
+
"""Check if a version string meets minimum version requirements."""
|
|
261
|
+
if version is None:
|
|
262
|
+
return False
|
|
263
|
+
if min_version is None:
|
|
264
|
+
msg = "min_version needs to be set when comparing"
|
|
265
|
+
raise ValueError(msg)
|
|
266
|
+
try:
|
|
267
|
+
# Clean up version strings - remove any pre-release indicators
|
|
268
|
+
# Example: "0.9.5-beta.2" becomes "0.9.5"
|
|
269
|
+
clean_version = version.split("-")[0].split("+")[0]
|
|
270
|
+
clean_min_version = min_version.split("-")[0].split("+")[0]
|
|
271
|
+
|
|
272
|
+
# Split version strings into components
|
|
273
|
+
version_parts = [int(part) for part in clean_version.split(".")]
|
|
274
|
+
min_version_parts = [int(part) for part in clean_min_version.split(".")]
|
|
275
|
+
|
|
276
|
+
# Pad shorter lists with zeros
|
|
277
|
+
while len(version_parts) < len(min_version_parts):
|
|
278
|
+
version_parts.append(0)
|
|
279
|
+
while len(min_version_parts) < len(version_parts):
|
|
280
|
+
min_version_parts.append(0)
|
|
281
|
+
|
|
282
|
+
# Compare version components
|
|
283
|
+
for v, m in zip(version_parts, min_version_parts, strict=False):
|
|
284
|
+
if v > m:
|
|
285
|
+
return True
|
|
286
|
+
if v < m:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
# All components are equal, so versions are equal
|
|
290
|
+
|
|
291
|
+
except (ValueError, IndexError):
|
|
292
|
+
# If parsing fails, log the error and reject the version
|
|
293
|
+
_LOGGER.exception(
|
|
294
|
+
"Error parsing version strings '%s' and '%s'",
|
|
295
|
+
version,
|
|
296
|
+
min_version,
|
|
297
|
+
)
|
|
298
|
+
return False
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def is_device_supported(
|
|
303
|
+
model: str | None, device_version: str | None
|
|
304
|
+
) -> tuple[bool, str]:
|
|
305
|
+
"""Check if a device is supported based on model and firmware version."""
|
|
306
|
+
# Check if this is a supported device
|
|
307
|
+
if model is None:
|
|
308
|
+
return False, "Missing model information"
|
|
309
|
+
|
|
310
|
+
# Verify model is in our supported lists
|
|
311
|
+
if (
|
|
312
|
+
model not in SWITCH_MODELS
|
|
313
|
+
and model not in LIGHT_MODELS
|
|
314
|
+
and model not in PLUG_MODELS
|
|
315
|
+
):
|
|
316
|
+
return False, f"Unsupported model: {model}"
|
|
317
|
+
|
|
318
|
+
# Check firmware version requirement
|
|
319
|
+
if device_version is None:
|
|
320
|
+
return False, "Missing firmware version"
|
|
321
|
+
|
|
322
|
+
# Version check - require at least 0.9.5
|
|
323
|
+
if not Device._is_version_compatible(device_version, min_version="0.9.5"):
|
|
324
|
+
return (
|
|
325
|
+
False,
|
|
326
|
+
f"Incompatible firmware version {device_version} (min required: 0.9.5)",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return True, ""
|
|
330
|
+
|
|
331
|
+
def __init__(
|
|
332
|
+
self,
|
|
333
|
+
host: str,
|
|
334
|
+
on_update: Callable[[UpdateEvent], Awaitable[None] | None] | None = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""
|
|
337
|
+
Initialize the Device client.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
host (str): The host address of the device.
|
|
341
|
+
on_update (Callable[[UpdateEvent], Awaitable[None] | None] | None):
|
|
342
|
+
Callback for device update events.
|
|
343
|
+
|
|
344
|
+
"""
|
|
345
|
+
self.host = host
|
|
346
|
+
self._trying_to_connect = False
|
|
347
|
+
self._websocket: websockets.ClientConnection | None = None
|
|
348
|
+
self._ws_task: asyncio.Task[None] | None = None
|
|
349
|
+
self._login_key = None
|
|
350
|
+
self._version: str | None = None
|
|
351
|
+
self.info_data: InformationData | None = None
|
|
352
|
+
self.initialized = False
|
|
353
|
+
self.settings: list[Setting] = []
|
|
354
|
+
|
|
355
|
+
# Callbacks
|
|
356
|
+
self._on_update = on_update
|
|
357
|
+
|
|
358
|
+
async def initialize(self) -> None:
|
|
359
|
+
"""
|
|
360
|
+
Initialize the device by fetching settings and information.
|
|
361
|
+
|
|
362
|
+
Raises
|
|
363
|
+
------
|
|
364
|
+
DeviceInitializationError
|
|
365
|
+
If fetching settings or information fails.
|
|
366
|
+
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
settings = await self.get_settings()
|
|
370
|
+
info = await self.get_info()
|
|
371
|
+
self._version = info.information.sw_version
|
|
372
|
+
if info and settings:
|
|
373
|
+
self.settings = settings
|
|
374
|
+
self.info_data = info.information
|
|
375
|
+
self.initialized = True
|
|
376
|
+
except Exception as e:
|
|
377
|
+
msg = "Failed to initialize device"
|
|
378
|
+
raise DeviceInitializationError(msg) from e
|
|
379
|
+
|
|
380
|
+
async def _emit(self, event: UpdateEvent) -> None:
|
|
381
|
+
"""Invoke unified callback if provided."""
|
|
382
|
+
if not self._on_update:
|
|
383
|
+
return
|
|
384
|
+
try:
|
|
385
|
+
result = self._on_update(event)
|
|
386
|
+
if isinstance(result, Awaitable):
|
|
387
|
+
await result
|
|
388
|
+
except Exception:
|
|
389
|
+
_LOGGER.exception("on_update callback failed for %s", event)
|
|
390
|
+
|
|
391
|
+
async def connect(self) -> None:
|
|
392
|
+
"""
|
|
393
|
+
Establish a connection to the device via websocket.
|
|
394
|
+
|
|
395
|
+
Starts the websocket client task for handling device communication.
|
|
396
|
+
"""
|
|
397
|
+
if self._ws_task is not None:
|
|
398
|
+
return # Already connected
|
|
399
|
+
|
|
400
|
+
self._trying_to_connect = True
|
|
401
|
+
self._ws_task = asyncio.create_task(self._handle_connection())
|
|
402
|
+
|
|
403
|
+
# Set up connection and cleanup
|
|
404
|
+
async def _handle_connection(self) -> None:
|
|
405
|
+
"""Start the websocket client for the device."""
|
|
406
|
+
uri = f"ws://{self.host}:3000/live"
|
|
407
|
+
|
|
408
|
+
while True:
|
|
409
|
+
try:
|
|
410
|
+
async with websockets.connect(uri) as websocket:
|
|
411
|
+
self._websocket = websocket
|
|
412
|
+
# Set device as available since connection is established
|
|
413
|
+
|
|
414
|
+
# Send login message immediately after connection
|
|
415
|
+
login_message = {"type": "login", "value": ""}
|
|
416
|
+
await websocket.send(json.dumps(login_message))
|
|
417
|
+
|
|
418
|
+
await self._emit(ConnectionStatus(connected=True))
|
|
419
|
+
_LOGGER.debug("Sent login message: %s", login_message)
|
|
420
|
+
|
|
421
|
+
# Listen for messages from the device
|
|
422
|
+
while True:
|
|
423
|
+
try:
|
|
424
|
+
message = await websocket.recv()
|
|
425
|
+
_LOGGER.debug("Received message: %s", message)
|
|
426
|
+
# Process the message and update entity states
|
|
427
|
+
match message:
|
|
428
|
+
case bytes():
|
|
429
|
+
await self._process_message(message.decode("utf-8"))
|
|
430
|
+
case str():
|
|
431
|
+
await self._process_message(message)
|
|
432
|
+
|
|
433
|
+
except websockets.exceptions.ConnectionClosed:
|
|
434
|
+
await self._emit(ConnectionStatus(connected=False))
|
|
435
|
+
|
|
436
|
+
break
|
|
437
|
+
await asyncio.sleep(1)
|
|
438
|
+
except asyncio.CancelledError:
|
|
439
|
+
break
|
|
440
|
+
except BaseException:
|
|
441
|
+
# Set device as unavailable when connection attempt fails
|
|
442
|
+
await self._emit(ConnectionStatus(connected=False))
|
|
443
|
+
_LOGGER.exception("Lost connection to: %s", self.host)
|
|
444
|
+
# Wait before trying to reconnect
|
|
445
|
+
try:
|
|
446
|
+
await asyncio.sleep(1)
|
|
447
|
+
except asyncio.CancelledError:
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
async def disconnect(self) -> None:
|
|
451
|
+
"""Stop the websocket client."""
|
|
452
|
+
self._trying_to_connect = False
|
|
453
|
+
if self._ws_task is not None:
|
|
454
|
+
self._ws_task.cancel()
|
|
455
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
456
|
+
await self._ws_task
|
|
457
|
+
|
|
458
|
+
if self._websocket is not None:
|
|
459
|
+
await self._websocket.close()
|
|
460
|
+
self._websocket = None
|
|
461
|
+
await self._emit(ConnectionStatus(connected=False))
|
|
462
|
+
|
|
463
|
+
async def _process_message(self, message: str) -> None:
|
|
464
|
+
"""Process a message from the device."""
|
|
465
|
+
try:
|
|
466
|
+
data = json.loads(message)
|
|
467
|
+
|
|
468
|
+
# Handle reset message - device wants to be removed
|
|
469
|
+
match data.get("type"):
|
|
470
|
+
case "device_reset":
|
|
471
|
+
_LOGGER.info("device_reset")
|
|
472
|
+
return
|
|
473
|
+
case "state":
|
|
474
|
+
# Handle state updates
|
|
475
|
+
state_value = float(data.get("value", 0))
|
|
476
|
+
# Find the entity directly from the device_info
|
|
477
|
+
await self._emit(StateChange(state_value))
|
|
478
|
+
case "information":
|
|
479
|
+
info_message = data.get("value")
|
|
480
|
+
information = DeviceInformation(**info_message)
|
|
481
|
+
_LOGGER.debug("information received %s", information)
|
|
482
|
+
await self._emit(
|
|
483
|
+
InformationUpdate(
|
|
484
|
+
InformationData.convert_device_information_to_data(
|
|
485
|
+
information
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
case "settings":
|
|
490
|
+
settings = data.get("value")
|
|
491
|
+
settings = Settings(**settings)
|
|
492
|
+
await self._emit(
|
|
493
|
+
SettingsUpdate(settings=await self._parse_settings(settings))
|
|
494
|
+
)
|
|
495
|
+
case "ack":
|
|
496
|
+
_LOGGER.debug("Ack received?")
|
|
497
|
+
case unknown:
|
|
498
|
+
_LOGGER.error("unknown data received %s", unknown)
|
|
499
|
+
|
|
500
|
+
except json.JSONDecodeError:
|
|
501
|
+
_LOGGER.exception("Invalid JSON received %s", unknown)
|
|
502
|
+
except Exception:
|
|
503
|
+
_LOGGER.exception("Error processing message %s", message)
|
|
504
|
+
|
|
505
|
+
async def set_brightness(self, value: float) -> None:
|
|
506
|
+
"""
|
|
507
|
+
Set the brightness level of the device.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
value : float
|
|
512
|
+
The brightness value between 0.0 (off) and 1.0 (full brightness).
|
|
513
|
+
|
|
514
|
+
Raises
|
|
515
|
+
------
|
|
516
|
+
ValueError
|
|
517
|
+
If the brightness value is not between 0 and 1.
|
|
518
|
+
|
|
519
|
+
"""
|
|
520
|
+
if not 0 <= value <= 1:
|
|
521
|
+
msg = f"Brightness value must be between 0 and 1, got {value}"
|
|
522
|
+
raise ValueError(msg)
|
|
523
|
+
await self.send_command({"type": "state", "value": value})
|
|
524
|
+
|
|
525
|
+
async def toggle(self) -> None:
|
|
526
|
+
"""Toggle the device state between on and off."""
|
|
527
|
+
await self.send_command({"type": "state", "value": -1})
|
|
528
|
+
|
|
529
|
+
async def turn_off(self) -> None:
|
|
530
|
+
"""Turn off the device."""
|
|
531
|
+
if self._is_version_compatible(self._version, "1.1.8"):
|
|
532
|
+
await self.send_command({"type": "state", "on": False})
|
|
533
|
+
else:
|
|
534
|
+
await self.send_command({"type": "state", "value": 0})
|
|
535
|
+
|
|
536
|
+
async def turn_on(self) -> None:
|
|
537
|
+
"""Turn on the device."""
|
|
538
|
+
if self._is_version_compatible(self._version, "1.1.8"):
|
|
539
|
+
await self.send_command({"type": "state", "on": True})
|
|
540
|
+
else:
|
|
541
|
+
await self.send_command({"type": "state", "value": -1})
|
|
542
|
+
|
|
543
|
+
async def send_command(
|
|
544
|
+
self,
|
|
545
|
+
command: dict[str, Any],
|
|
546
|
+
retries: int = 3,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""
|
|
549
|
+
Send a command to the device via WebSocket.
|
|
550
|
+
|
|
551
|
+
This method serializes the command dictionary to JSON and sends it through
|
|
552
|
+
the WebSocket connection. It handles connection errors and updates the
|
|
553
|
+
connection status accordingly.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
command: A dictionary containing the command data to send to the device.
|
|
557
|
+
timeout_seconds: Maximum time in seconds to wait for the send operation.
|
|
558
|
+
retries: Number of retry attempts if the command fails to send.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
None
|
|
562
|
+
|
|
563
|
+
Raises:
|
|
564
|
+
NotConnectedError: If there is no active WebSocket connection.
|
|
565
|
+
TimeoutError: If the command send operation times out.
|
|
566
|
+
|
|
567
|
+
"""
|
|
568
|
+
if self._websocket is None and not self._trying_to_connect:
|
|
569
|
+
_LOGGER.error(
|
|
570
|
+
"Cannot send command to %s - Please connect() first",
|
|
571
|
+
self.host,
|
|
572
|
+
)
|
|
573
|
+
raise NotConnectedError
|
|
574
|
+
|
|
575
|
+
command_str = json.dumps(command)
|
|
576
|
+
last_exception: Exception | None = None
|
|
577
|
+
|
|
578
|
+
for attempt in range(1, retries + 1):
|
|
579
|
+
try:
|
|
580
|
+
_LOGGER.info(
|
|
581
|
+
"Sending command to %s (attempt %d/%d): %s",
|
|
582
|
+
self.host,
|
|
583
|
+
attempt,
|
|
584
|
+
retries,
|
|
585
|
+
command_str,
|
|
586
|
+
)
|
|
587
|
+
if self._websocket:
|
|
588
|
+
await self._websocket.send(command_str)
|
|
589
|
+
except websockets.exceptions.ConnectionClosedError as err:
|
|
590
|
+
last_exception = err
|
|
591
|
+
_LOGGER.exception(
|
|
592
|
+
"Failed to send command to %s - connection closed: %s %s",
|
|
593
|
+
self.host,
|
|
594
|
+
err.code,
|
|
595
|
+
err.reason,
|
|
596
|
+
)
|
|
597
|
+
# Mark entity as unavailable when command fails due to connection
|
|
598
|
+
await self._emit(ConnectionStatus(connected=False))
|
|
599
|
+
except websockets.exceptions.ConnectionClosedOK as err:
|
|
600
|
+
last_exception = err
|
|
601
|
+
_LOGGER.exception(
|
|
602
|
+
"Failed to send command to %s - connection closed due to : %s %s",
|
|
603
|
+
self.host,
|
|
604
|
+
err.code,
|
|
605
|
+
err.reason,
|
|
606
|
+
)
|
|
607
|
+
# Mark entity as unavailable when command fails due to connection
|
|
608
|
+
await self._emit(ConnectionStatus(connected=False))
|
|
609
|
+
raise NotConnectedError from err
|
|
610
|
+
except Exception as err:
|
|
611
|
+
last_exception = err
|
|
612
|
+
_LOGGER.exception(
|
|
613
|
+
"Failed to send command to %s (attempt %d/%d)",
|
|
614
|
+
self.host,
|
|
615
|
+
attempt,
|
|
616
|
+
retries,
|
|
617
|
+
)
|
|
618
|
+
await self._emit(ConnectionStatus(connected=False))
|
|
619
|
+
else:
|
|
620
|
+
_LOGGER.debug(
|
|
621
|
+
"Command %s sent successfully to %s", command_str, self.host
|
|
622
|
+
)
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
# Wait a bit before retrying (exponential backoff)
|
|
626
|
+
if attempt < retries:
|
|
627
|
+
await asyncio.sleep(0.5 * (2**attempt))
|
|
628
|
+
|
|
629
|
+
# If we get here, all retries failed
|
|
630
|
+
_LOGGER.error(
|
|
631
|
+
"Failed to send command to %s after %d attempts", self.host, retries
|
|
632
|
+
)
|
|
633
|
+
if last_exception:
|
|
634
|
+
raise last_exception
|
|
635
|
+
|
|
636
|
+
async def is_supported(self) -> bool:
|
|
637
|
+
"""Check if the device is supported based on model and firmware version."""
|
|
638
|
+
info = await self.get_info()
|
|
639
|
+
supported, _ = Device.is_device_supported(
|
|
640
|
+
model=info.information.model, device_version=info.information.sw_version
|
|
641
|
+
)
|
|
642
|
+
return supported
|
|
643
|
+
|
|
644
|
+
async def _parse_settings(self, settings: Settings) -> list[Setting]:
|
|
645
|
+
settings_list: list[Setting] = []
|
|
646
|
+
if settings.disable_433 is not None:
|
|
647
|
+
settings_list.append(
|
|
648
|
+
OnOffSetting(
|
|
649
|
+
param_key="disable_433",
|
|
650
|
+
name="433Mhz",
|
|
651
|
+
off_value=1,
|
|
652
|
+
on_value=0,
|
|
653
|
+
current=settings.disable_433,
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
if settings.disable_physical_button is not None:
|
|
657
|
+
settings_list.append(
|
|
658
|
+
OnOffSetting(
|
|
659
|
+
param_key="disable_physical_button",
|
|
660
|
+
name="Physical Button",
|
|
661
|
+
off_value=1,
|
|
662
|
+
on_value=0,
|
|
663
|
+
current=settings.disable_physical_button,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
if settings.disable_led is not None:
|
|
667
|
+
settings_list.append(
|
|
668
|
+
OnOffSetting(
|
|
669
|
+
param_key="disable_led",
|
|
670
|
+
name="Led",
|
|
671
|
+
off_value=1,
|
|
672
|
+
on_value=0,
|
|
673
|
+
current=settings.disable_led,
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
if settings.diy_mode is not None:
|
|
677
|
+
settings_list.append(
|
|
678
|
+
OnOffSetting(
|
|
679
|
+
param_key="diy_mode",
|
|
680
|
+
name="Cloud Access",
|
|
681
|
+
off_value=1,
|
|
682
|
+
on_value=0,
|
|
683
|
+
current=settings.diy_mode,
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
return settings_list
|
|
687
|
+
|
|
688
|
+
async def update_setting(self, settings: dict[str, Any]) -> None:
|
|
689
|
+
"""Update device settings via REST API."""
|
|
690
|
+
url = f"http://{self.host}:3000/settings"
|
|
691
|
+
try:
|
|
692
|
+
async with (
|
|
693
|
+
aiohttp.ClientSession() as session,
|
|
694
|
+
session.post(url, json=settings) as response,
|
|
695
|
+
):
|
|
696
|
+
response.raise_for_status()
|
|
697
|
+
|
|
698
|
+
_LOGGER.debug("Updated settings at %s with %s", url, settings)
|
|
699
|
+
except Exception:
|
|
700
|
+
_LOGGER.exception("Failed to update settings at %s", url)
|
|
701
|
+
raise
|
|
702
|
+
|
|
703
|
+
async def get_settings(self) -> list[Setting]:
|
|
704
|
+
"""Fetch device settings via REST API."""
|
|
705
|
+
url = f"http://{self.host}:3000/settings"
|
|
706
|
+
try:
|
|
707
|
+
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
|
708
|
+
json_resp = await response.json()
|
|
709
|
+
return await self._parse_settings(Settings(**json_resp))
|
|
710
|
+
except Exception:
|
|
711
|
+
_LOGGER.exception("Failed to fetch settings from %s", url)
|
|
712
|
+
raise
|
|
713
|
+
|
|
714
|
+
async def get_info(self) -> InformationUpdate:
|
|
715
|
+
"""Fetch device information via REST API."""
|
|
716
|
+
url = f"http://{self.host}:3000/info"
|
|
717
|
+
try:
|
|
718
|
+
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
|
719
|
+
response.raise_for_status()
|
|
720
|
+
information = DeviceInformation(**await response.json())
|
|
721
|
+
self.info_data = InformationData.convert_device_information_to_data(
|
|
722
|
+
information
|
|
723
|
+
)
|
|
724
|
+
return InformationUpdate(self.info_data)
|
|
725
|
+
except:
|
|
726
|
+
_LOGGER.exception("Failed to fetch device information from %s:", url)
|
|
727
|
+
raise
|
sn2/json_model.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Data models for device information and settings."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class SomeInfo:
|
|
8
|
+
"""Represents some information with various attributes."""
|
|
9
|
+
|
|
10
|
+
s: int | None = None
|
|
11
|
+
v: int | None = None
|
|
12
|
+
bp: int | None = None
|
|
13
|
+
bpr: int | None = None
|
|
14
|
+
bi: int | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DeviceInformation:
|
|
19
|
+
"""Represents device information with various attributes."""
|
|
20
|
+
|
|
21
|
+
ak: str | None = None
|
|
22
|
+
fhs: int | None = None
|
|
23
|
+
u: int | None = None
|
|
24
|
+
wr: int | None = None
|
|
25
|
+
ss: str | None = None
|
|
26
|
+
t: str | None = None
|
|
27
|
+
n: str | None = None
|
|
28
|
+
tsc: int | None = None
|
|
29
|
+
lcu: str | None = None
|
|
30
|
+
lat: int | None = None
|
|
31
|
+
lon: int | None = None
|
|
32
|
+
cs: bool | None = None
|
|
33
|
+
sr_h: int | None = None
|
|
34
|
+
sr_m: int | None = None
|
|
35
|
+
ss_h: int | None = None
|
|
36
|
+
ss_m: int | None = None
|
|
37
|
+
tz_o: int | None = None
|
|
38
|
+
tz_i: int | None = None
|
|
39
|
+
tz_dst: int | None = None
|
|
40
|
+
c: bool | None = None
|
|
41
|
+
ws: str | None = None
|
|
42
|
+
rr: int | None = None
|
|
43
|
+
hwm: str | None = None
|
|
44
|
+
nhwv: int | None = None
|
|
45
|
+
nswv: str | None = None
|
|
46
|
+
b: SomeInfo | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Settings:
|
|
51
|
+
"""Represents device settings with various configuration attributes."""
|
|
52
|
+
|
|
53
|
+
name: str | None = None
|
|
54
|
+
tz_id: int | None = None
|
|
55
|
+
auto_on_seconds: int | None = None
|
|
56
|
+
auto_off_seconds: int | None = None
|
|
57
|
+
enable_local_security: int | None = None
|
|
58
|
+
vacation_mode: int | None = None
|
|
59
|
+
state_after_powerloss: int | None = None
|
|
60
|
+
disable_physical_button: int | None = None
|
|
61
|
+
disable_433: int | None = None
|
|
62
|
+
disable_multi_press: int | None = None
|
|
63
|
+
disable_network_ctrl: int | None = None
|
|
64
|
+
disable_led: int | None = None
|
|
65
|
+
disable_on_transmitters: int | None = None
|
|
66
|
+
disable_off_transmitters: int | None = None
|
|
67
|
+
dimmer_edge: int | None = None
|
|
68
|
+
blink_on_433_on: int | None = None
|
|
69
|
+
button_type: int | None = None
|
|
70
|
+
diy_mode: int | None = None
|
|
71
|
+
toggle_433: int | None = None
|
|
72
|
+
position_man_set: int | None = None
|
|
73
|
+
dimmer_on_start_level: int | None = None
|
|
74
|
+
dimmer_off_level: int | None = None
|
|
75
|
+
dimmer_min_dim: int | None = None
|
|
76
|
+
remote_log: int | None = None
|
|
77
|
+
notifcation_on: int | None = None
|
|
78
|
+
notifcation_off: int | None = None
|
sn2/py.typed
ADDED