python-swidget 1.2.5__tar.gz → 1.3.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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: python-swidget
3
- Version: 1.2.5
3
+ Version: 1.3.0
4
4
  Summary: Python API for Swidget smart devices
5
5
  Home-page: https://github.com/swidget/python-swidget
6
6
  License: GPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-swidget"
3
- version = "1.2.5"
3
+ version = "1.3.0"
4
4
  description = "Python API for Swidget smart devices"
5
5
  license = "GPL-3.0-or-later"
6
6
  authors = ["Swidget"]
@@ -19,6 +19,7 @@ from swidget.exceptions import SwidgetException
19
19
  from swidget.provision import provision_wifi
20
20
  from swidget.swidgetdevice import (
21
21
  DeviceType,
22
+ InsertType,
22
23
  SwidgetAssembly,
23
24
  SwidgetComponent,
24
25
  SwidgetDevice,
@@ -38,6 +39,7 @@ __all__ = [
38
39
  "SwidgetDiscoveredDevice",
39
40
  "SwidgetException",
40
41
  "DeviceType",
42
+ "InsertType",
41
43
  "SwidgetAssembly",
42
44
  "SwidgetDevice",
43
45
  "SwidgetComponent",
@@ -108,9 +108,8 @@ async def discover_single(
108
108
 
109
109
  _LOGGER.debug(f"Creating new device class of type: {device_type}")
110
110
  device_class = _get_device_class(device_type)
111
- _LOGGER.debug(f"{device_class}")
111
+ _LOGGER.debug(f"{device_class} created")
112
112
  dev = device_class(host, token_name, password, use_https, use_websockets)
113
- await dev.start()
114
113
  return dev
115
114
 
116
115
 
@@ -115,10 +115,10 @@ class SwidgetDevice:
115
115
  async def start(self) -> None:
116
116
  """Start the websocket."""
117
117
  _LOGGER.debug("SwidgetDevice.start()")
118
- if self.use_websockets:
118
+ if self.use_websockets and not self.connected:
119
119
  _LOGGER.debug("Calling self._websocket.connect()")
120
120
  await self._websocket.connect()
121
- _LOGGER.debug("Calling self.update() ")
121
+ _LOGGER.debug("Calling self.update()")
122
122
  await self.update()
123
123
 
124
124
  async def stop(self) -> bool:
@@ -147,7 +147,7 @@ class SwidgetDevice:
147
147
  """Register a function to be called when a new websocket message is recieved."""
148
148
  for c in self._subscribers:
149
149
  if c == callback:
150
- _LOGGER.warn(
150
+ _LOGGER.warning(
151
151
  "Callback has already been added, not adding the same callback function again"
152
152
  )
153
153
  return False
@@ -190,20 +190,14 @@ class SwidgetDevice:
190
190
  async def get_device_config(self) -> Any:
191
191
  """Get the config of the device."""
192
192
  _LOGGER.debug("SwidgetDevice.get_device_config() called")
193
- if self.use_websockets:
194
- _LOGGER.debug(
195
- "In websocket mode. Sending get_summary() command over websocket"
196
- )
197
- raise NotImplementedError
198
- else:
199
- _LOGGER.debug("In http mode. Sending get_summary() command over http")
200
- async with self._session.get(
201
- url=f"{self.uri_scheme}://{self.ip_address}/api/v1/device_config",
202
- ssl=False,
203
- ) as response:
204
- config = await response.json()
205
- self.device_config = DeviceConfiguration(config)
206
- self._last_update = int(time.time())
193
+ _LOGGER.debug("Sending get_summary() command over http")
194
+ async with self._session.get(
195
+ url=f"{self.uri_scheme}://{self.ip_address}/api/v1/device_config",
196
+ ssl=False,
197
+ ) as response:
198
+ config = await response.json()
199
+ self.device_config = DeviceConfiguration(config)
200
+ self._last_update = int(time.time())
207
201
 
208
202
  async def get_summary(self) -> None:
209
203
  """Get a summary of the device over HTTP."""
@@ -325,7 +319,7 @@ class SwidgetDevice:
325
319
  _LOGGER.debug("SwidgetDevice.send_command() called")
326
320
  data = {assembly: {"components": {component: {function: command}}}}
327
321
  _LOGGER.debug(f"Command to send: {data}")
328
- if self.use_websockets:
322
+ if self.use_websockets and self.connected is True:
329
323
  _LOGGER.debug("In websocket mode. Sending command over websocket")
330
324
  command_data = json.dumps(
331
325
  {"type": "command", "request_id": "command", "payload": data}
@@ -653,12 +647,12 @@ class SwidgetDevice:
653
647
  """Return True if the device is dimmable."""
654
648
  return self.is_dimmer
655
649
 
656
- @property # type: ignore
650
+ @property
657
651
  def friendly_name(self) -> str:
658
652
  """Return a friendly description of the device."""
659
653
  return self._friendly_name
660
654
 
661
- @property # type: ignore
655
+ @property
662
656
  def is_on(self) -> bool:
663
657
  """Return whether device is on."""
664
658
  _LOGGER.debug("SwidgetDevice.is_on called")
@@ -703,7 +697,7 @@ class SwidgetDevice:
703
697
  command={"state": "off"},
704
698
  )
705
699
 
706
- @property # type: ignore
700
+ @property
707
701
  def usb_is_on(self) -> bool:
708
702
  """Return whether USB is on."""
709
703
  _LOGGER.debug("SwidgetDevice.usb_is_on called")
@@ -25,7 +25,7 @@ class SwidgetDimmer(SwidgetDevice):
25
25
  use_https=use_https,
26
26
  use_websockets=use_websockets,
27
27
  )
28
- self._device_type = DeviceType.Dimmer
28
+ self.device_type = DeviceType.Dimmer
29
29
 
30
30
  @property # type: ignore
31
31
  def brightness(self) -> int:
@@ -0,0 +1,207 @@
1
+ """Module to handle websocket connections to Swidget devices."""
2
+ import asyncio
3
+ import logging
4
+ import socket
5
+ from typing import Any, Awaitable, Callable, Union
6
+
7
+ import aiohttp
8
+ from aiohttp import (
9
+ ClientConnectionError,
10
+ ClientWebSocketResponse,
11
+ WSMsgType,
12
+ WSServerHandshakeError,
13
+ )
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ class SwidgetWebsocket:
19
+ """A websocket connection to a Swidget Device."""
20
+
21
+ def __init__(
22
+ self,
23
+ host: str,
24
+ token_name: str,
25
+ secret_key: str,
26
+ callback: Union[Callable[[Any], None], Callable[[Any], Awaitable[None]]],
27
+ session: aiohttp.ClientSession | None = None,
28
+ use_security: bool = True,
29
+ retry_interval: int = 30, # Initial retry interval in seconds
30
+ max_retries: int = 200, # Maximum number of reconnection attempts
31
+ ):
32
+ """Initialize the SwidgetWebsocket.
33
+
34
+ Args:
35
+ host: The hostname or IP address of the Swidget device.
36
+ token_name: The name of the authentication token.
37
+ secret_key: The secret key for authentication.
38
+ callback: A callable that will be called with received messages.
39
+ session: An optional aiohttp.ClientSession to use.
40
+ use_security: Whether to use wss:// (True) or ws:// (False).
41
+ retry_interval: Initial interval for reconnection attempts.
42
+ max_retries: Maximum number of reconnection attempts.
43
+ """
44
+ self.host = host
45
+ self.token_name = token_name or "x-secret-key"
46
+ self.secret_key = secret_key or ""
47
+ self.session = session or aiohttp.ClientSession()
48
+ self.use_security = use_security
49
+ self.callback = callback
50
+ self.retry_interval = retry_interval
51
+ self.max_retries = max_retries
52
+ self.retry_count = 0
53
+ self._verify_ssl = False
54
+ self.uri = self._get_uri()
55
+
56
+ # self._client= None
57
+ self.is_running = True
58
+ self._closing = False
59
+ self._client: ClientWebSocketResponse | None = None
60
+ self._receiver_task: asyncio.Task | None = None
61
+ self._closing = False
62
+
63
+ def _get_uri(self) -> str:
64
+ """Generate the websocket URI."""
65
+ protocol = "wss" if self.use_security else "ws"
66
+ return (
67
+ f"{protocol}://{self.host}/api/v1/sock?{self.token_name}={self.secret_key}"
68
+ )
69
+
70
+ async def connect(self) -> None:
71
+ """Connect to the websocket server."""
72
+ _LOGGER.debug("websocket.connect() called")
73
+
74
+ if self.connected:
75
+ _LOGGER.debug("Websocket already connected")
76
+ return
77
+
78
+ if self._client is not None:
79
+ raise ConnectionError("Already connected")
80
+
81
+ if not self.session:
82
+ raise ConnectionError("No aiohttp session available")
83
+
84
+ headers = {"Connection": "Upgrade", self.token_name: self.secret_key}
85
+
86
+ try:
87
+ _LOGGER.debug(f"Trying to connect to {self.host}")
88
+ self._client = await self.session.ws_connect(
89
+ url=self.uri,
90
+ headers=headers,
91
+ verify_ssl=self._verify_ssl,
92
+ heartbeat=30,
93
+ )
94
+ self.retry_count = 0
95
+ _LOGGER.debug("Websocket now connected")
96
+ except (ClientConnectionError, WSServerHandshakeError) as e:
97
+ _LOGGER.error(f"Error connecting to websocket: {e}")
98
+ self._client = None
99
+ except socket.gaierror as e:
100
+ _LOGGER.error(f"Error resolving host: {e}")
101
+ self._client = None
102
+ except Exception as e:
103
+ _LOGGER.error(f"An unexpected error occurred: {e}")
104
+ self._client = None
105
+
106
+ async def send_str(self, message: str) -> None:
107
+ """Send a string message through the websocket with retry attempts."""
108
+ _LOGGER.debug("websocket.send_str() called")
109
+ max_send_retries = 3
110
+ for attempt in range(max_send_retries):
111
+ try:
112
+ if self._client is not None:
113
+ await self._client.send_str(message)
114
+ return
115
+ else:
116
+ _LOGGER.warning("Websocket is not connected, not sending")
117
+ return
118
+ except Exception as e:
119
+ _LOGGER.warning(
120
+ f"Error sending message, attempt {attempt}/{max_send_retries}: {e}"
121
+ )
122
+ if attempt < max_send_retries - 1:
123
+ await asyncio.sleep(5**attempt)
124
+ else:
125
+ _LOGGER.error(
126
+ f"Failed to send message after {max_send_retries} attempts."
127
+ )
128
+
129
+ async def receive(self):
130
+ """Receive a message from the WebSocket server."""
131
+ _LOGGER.debug("websocket.receive() called")
132
+ try:
133
+ if self._client is not None:
134
+ message = await self._client.receive()
135
+ if message.type == WSMsgType.TEXT:
136
+ message_data = message.json()
137
+ _LOGGER.debug(f"[{self.host}] Received message: {message_data}")
138
+ return message_data
139
+ elif message.type == WSMsgType.CLOSED:
140
+ _LOGGER.error("Websocket client is closed")
141
+ self._client = None
142
+ elif message.type == WSMsgType.ERROR:
143
+ _LOGGER.error("WebSocket error.")
144
+ self._client = None
145
+ except Exception as e:
146
+ _LOGGER.error(f"Error receiving message: {e}")
147
+
148
+ async def close(self):
149
+ """Close the WebSocket connection."""
150
+ _LOGGER.debug("websocket.close() called")
151
+ self.is_running = False
152
+ self._closing = True
153
+ if self._client is not None:
154
+ await self._client.close()
155
+ self._client = None
156
+ if self.session and not self.session.closed:
157
+ await self.session.close()
158
+
159
+ async def reconnect(self):
160
+ """Reconnect to the WebSocket server after a delay."""
161
+ _LOGGER.debug("websocket.reconnect() called")
162
+ if self.max_retries is not None and self.retry_count >= self.max_retries:
163
+ _LOGGER.warning("Max retries reached. Stopping reconnect attempts.")
164
+ self.is_running = False
165
+ return
166
+
167
+ self.retry_count += 1
168
+ delay = self.retry_interval * (2 ** (self.retry_count - 1))
169
+ _LOGGER.warning(
170
+ f"Reconnecting in {delay} seconds (attempt {self.retry_count})..."
171
+ )
172
+ await asyncio.sleep(delay)
173
+ await self.connect()
174
+
175
+ async def run(self):
176
+ """Run the WebSocket client to handle messages and reconnections."""
177
+ while self.is_running:
178
+ if self._client is None:
179
+ await self.reconnect()
180
+ if self._client is not None:
181
+ message = await self.receive()
182
+ if message and self.callback:
183
+ if asyncio.iscoroutinefunction(self.callback):
184
+ await self.callback(message)
185
+ else:
186
+ self.callback(message)
187
+
188
+ def status(self) -> dict:
189
+ """Return the current status of the websocket connection."""
190
+ _LOGGER.debug("websocket.status() called")
191
+ return {
192
+ "host": self.host,
193
+ "connected": self._client is not None,
194
+ "closing": self._closing,
195
+ "retry_interval": self.retry_interval,
196
+ "max_retries": self.max_retries,
197
+ }
198
+
199
+ @property
200
+ def connected(self) -> bool:
201
+ """Return the status of the connection."""
202
+ return self._client is not None and not self._client.closed
203
+
204
+ def __repr__(self) -> str:
205
+ """Return the representation."""
206
+ prefix = "" if self.connected else "not "
207
+ return f"{type(self).__name__}(ws_server_url={self.host}, {prefix}connected)"
@@ -1,188 +0,0 @@
1
- """Module to handle websocket connections to Swidget devices."""
2
- import asyncio
3
- import logging
4
- import socket
5
- from typing import Any
6
-
7
- import aiohttp
8
- from aiohttp import ClientWebSocketResponse, WSMsgType
9
-
10
- _LOGGER = logging.getLogger(__name__)
11
-
12
-
13
- async def cancel_task(*tasks: asyncio.Task | None) -> None:
14
- """Cancel task(s)."""
15
- for task in tasks:
16
- if task is not None and not task.done():
17
- task.cancel()
18
- try:
19
- await task
20
- except asyncio.CancelledError:
21
- pass
22
-
23
-
24
- class SwidgetWebsocket:
25
- """A websocket connection to a Swidget Device."""
26
-
27
- # pylint: disable=too-many-instance-attributes
28
- _client: aiohttp.ClientWebSocketResponse | None = None
29
-
30
- def __init__(
31
- self,
32
- host,
33
- token_name,
34
- secret_key,
35
- callback,
36
- session=None,
37
- use_security=True,
38
- ):
39
- self.host = host
40
- self.session = session or aiohttp.ClientSession()
41
- self.use_security = use_security
42
- self.uri = self.get_uri(host, token_name, secret_key)
43
- self.callback = callback
44
- self._verify_ssl = False
45
- self._state = None
46
- self.failed_attempts = 0
47
- self._error_reason = None
48
- self.headers = {"Connection": "Upgrade"}
49
- self._receiver_task: asyncio.Task | None = None
50
-
51
- @property
52
- def connected(self) -> bool:
53
- """Return of status of whether the device is currently connected."""
54
- return self._client is not None and not self._client.closed
55
-
56
- @property
57
- def websocket(self) -> ClientWebSocketResponse | None:
58
- """Return the web socket."""
59
- return self._client
60
-
61
- def get_uri(self, host, token_name, secret_key):
62
- """Generate the websocket URI."""
63
- if self.use_security:
64
- return f"wss://{host}/api/v1/sock?{token_name}={secret_key}"
65
- else:
66
- return f"ws://{host}/api/v1/sock?{token_name}={secret_key}"
67
-
68
- async def connect(self) -> None:
69
- """Create a new connection and, optionally, start the monitor."""
70
- _LOGGER.debug("websocket.connect() called")
71
- await cancel_task(self._receiver_task)
72
- if self.connected:
73
- _LOGGER.debug("Websocket already connected")
74
- return
75
-
76
- if self._client is not None:
77
- raise ConnectionError("Already connected")
78
-
79
- if not self.session:
80
- raise ConnectionError("No aiohttp session available")
81
-
82
- try:
83
- _LOGGER.debug("Trying to connect")
84
- self._client = await self.session.ws_connect(
85
- url=self.uri,
86
- headers=self.headers,
87
- verify_ssl=self._verify_ssl,
88
- heartbeat=30,
89
- )
90
- _LOGGER.debug("Websocket now connected")
91
- except aiohttp.WSServerHandshakeError as handshake_error:
92
- _LOGGER.error(
93
- f"Error occurred during websocket handshake: {handshake_error}"
94
- )
95
- raise
96
- except aiohttp.ClientConnectionError as connection_error:
97
- _LOGGER.error(
98
- f"Error connecting to the websocket server: {connection_error}"
99
- )
100
- raise
101
- except socket.gaierror as gai_error:
102
- _LOGGER.error(f"Error resolving host: {gai_error}")
103
- raise
104
- except Exception as e:
105
- _LOGGER.error(f"An unexpected error occurred: {e}")
106
- raise
107
- self._receiver_task = asyncio.ensure_future(self.listen())
108
-
109
- async def close(self) -> None:
110
- """Close the websocket."""
111
- _LOGGER.debug("websocket.close() called")
112
- if self._client is not None and not self._client.closed:
113
- await self._client.close()
114
- self._client = None
115
-
116
- async def disconnect(self) -> None:
117
- """Wrapper of the close() function."""
118
- await self.close()
119
-
120
- async def send_str(self, message):
121
- """Send a message through the websocket."""
122
- _LOGGER.debug("websocket.send_str() called")
123
- if not self.connected:
124
- raise ConnectionError
125
- message = str(message)
126
- _LOGGER.debug(f"Sending messsage over websocket: {message}")
127
- await self._client.send_str(f"{message}")
128
-
129
- async def listen(self):
130
- """Ask the client to listen to websocket events."""
131
- _LOGGER.debug("websocket.listen() called")
132
- # Check if the websocket is connected
133
- if not self._client or not self.connected:
134
- _LOGGER.error("Websocket is not connected")
135
- raise ConnectionError("Websocket is not connected")
136
-
137
- while not self._client.closed:
138
- try:
139
- message = await self._client.receive()
140
- except StopAsyncIteration:
141
- break
142
-
143
- if message.type == aiohttp.WSMsgType.ERROR:
144
- raise ConnectionError("Websocket error occurred")
145
-
146
- if message.type == aiohttp.WSMsgType.TEXT:
147
- message_data = message.json()
148
- _LOGGER.debug(f"Received from websocket: {message_data}")
149
- await self.callback(message_data)
150
-
151
- if message.type in (
152
- aiohttp.WSMsgType.CLOSE,
153
- aiohttp.WSMsgType.CLOSED,
154
- aiohttp.WSMsgType.CLOSING,
155
- ):
156
- _LOGGER.error("Connection to the Swidget WebSocket has been closed")
157
- break
158
-
159
- self._client = None # Ensure client is set to None when closed
160
-
161
- async def receive_message_or_raise(self) -> Any:
162
- """Receive ONE (raw) message or raise."""
163
- assert self._client
164
- ws_msg = await self._client.receive()
165
-
166
- if ws_msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING):
167
- raise ConnectionError("Connection was closed.")
168
-
169
- if ws_msg.type == WSMsgType.ERROR:
170
- raise ConnectionError
171
-
172
- if ws_msg.type != WSMsgType.TEXT:
173
- raise ValueError(f"Received non-Text message: {ws_msg.type}: {ws_msg.data}")
174
-
175
- try:
176
- msg = ws_msg.json()
177
- except TypeError as err:
178
- raise TypeError(f"Received unsupported JSON: {err}") from err
179
- except ValueError as err:
180
- raise ValueError("Received invalid JSON.") from err
181
-
182
- _LOGGER.debug(f"Received message:\n{msg}\n")
183
- return msg
184
-
185
- def __repr__(self) -> str:
186
- """Return the representation."""
187
- prefix = "" if self.connected else "not "
188
- return f"{type(self).__name__}(ws_server_url={self.host}, {prefix}connected)"
File without changes