python-swidget 1.3.3__tar.gz → 1.4.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,270 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-swidget
3
+ Version: 1.4.0
4
+ Summary: Python API for Swidget smart devices
5
+ License: GPL-3.0-or-later
6
+ Author: Swidget
7
+ Requires-Python: >=3.8,<4.0
8
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Provides-Extra: docs
18
+ Requires-Dist: aiohttp (>=3.8.1)
19
+ Requires-Dist: anyio
20
+ Requires-Dist: asyncclick (>=8)
21
+ Requires-Dist: importlib-metadata
22
+ Requires-Dist: m2r (>=0,<1) ; extra == "docs"
23
+ Requires-Dist: mistune (<2.0.0) ; extra == "docs"
24
+ Requires-Dist: pydantic (>=2,<3)
25
+ Requires-Dist: requests (==2.32.5)
26
+ Requires-Dist: sphinx (>=4,<5) ; extra == "docs"
27
+ Requires-Dist: sphinx_rtd_theme (>=0,<1) ; extra == "docs"
28
+ Requires-Dist: sphinxcontrib-programoutput (>=0,<1) ; extra == "docs"
29
+ Requires-Dist: ssdp (==1.1.1)
30
+ Requires-Dist: types-requests (>=2.32.0,<3.0.0)
31
+ Requires-Dist: urllib3 (==2.5.0)
32
+ Project-URL: Repository, https://github.com/swidget/python-swidget
33
+ Description-Content-Type: text/markdown
34
+
35
+ python-swidget
36
+ ==============
37
+
38
+ ![CI](https://github.com/swidget/python-swidget/actions/workflows/ci.yml/badge.svg)
39
+ ![PyPI](https://img.shields.io/pypi/v/python-swidget)
40
+ ![License](https://img.shields.io/pypi/l/python-swidget)
41
+
42
+ Python SDK for Swidget smart devices. It supports local HTTP control and websockets for realtime updates, along with SSDP discovery, Wi-Fi provisioning helpers, and a CLI tool (`swidget`).
43
+
44
+ Table of contents
45
+ -----------------
46
+ - Installation
47
+ - Quickstart
48
+ - Device types and helpers
49
+ - HTTP vs websocket modes
50
+ - Discovery
51
+ - Provisioning (AP mode)
52
+ - CLI usage
53
+ - Development (tests, lint, hooks)
54
+
55
+ Installation
56
+ ------------
57
+ Using Poetry (preferred):
58
+ ```
59
+ poetry install --with dev
60
+ ```
61
+
62
+ Using pip:
63
+ ```
64
+ pip install python-swidget
65
+ ```
66
+
67
+ Quickstart
68
+ ----------
69
+ ```python
70
+ from swidget import SwidgetDimmer
71
+
72
+ dev = SwidgetDimmer(
73
+ host="192.168.1.50",
74
+ token_name="x-secret-key",
75
+ secret_key="password",
76
+ use_https=True,
77
+ use_websockets=False, # set True for realtime updates
78
+ )
79
+
80
+ # HTTP-only mode
81
+ await dev.update()
82
+ await dev.turn_on()
83
+ await dev.turn_off()
84
+ await dev.close()
85
+ ```
86
+
87
+ Websocket mode (realtime)
88
+ ```python
89
+ dev = SwidgetDimmer(
90
+ host="192.168.1.50",
91
+ token_name="x-secret-key",
92
+ secret_key="password",
93
+ use_https=True,
94
+ use_websockets=True,
95
+ )
96
+ await dev.start() # opens websocket and updates state
97
+ await dev.turn_on()
98
+ await dev.close()
99
+ ```
100
+
101
+ Device types and helpers
102
+ ------------------------
103
+ - `SwidgetDevice` – base class (shared HTTP/websocket helpers)
104
+ - `SwidgetDimmer` – brightness control (`brightness`, `set_brightness`)
105
+ - `SwidgetOutlet` – outlet control (`turn_on`, `turn_off`, power readings)
106
+ - `SwidgetSwitch` – generic switch control (`turn_on`, `turn_off`)
107
+ - `SwidgetTimerSwitch` – timer-capable switch (`set_countdown_timer`)
108
+
109
+ Common properties:
110
+ - `device_type`, `insert_type`, `friendly_name`, `hw_info`
111
+ - `realtime_values`: convenience dict of sensor/power values
112
+
113
+ Common actions:
114
+ - `turn_on()`, `turn_off()`, `blink()`, `ping()`
115
+ - `update()` to refresh summary/state/config
116
+ - `send_command(assembly, component, function, command_dict)` for raw control
117
+
118
+ HTTP vs websocket modes
119
+ -----------------------
120
+ - HTTP-only: set `use_websockets=False`. All operations go over REST (`/api/v1/...`).
121
+ - Websocket mode: set `use_websockets=True`. Summary/state updates and commands use the socket when connected; HTTP is used as fallback where applicable.
122
+ - TLS: `use_https=True` disables certificate verification by default; pass `verify_ssl=True` when constructing `SwidgetDevice` if you have valid certs.
123
+
124
+ Discovery
125
+ ---------
126
+ Discover devices via SSDP:
127
+ ```python
128
+ from swidget import discover_devices
129
+ devices = await discover_devices(timeout=5)
130
+ for mac, dev in devices.items():
131
+ print(dev.host, dev.friendly_name, dev.host_type, dev.insert_type)
132
+ ```
133
+
134
+ Discover a single device when you know the IP:
135
+ ```python
136
+ from swidget import discover_single
137
+ dev = await discover_single(
138
+ host="192.168.1.50",
139
+ token_name="x-secret-key",
140
+ password="device_password",
141
+ use_https=True,
142
+ use_websockets=False,
143
+ )
144
+ await dev.update()
145
+ ```
146
+
147
+ Provisioning (AP mode)
148
+ ----------------------
149
+ Provision a device while connected to its AP (`Swidget-...` SSID):
150
+ ```python
151
+ from swidget import provision_wifi
152
+ provision_wifi(
153
+ device_name="My Swidget",
154
+ ssid="HomeWiFi",
155
+ network_password="wifi-pass",
156
+ secret_key="factory-secret",
157
+ )
158
+ ```
159
+ Notes:
160
+ - Provisioning disables TLS verification; run only on the device’s AP network.
161
+ - The helper waits for connection success and triggers setup completion.
162
+
163
+ CLI usage
164
+ ---------
165
+ Install the console entrypoint via Poetry/pip and run:
166
+ ```
167
+ swidget --host 192.168.1.50 --password device_password --type dimmer state
168
+ swidget --host 192.168.1.50 --password device_password on
169
+ swidget --host 192.168.1.50 --password device_password brightness 80
170
+ swidget discover
171
+ ```
172
+ Key options:
173
+ - `--http_only` (flag) to force HTTP-only (no websockets)
174
+ - `--type` to skip discovery when you know the device type
175
+ - `wifi join` to provision via AP (prompts for SSID/password)
176
+
177
+ Development
178
+ -----------
179
+ Run tests and lint (hook runs both):
180
+ ```
181
+ pytest
182
+ black --check .
183
+ ```
184
+
185
+ If you install via plain pip and want the dev toolchain (tests, typing, docs, formatting):
186
+ ```
187
+ pip install pytest pytest-asyncio aioresponses mypy black sphinx sphinx-rtd-theme sphinx-autobuild
188
+ ```
189
+
190
+ Enable git hook (optional):
191
+ ```
192
+ git config core.hooksPath githooks
193
+ ```
194
+
195
+ Code structure:
196
+ - `swidget/` core library: device models, discovery, provisioning, websocket client, CLI
197
+ - `tests/` unit tests (network mocked)
198
+ - `devtools/synthetic_test.py` optional integration script for real devices (kept separate from unit tests)
199
+ - `examples/` runnable samples (dimmer HTTP, outlet power, timer switch, snapshot)
200
+
201
+ Additional examples
202
+ -------------------
203
+
204
+ Outlet (power reading + toggle)
205
+ ```
206
+ from swidget import SwidgetOutlet
207
+
208
+ dev = SwidgetOutlet(
209
+ host="192.168.1.60",
210
+ token_name="x-secret-key",
211
+ secret_key="password",
212
+ use_https=True,
213
+ use_websockets=False,
214
+ )
215
+ await dev.update()
216
+ print("Host features:", dev.host_features)
217
+ print("Realtime values:", dev.realtime_values)
218
+ await dev.turn_on()
219
+ await dev.turn_off()
220
+ await dev.close()
221
+ ```
222
+
223
+ Timer switch
224
+ ```
225
+ from swidget import SwidgetTimerSwitch
226
+
227
+ dev = SwidgetTimerSwitch(
228
+ host="192.168.1.61",
229
+ token_name="x-secret-key",
230
+ secret_key="password",
231
+ use_https=True,
232
+ use_websockets=False,
233
+ )
234
+ await dev.set_countdown_timer(20) # minutes
235
+ await dev.close()
236
+ ```
237
+
238
+ Snapshots (video-capable insert)
239
+ ```
240
+ from swidget import SwidgetDevice
241
+
242
+ dev = SwidgetDevice(
243
+ host="192.168.1.62",
244
+ token_name="x-secret-key",
245
+ secret_key="password",
246
+ use_https=True,
247
+ use_websockets=False,
248
+ )
249
+ await dev.update()
250
+ img = await dev.get_snapshot_bytes(width=640, height=360)
251
+ with open("snapshot.jpg", "wb") as f:
252
+ f.write(img)
253
+ await dev.close()
254
+ ```
255
+
256
+ Type checking
257
+ -------------
258
+ Run mypy in strict mode:
259
+ ```
260
+ poetry run mypy --strict swidget
261
+ ```
262
+
263
+ Docs build
264
+ ----------
265
+ Generate HTML docs with Sphinx:
266
+ ```
267
+ cd docs
268
+ poetry run sphinx-build -b html . _build
269
+ ```
270
+
@@ -0,0 +1,235 @@
1
+ python-swidget
2
+ ==============
3
+
4
+ ![CI](https://github.com/swidget/python-swidget/actions/workflows/ci.yml/badge.svg)
5
+ ![PyPI](https://img.shields.io/pypi/v/python-swidget)
6
+ ![License](https://img.shields.io/pypi/l/python-swidget)
7
+
8
+ Python SDK for Swidget smart devices. It supports local HTTP control and websockets for realtime updates, along with SSDP discovery, Wi-Fi provisioning helpers, and a CLI tool (`swidget`).
9
+
10
+ Table of contents
11
+ -----------------
12
+ - Installation
13
+ - Quickstart
14
+ - Device types and helpers
15
+ - HTTP vs websocket modes
16
+ - Discovery
17
+ - Provisioning (AP mode)
18
+ - CLI usage
19
+ - Development (tests, lint, hooks)
20
+
21
+ Installation
22
+ ------------
23
+ Using Poetry (preferred):
24
+ ```
25
+ poetry install --with dev
26
+ ```
27
+
28
+ Using pip:
29
+ ```
30
+ pip install python-swidget
31
+ ```
32
+
33
+ Quickstart
34
+ ----------
35
+ ```python
36
+ from swidget import SwidgetDimmer
37
+
38
+ dev = SwidgetDimmer(
39
+ host="192.168.1.50",
40
+ token_name="x-secret-key",
41
+ secret_key="password",
42
+ use_https=True,
43
+ use_websockets=False, # set True for realtime updates
44
+ )
45
+
46
+ # HTTP-only mode
47
+ await dev.update()
48
+ await dev.turn_on()
49
+ await dev.turn_off()
50
+ await dev.close()
51
+ ```
52
+
53
+ Websocket mode (realtime)
54
+ ```python
55
+ dev = SwidgetDimmer(
56
+ host="192.168.1.50",
57
+ token_name="x-secret-key",
58
+ secret_key="password",
59
+ use_https=True,
60
+ use_websockets=True,
61
+ )
62
+ await dev.start() # opens websocket and updates state
63
+ await dev.turn_on()
64
+ await dev.close()
65
+ ```
66
+
67
+ Device types and helpers
68
+ ------------------------
69
+ - `SwidgetDevice` – base class (shared HTTP/websocket helpers)
70
+ - `SwidgetDimmer` – brightness control (`brightness`, `set_brightness`)
71
+ - `SwidgetOutlet` – outlet control (`turn_on`, `turn_off`, power readings)
72
+ - `SwidgetSwitch` – generic switch control (`turn_on`, `turn_off`)
73
+ - `SwidgetTimerSwitch` – timer-capable switch (`set_countdown_timer`)
74
+
75
+ Common properties:
76
+ - `device_type`, `insert_type`, `friendly_name`, `hw_info`
77
+ - `realtime_values`: convenience dict of sensor/power values
78
+
79
+ Common actions:
80
+ - `turn_on()`, `turn_off()`, `blink()`, `ping()`
81
+ - `update()` to refresh summary/state/config
82
+ - `send_command(assembly, component, function, command_dict)` for raw control
83
+
84
+ HTTP vs websocket modes
85
+ -----------------------
86
+ - HTTP-only: set `use_websockets=False`. All operations go over REST (`/api/v1/...`).
87
+ - Websocket mode: set `use_websockets=True`. Summary/state updates and commands use the socket when connected; HTTP is used as fallback where applicable.
88
+ - TLS: `use_https=True` disables certificate verification by default; pass `verify_ssl=True` when constructing `SwidgetDevice` if you have valid certs.
89
+
90
+ Discovery
91
+ ---------
92
+ Discover devices via SSDP:
93
+ ```python
94
+ from swidget import discover_devices
95
+ devices = await discover_devices(timeout=5)
96
+ for mac, dev in devices.items():
97
+ print(dev.host, dev.friendly_name, dev.host_type, dev.insert_type)
98
+ ```
99
+
100
+ Discover a single device when you know the IP:
101
+ ```python
102
+ from swidget import discover_single
103
+ dev = await discover_single(
104
+ host="192.168.1.50",
105
+ token_name="x-secret-key",
106
+ password="device_password",
107
+ use_https=True,
108
+ use_websockets=False,
109
+ )
110
+ await dev.update()
111
+ ```
112
+
113
+ Provisioning (AP mode)
114
+ ----------------------
115
+ Provision a device while connected to its AP (`Swidget-...` SSID):
116
+ ```python
117
+ from swidget import provision_wifi
118
+ provision_wifi(
119
+ device_name="My Swidget",
120
+ ssid="HomeWiFi",
121
+ network_password="wifi-pass",
122
+ secret_key="factory-secret",
123
+ )
124
+ ```
125
+ Notes:
126
+ - Provisioning disables TLS verification; run only on the device’s AP network.
127
+ - The helper waits for connection success and triggers setup completion.
128
+
129
+ CLI usage
130
+ ---------
131
+ Install the console entrypoint via Poetry/pip and run:
132
+ ```
133
+ swidget --host 192.168.1.50 --password device_password --type dimmer state
134
+ swidget --host 192.168.1.50 --password device_password on
135
+ swidget --host 192.168.1.50 --password device_password brightness 80
136
+ swidget discover
137
+ ```
138
+ Key options:
139
+ - `--http_only` (flag) to force HTTP-only (no websockets)
140
+ - `--type` to skip discovery when you know the device type
141
+ - `wifi join` to provision via AP (prompts for SSID/password)
142
+
143
+ Development
144
+ -----------
145
+ Run tests and lint (hook runs both):
146
+ ```
147
+ pytest
148
+ black --check .
149
+ ```
150
+
151
+ If you install via plain pip and want the dev toolchain (tests, typing, docs, formatting):
152
+ ```
153
+ pip install pytest pytest-asyncio aioresponses mypy black sphinx sphinx-rtd-theme sphinx-autobuild
154
+ ```
155
+
156
+ Enable git hook (optional):
157
+ ```
158
+ git config core.hooksPath githooks
159
+ ```
160
+
161
+ Code structure:
162
+ - `swidget/` core library: device models, discovery, provisioning, websocket client, CLI
163
+ - `tests/` unit tests (network mocked)
164
+ - `devtools/synthetic_test.py` optional integration script for real devices (kept separate from unit tests)
165
+ - `examples/` runnable samples (dimmer HTTP, outlet power, timer switch, snapshot)
166
+
167
+ Additional examples
168
+ -------------------
169
+
170
+ Outlet (power reading + toggle)
171
+ ```
172
+ from swidget import SwidgetOutlet
173
+
174
+ dev = SwidgetOutlet(
175
+ host="192.168.1.60",
176
+ token_name="x-secret-key",
177
+ secret_key="password",
178
+ use_https=True,
179
+ use_websockets=False,
180
+ )
181
+ await dev.update()
182
+ print("Host features:", dev.host_features)
183
+ print("Realtime values:", dev.realtime_values)
184
+ await dev.turn_on()
185
+ await dev.turn_off()
186
+ await dev.close()
187
+ ```
188
+
189
+ Timer switch
190
+ ```
191
+ from swidget import SwidgetTimerSwitch
192
+
193
+ dev = SwidgetTimerSwitch(
194
+ host="192.168.1.61",
195
+ token_name="x-secret-key",
196
+ secret_key="password",
197
+ use_https=True,
198
+ use_websockets=False,
199
+ )
200
+ await dev.set_countdown_timer(20) # minutes
201
+ await dev.close()
202
+ ```
203
+
204
+ Snapshots (video-capable insert)
205
+ ```
206
+ from swidget import SwidgetDevice
207
+
208
+ dev = SwidgetDevice(
209
+ host="192.168.1.62",
210
+ token_name="x-secret-key",
211
+ secret_key="password",
212
+ use_https=True,
213
+ use_websockets=False,
214
+ )
215
+ await dev.update()
216
+ img = await dev.get_snapshot_bytes(width=640, height=360)
217
+ with open("snapshot.jpg", "wb") as f:
218
+ f.write(img)
219
+ await dev.close()
220
+ ```
221
+
222
+ Type checking
223
+ -------------
224
+ Run mypy in strict mode:
225
+ ```
226
+ poetry run mypy --strict swidget
227
+ ```
228
+
229
+ Docs build
230
+ ----------
231
+ Generate HTML docs with Sphinx:
232
+ ```
233
+ cd docs
234
+ poetry run sphinx-build -b html . _build
235
+ ```
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-swidget"
3
- version = "1.3.3"
3
+ version = "1.4.0"
4
4
  description = "Python API for Swidget smart devices"
5
5
  license = "GPL-3.0-or-later"
6
6
  authors = ["Swidget"]
@@ -38,6 +38,7 @@ pytest = ">=6.2.5"
38
38
  pytest-cov = "^2"
39
39
  pytest-asyncio = "^0"
40
40
  pytest-sugar = "*"
41
+ aioresponses = "^0.7.6"
41
42
  pre-commit = "*"
42
43
  voluptuous = "*"
43
44
  toml = "*"
@@ -46,6 +47,11 @@ pytest-mock = "^3"
46
47
  codecov = "^2"
47
48
  xdoctest = "^0"
48
49
  coverage = {version = "^6", extras = ["toml"]}
50
+ sphinx = "^4"
51
+ sphinx-autobuild = "^2021.3.14"
52
+ sphinx-rtd-theme = "^1.2"
53
+ mypy = "^1.11"
54
+ black = "^24.4"
49
55
 
50
56
  [tool.poetry.extras]
51
57
  docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"]
@@ -12,7 +12,7 @@ Module-specific errors are raised as `SwidgetException` and are expected
12
12
  to be handled by the user of the library.
13
13
  """
14
14
 
15
- from importlib_metadata import version # type: ignore
15
+ from importlib_metadata import version
16
16
 
17
17
  from swidget.discovery import SwidgetDiscoveredDevice, discover_devices, discover_single
18
18
  from swidget.exceptions import SwidgetException
@@ -236,10 +236,10 @@ async def ping(dev):
236
236
  click.echo("Pinging the device")
237
237
  try:
238
238
  result = await dev.ping()
239
- if result == 200:
239
+ if result:
240
240
  click.echo("Successfully pinged device")
241
241
  else:
242
- click.echo(result.status_code)
242
+ click.echo("Unable to ping device")
243
243
  except Exception:
244
244
  click.echo("Unable to ping device")
245
245
 
@@ -5,7 +5,7 @@ import socket
5
5
  from typing import Any, Type
6
6
  from urllib.parse import urlparse
7
7
 
8
- import ssdp # type: ignore
8
+ import ssdp
9
9
 
10
10
  from swidget.swidgetdevice import DeviceType, SwidgetDevice
11
11
 
@@ -0,0 +1,13 @@
1
+ """Exception types for the python-swidget library."""
2
+
3
+
4
+ class SwidgetException(Exception):
5
+ """Base exception for Swidget-related errors."""
6
+
7
+
8
+ class SwidgetAuthenticationException(SwidgetException):
9
+ """Raised when device authentication fails (HTTP 403)."""
10
+
11
+
12
+ class SwidgetConnectionException(SwidgetException):
13
+ """Raised when a connection to the device cannot be established."""
@@ -9,8 +9,13 @@ from types import TracebackType
9
9
  from typing import Any, Dict, List, Optional
10
10
 
11
11
  from aiohttp import ClientSession, TCPConnector
12
+ from aiohttp.client_exceptions import ClientConnectorError
12
13
 
13
- from .exceptions import SwidgetException
14
+ from .exceptions import (
15
+ SwidgetAuthenticationException,
16
+ SwidgetConnectionException,
17
+ SwidgetException,
18
+ )
14
19
  from .websocket import SwidgetWebsocket
15
20
 
16
21
  _LOGGER = logging.getLogger(__name__)
@@ -63,10 +68,16 @@ class SelfDiagnosticErrorCodes(Enum):
63
68
 
64
69
 
65
70
  class SwidgetDevice:
66
- """Class to represent the Swidge device."""
71
+ """Core representation of a Swidget device (base class for all device types)."""
67
72
 
68
73
  def __init__(
69
- self, host, token_name, secret_key, use_https=True, use_websockets=True
74
+ self,
75
+ host,
76
+ token_name,
77
+ secret_key,
78
+ use_https=True,
79
+ use_websockets=True,
80
+ verify_ssl: bool = False,
70
81
  ) -> None:
71
82
  self.token_name = token_name
72
83
  self.ip_address = host
@@ -74,13 +85,16 @@ class SwidgetDevice:
74
85
  self.uri_scheme = "https" if self.use_https is True else "http"
75
86
  self.secret_key = secret_key
76
87
  self.use_websockets = use_websockets
88
+ self.verify_ssl = verify_ssl
77
89
  self.device_type = DeviceType.Unknown
78
90
  self._friendly_name = "Unknown Swidget Device"
79
91
  self.assemblies: Dict[Any, Any] = dict()
80
92
  self.device_config: DeviceConfiguration = DeviceConfiguration({})
81
93
  self._subscribers: List[Any] = list()
82
94
  headers = {self.token_name: self.secret_key, "Connection": "keep-alive"}
83
- connector = TCPConnector(verify_ssl=False, force_close=True)
95
+ # aiohttp recommends using ssl context; verify_ssl is deprecated.
96
+ ssl_flag = verify_ssl if use_https else False
97
+ connector = TCPConnector(ssl=ssl_flag, force_close=True)
84
98
  if use_https is True:
85
99
  self._session = ClientSession(headers=headers, connector=connector)
86
100
  else:
@@ -93,12 +107,14 @@ class SwidgetDevice:
93
107
  secret_key=self.secret_key,
94
108
  callback=self.message_callback,
95
109
  session=self._session,
110
+ use_security=self.use_https,
111
+ verify_ssl=verify_ssl,
96
112
  )
97
113
 
98
114
  @property
99
115
  def connected(self) -> bool:
100
116
  """Property to represent if the client is connected to the device."""
101
- return self._websocket.connected
117
+ return hasattr(self, "_websocket") and self._websocket.connected
102
118
 
103
119
  def get_websocket(self) -> Optional[SwidgetWebsocket]:
104
120
  """Return the SwidgetWebsocket class instance if possible."""
@@ -189,15 +205,112 @@ class SwidgetDevice:
189
205
  for callback in self._subscribers:
190
206
  await callback(message)
191
207
 
208
+ async def make_http_request(
209
+ self,
210
+ method: str,
211
+ endpoint: str,
212
+ params: Optional[Dict[str, Any]] = None,
213
+ json_payload: Optional[Dict[str, Any]] = None,
214
+ ) -> Dict[str, Any]:
215
+ """
216
+ Make a generic HTTP request to a specified device endpoint.
217
+
218
+ Args:
219
+ method: The HTTP method to use (e.g., "GET", "POST").
220
+ endpoint: The API endpoint to request (e.g., "summary", "state").
221
+ params: Optional dictionary of URL query parameters.
222
+ json_payload: Optional dictionary to send as a JSON request body.
223
+
224
+ Returns:
225
+ The JSON response from the device as a dictionary.
226
+
227
+ Raises:
228
+ SwidgetConnectionException: If there is a problem connecting.
229
+ SwidgetAuthenticationException: If the device returns a 403 error.
230
+ ValueError: If an unsupported HTTP method is provided.
231
+ """
232
+ http_method = method.upper()
233
+ if http_method not in ("GET", "POST"):
234
+ raise ValueError(f"Unsupported HTTP method: {http_method}")
235
+
236
+ url = f"{self.uri_scheme}://{self.ip_address}/api/v1/{endpoint}"
237
+ _LOGGER.debug(f"Sending {http_method} request to: {url}")
238
+
239
+ try:
240
+ async with self._session.request(
241
+ method=http_method,
242
+ url=url,
243
+ params=params,
244
+ json=json_payload,
245
+ ssl=self.verify_ssl if self.use_https else False,
246
+ ) as response:
247
+ if response.status == 200:
248
+ if response.content_length == 0:
249
+ return {}
250
+ return await response.json()
251
+ elif response.status == 403:
252
+ _LOGGER.error(
253
+ f"Authentication failed for {http_method} '{endpoint}'"
254
+ )
255
+ raise SwidgetAuthenticationException
256
+ else:
257
+ response.raise_for_status()
258
+ return {}
259
+ except ClientConnectorError as e:
260
+ _LOGGER.error(f"Connection error while requesting '{endpoint}': {e}")
261
+ raise SwidgetConnectionException from e
262
+
263
+ async def _make_passthrough_request(
264
+ self,
265
+ method: str,
266
+ path: str,
267
+ params: Optional[Dict[str, Any]] = None,
268
+ ) -> Any:
269
+ """Send a request to endpoints that are not part of the /api/v1/ namespace."""
270
+ http_method = method.upper()
271
+ url = f"{self.uri_scheme}://{self.ip_address}/{path.lstrip('/')}"
272
+ request_params = dict(params or {})
273
+ if (
274
+ self.secret_key
275
+ and self.token_name
276
+ and self.token_name not in request_params
277
+ ):
278
+ request_params[self.token_name] = self.secret_key
279
+
280
+ _LOGGER.debug(
281
+ f"Sending {http_method} request to: {url} with params {request_params}"
282
+ )
283
+
284
+ try:
285
+ async with self._session.request(
286
+ method=http_method,
287
+ url=url,
288
+ params=request_params,
289
+ ssl=self.verify_ssl if self.use_https else False,
290
+ ) as response:
291
+ if response.status == 200:
292
+ if response.content_length == 0:
293
+ return {}
294
+ content_type = response.headers.get("Content-Type", "").lower()
295
+ if "application/json" in content_type:
296
+ return await response.json()
297
+ # Fallback: return text (e.g., ping returns plain "PONG")
298
+ text_body = await response.text()
299
+ try:
300
+ return json.loads(text_body)
301
+ except Exception:
302
+ return text_body
303
+ response.raise_for_status()
304
+ return {}
305
+ except ClientConnectorError as e:
306
+ _LOGGER.error(f"Connection error while requesting '{path}': {e}")
307
+ raise SwidgetConnectionException from e
308
+
192
309
  async def get_device_config(self) -> Any:
193
310
  """Get the config of the device."""
194
311
  _LOGGER.debug("SwidgetDevice.get_device_config() called")
195
312
  _LOGGER.debug("Sending get_summary() command over http")
196
- async with self._session.get(
197
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/device_config",
198
- ssl=False,
199
- ) as response:
200
- config = await response.json()
313
+ config = await self.make_http_request("GET", "device_config")
201
314
  self.device_config = DeviceConfiguration(config)
202
315
  self._last_update = int(time.time())
203
316
 
@@ -213,10 +326,7 @@ class SwidgetDevice:
213
326
  )
214
327
  else:
215
328
  _LOGGER.debug("In http mode. Sending get_summary() command over http")
216
- async with self._session.get(
217
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/summary", ssl=False
218
- ) as response:
219
- summary = await response.json()
329
+ summary = await self.make_http_request("GET", "summary")
220
330
  await self.process_summary(summary)
221
331
 
222
332
  async def process_summary(self, summary) -> None:
@@ -239,10 +349,7 @@ class SwidgetDevice:
239
349
  """Retrieve the friendly name of the device."""
240
350
  _LOGGER.debug("SwidgetDevice.get_friendly_name() called")
241
351
  try:
242
- async with self._session.get(
243
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/name", ssl=False
244
- ) as response:
245
- name = await response.json()
352
+ name = await self.make_http_request("GET", "name")
246
353
  except Exception:
247
354
  name = {"name": f"Swidget {self.device_type} w/{self.insert_type} insert"}
248
355
  await self.process_friendly_name(name["name"])
@@ -258,17 +365,14 @@ class SwidgetDevice:
258
365
  _LOGGER.debug("SwidgetDevice.get_state() called")
259
366
  if self.use_websockets:
260
367
  _LOGGER.debug(
261
- "In websocket mode. Sending get_summary() command over websocket"
368
+ "In websocket mode. Sending get_state() command over websocket"
262
369
  )
263
370
  await self._websocket.send_str(
264
371
  json.dumps({"type": "state", "request_id": "state"})
265
372
  )
266
373
  else:
267
- _LOGGER.debug("In http mode. Sending get_summary() command over http")
268
- async with self._session.get(
269
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/state", ssl=False
270
- ) as response:
271
- state = await response.json()
374
+ _LOGGER.debug("In http mode. Sending get_state() command over http")
375
+ state = await self.make_http_request("GET", "state")
272
376
  await self.process_state(state)
273
377
 
274
378
  async def process_state(self, state) -> None:
@@ -297,7 +401,7 @@ class SwidgetDevice:
297
401
  await self.get_state()
298
402
  if self._friendly_name == "Unknown Swidget Device":
299
403
  await self.get_friendly_name()
300
- if self.device_config == {}:
404
+ if not self.device_config.config_populated():
301
405
  await self.get_device_config()
302
406
  elif (int(time.time()) - self._last_update) < 5:
303
407
  _LOGGER.debug("update() recently called, not executing")
@@ -309,10 +413,18 @@ class SwidgetDevice:
309
413
  async def send_config(self, payload: dict) -> None:
310
414
  """Send a config block to the device."""
311
415
  _LOGGER.debug("SwidgetDevice.send_config() called")
312
- data = json.dumps(
313
- {"type": "config", "request_id": "send_config", "payload": payload}
314
- )
315
- await self._websocket.send_str(data)
416
+ if self.use_websockets:
417
+ _LOGGER.debug(
418
+ "In websocket mode. Sending send_config() command over websocket"
419
+ )
420
+ data = json.dumps(
421
+ {"type": "config", "request_id": "send_config", "payload": payload}
422
+ )
423
+ await self._websocket.send_str(data)
424
+ else:
425
+ raise SwidgetException(
426
+ "Configuration management is not available via websocket."
427
+ )
316
428
 
317
429
  async def send_command(
318
430
  self, assembly: str, component: str, function: str, command: dict
@@ -329,16 +441,19 @@ class SwidgetDevice:
329
441
  await self._websocket.send_str(command_data)
330
442
  else:
331
443
  _LOGGER.debug("NOT in websocket mode, sending command over HTTP")
332
- async with self._session.post(
333
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/command",
334
- ssl=False,
335
- data=json.dumps(data),
336
- ) as response:
337
- state = await response.json()
338
-
339
- # Do a hard set of the new state of the device. May change this in the future
340
- function_value = state[assembly]["components"][component][function]
341
- self.assemblies[assembly].components[component].functions[function] = function_value # fmt: skip
444
+ response = await self.make_http_request(
445
+ "POST", "command", json_payload=data
446
+ )
447
+ if response:
448
+ try:
449
+ function_value = response[assembly]["components"][component][
450
+ function
451
+ ]
452
+ self.assemblies[assembly].components[component].functions[
453
+ function
454
+ ] = function_value
455
+ except Exception:
456
+ _LOGGER.debug("Command response did not include state update")
342
457
 
343
458
  async def ping(self) -> bool:
344
459
  """Ping the device to ensure it's devices.
@@ -347,12 +462,9 @@ class SwidgetDevice:
347
462
  """
348
463
  _LOGGER.debug("SwidgetDevice.ping() called")
349
464
  try:
350
- async with self._session.get(
351
- url=f"{self.uri_scheme}://{self.ip_address}/ping", ssl=False
352
- ) as response:
353
- if response.status == 200:
354
- return True
355
- return False
465
+ response = await self._make_passthrough_request("GET", "ping")
466
+ _LOGGER.debug(f"Ping response: {response}")
467
+ return True if response == {} else bool(response)
356
468
  except Exception:
357
469
  return False
358
470
 
@@ -362,13 +474,7 @@ class SwidgetDevice:
362
474
  :raises SwidgetException: Raise the exception if there we are unable to connect to the Swidget device
363
475
  """
364
476
  _LOGGER.debug("SwidgetDevice.blink() called")
365
- try:
366
- async with self._session.get(
367
- url=f"{self.uri_scheme}://{self.ip_address}/blink", ssl=False
368
- ) as response:
369
- return await response.json()
370
- except Exception:
371
- raise SwidgetException
477
+ return await self._make_passthrough_request("GET", "blink")
372
478
 
373
479
  async def enable_debug_server(self) -> Any:
374
480
  """Enable the Swidget local debug server.
@@ -376,30 +482,14 @@ class SwidgetDevice:
376
482
  :raises SwidgetException: Raise the exception if there we are unable to connect to the Swidget device
377
483
  """
378
484
  _LOGGER.debug("SwidgetDevice.enable_debug_server() called")
379
- try:
380
- async with self._session.get(
381
- url=f"{self.uri_scheme}://{self.ip_address}/debug?x-secret-key={self.secret_key}",
382
- ssl=False,
383
- ) as response:
384
- if response.status == 200:
385
- return True
386
- return False
387
- except Exception:
388
- raise SwidgetException
485
+ return await self.make_http_request("GET", "debug")
389
486
 
390
487
  async def restart_device(self) -> Any:
391
488
  """Restart the Swidget device.
392
489
 
393
490
  :raises SwidgetException: Raise the exception if there we are unable to connect to the Swidget device
394
491
  """
395
- try:
396
-
397
- async with self._session.post(
398
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/reset", ssl=False
399
- ) as response:
400
- return await response.json()
401
- except Exception:
402
- raise SwidgetException
492
+ return await self.make_http_request("POST", "reset")
403
493
 
404
494
  async def factory_reset(self) -> Any:
405
495
  """Factory reset the Swidget device.
@@ -421,12 +511,8 @@ class SwidgetDevice:
421
511
  :raises SwidgetException: Raise the exception if there we are unable to connect to the Swidget device
422
512
  """
423
513
  try:
424
-
425
- async with self._session.get(
426
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/update", ssl=False
427
- ) as response:
428
- newer_versions = await response.json()
429
- return sorted(newer_versions["updates"])
514
+ newer_versions = await self.make_http_request("GET", "update")
515
+ return sorted(newer_versions["updates"])
430
516
  except Exception:
431
517
  raise SwidgetException
432
518
 
@@ -437,15 +523,10 @@ class SwidgetDevice:
437
523
  """
438
524
  try:
439
525
  data = {"version": version}
440
- async with self._session.post(
441
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/update/version",
442
- ssl=False,
443
- data=json.dumps(data),
444
- ) as response:
445
- result = response.status
446
- if result == 200:
447
- return True
448
- return False
526
+ response = await self.make_http_request(
527
+ "POST", "update/version", json_payload=data
528
+ )
529
+ return bool(response)
449
530
  except Exception:
450
531
  raise SwidgetException
451
532
 
@@ -758,7 +839,7 @@ class DeviceConfiguration:
758
839
 
759
840
  def config_populated(self) -> bool:
760
841
  """Return if configuration has been retrieved from the device."""
761
- return self._config_dict == {}
842
+ return self._config_dict != {}
762
843
 
763
844
  @property
764
845
  def config(self):
@@ -27,7 +27,7 @@ class SwidgetDimmer(SwidgetDevice):
27
27
  )
28
28
  self.device_type = DeviceType.Dimmer
29
29
 
30
- @property # type: ignore
30
+ @property
31
31
  def brightness(self) -> int:
32
32
  """Return current brightness on dimmers.
33
33
 
@@ -66,7 +66,7 @@ class SwidgetDimmer(SwidgetDevice):
66
66
  command={"default": brightness},
67
67
  )
68
68
 
69
- @property # type: ignore
69
+ @property
70
70
  def is_dimmable(self) -> bool:
71
71
  """Whether the switch supports brightness changes."""
72
72
  _LOGGER.debug("SwidgetDimmer.is_dimmable() called")
@@ -29,8 +29,8 @@ class SwidgetTimerSwitch(SwidgetSwitch):
29
29
  self._device_type = DeviceType.TimerSwitch
30
30
 
31
31
  async def set_countdown_timer(self, minutes) -> Any:
32
- """Set the countdown timer."""
33
- _LOGGER.debug("SwidgetTimerSwitch.set_brightness() called")
32
+ """Set the countdown timer in minutes."""
33
+ _LOGGER.debug("SwidgetTimerSwitch.set_countdown_timer() called")
34
34
  await self.send_command(
35
35
  assembly="host",
36
36
  component="0",
@@ -26,6 +26,7 @@ class SwidgetWebsocket:
26
26
  callback: Union[Callable[[Any], None], Callable[[Any], Awaitable[None]]],
27
27
  session: aiohttp.ClientSession | None = None,
28
28
  use_security: bool = True,
29
+ verify_ssl: bool = False,
29
30
  retry_interval: int = 30, # Initial retry interval in seconds
30
31
  max_retries: int | None = None, # Maximum number of reconnection attempts
31
32
  ):
@@ -38,6 +39,7 @@ class SwidgetWebsocket:
38
39
  callback: A callable that will be called with received messages.
39
40
  session: An optional aiohttp.ClientSession to use.
40
41
  use_security: Whether to use wss:// (True) or ws:// (False).
42
+ verify_ssl: Whether to verify TLS when using wss://.
41
43
  retry_interval: Initial interval for reconnection attempts.
42
44
  max_retries: Maximum number of reconnection attempts.
43
45
  """
@@ -46,18 +48,18 @@ class SwidgetWebsocket:
46
48
  self.secret_key = secret_key or ""
47
49
  self.session = session or aiohttp.ClientSession()
48
50
  self.use_security = use_security
51
+ self._verify_ssl = verify_ssl
49
52
  self.callback = callback
50
53
  self.retry_interval = retry_interval
51
54
  self.max_retries = max_retries
52
55
  self.retry_count = 0
53
- self._verify_ssl = False
54
56
  self.uri = self._get_uri()
55
57
 
56
58
  # self._client= None
57
59
  self.is_running = True
58
60
  self._closing = False
59
61
  self._client: ClientWebSocketResponse | None = None
60
- self._receiver_task: asyncio.Task | None = None
62
+ self._receiver_task: asyncio.Task[Any] | None = None
61
63
  self._closing = False
62
64
 
63
65
  def _get_uri(self) -> str:
@@ -188,7 +190,7 @@ class SwidgetWebsocket:
188
190
  else:
189
191
  self.callback(message)
190
192
 
191
- def status(self) -> dict:
193
+ def status(self) -> dict[str, Any]:
192
194
  """Return the current status of the websocket connection."""
193
195
  _LOGGER.debug("websocket.status() called")
194
196
  return {
@@ -1,55 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: python-swidget
3
- Version: 1.3.3
4
- Summary: Python API for Swidget smart devices
5
- License: GPL-3.0-or-later
6
- Author: Swidget
7
- Requires-Python: >=3.8,<4.0
8
- Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.8
11
- Classifier: Programming Language :: Python :: 3.9
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Provides-Extra: docs
17
- Requires-Dist: aiohttp (>=3.8.1)
18
- Requires-Dist: anyio
19
- Requires-Dist: asyncclick (>=8)
20
- Requires-Dist: importlib-metadata
21
- Requires-Dist: m2r (>=0,<1) ; extra == "docs"
22
- Requires-Dist: mistune (<2.0.0) ; extra == "docs"
23
- Requires-Dist: pydantic (>=2,<3)
24
- Requires-Dist: requests (==2.32.5)
25
- Requires-Dist: sphinx (>=4,<5) ; extra == "docs"
26
- Requires-Dist: sphinx_rtd_theme (>=0,<1) ; extra == "docs"
27
- Requires-Dist: sphinxcontrib-programoutput (>=0,<1) ; extra == "docs"
28
- Requires-Dist: ssdp (==1.1.1)
29
- Requires-Dist: types-requests (>=2.32.0,<3.0.0)
30
- Requires-Dist: urllib3 (==2.5.0)
31
- Project-URL: Repository, https://github.com/swidget/python-swidget
32
- Description-Content-Type: text/markdown
33
-
34
- # python-swidget
35
- A library to manage Swidget smart devices
36
-
37
- # Basic Usage
38
-
39
- ## Connect to the device using http/ https
40
- ```
41
- dev = SwidgetDimmer(host=host, token_name='x-secret-key', secret_key='password', use_https=True, use_websockets=False)
42
- dev.update()
43
- dev.turn_on()
44
- dev.close()
45
- ```
46
-
47
- ## Connect to the device using websockets
48
- ```
49
- dev = SwidgetDimmer(host=host, token_name='x-secret-key', secret_key='password', use_https=True, use_websockets=True)
50
- dev.start()
51
- dev.update()
52
- dev.turn_on()
53
- dev.close()
54
- ```
55
-
@@ -1,21 +0,0 @@
1
- # python-swidget
2
- A library to manage Swidget smart devices
3
-
4
- # Basic Usage
5
-
6
- ## Connect to the device using http/ https
7
- ```
8
- dev = SwidgetDimmer(host=host, token_name='x-secret-key', secret_key='password', use_https=True, use_websockets=False)
9
- dev.update()
10
- dev.turn_on()
11
- dev.close()
12
- ```
13
-
14
- ## Connect to the device using websockets
15
- ```
16
- dev = SwidgetDimmer(host=host, token_name='x-secret-key', secret_key='password', use_https=True, use_websockets=True)
17
- dev.start()
18
- dev.update()
19
- dev.turn_on()
20
- dev.close()
21
- ```
@@ -1,2 +0,0 @@
1
- class SwidgetException(Exception):
2
- """Base exception for device errors."""