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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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
@@ -0,0 +1,2 @@
1
+ # Marker file for PEP 561
2
+ # This file indicates that the sn2 package supports type checking