python-swidget 1.4.6__tar.gz → 1.4.9__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
1
  Metadata-Version: 2.1
2
2
  Name: python-swidget
3
- Version: 1.4.6
3
+ Version: 1.4.9
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.4.6"
3
+ version = "1.4.9"
4
4
  description = "Python API for Swidget smart devices"
5
5
  license = "GPL-3.0-or-later"
6
6
  authors = ["Swidget"]
@@ -361,10 +361,30 @@ class SwidgetDevice:
361
361
  self.model = summary["model"]
362
362
  self.mac_address = summary["mac"]
363
363
  self.version = summary["version"]
364
- self.assemblies = {
364
+ new_assemblies = {
365
365
  "host": SwidgetAssembly(summary["host"]),
366
366
  "insert": SwidgetAssembly(summary["insert"]),
367
367
  }
368
+ # Carry already-populated function state forward. Rebuilding
369
+ # assemblies wholesale resets every component's ``functions`` to
370
+ # ``None`` placeholders until the next ``state`` message lands —
371
+ # subscribers that read state in between (e.g. an HA coordinator
372
+ # firing on the summary callback) would briefly see "unknown"
373
+ # and flicker the UI.
374
+ for assembly_key, new_assembly in new_assemblies.items():
375
+ old_assembly = self.assemblies.get(assembly_key)
376
+ if old_assembly is None:
377
+ continue
378
+ for component_id, new_component in new_assembly.components.items():
379
+ old_component = old_assembly.components.get(component_id)
380
+ if old_component is None:
381
+ continue
382
+ for fn_name in new_component.functions:
383
+ if fn_name in old_component.functions:
384
+ new_component.functions[fn_name] = old_component.functions[
385
+ fn_name
386
+ ]
387
+ self.assemblies = new_assemblies
368
388
  self.device_type = DeviceType(self.assemblies["host"].type)
369
389
  self.insert_type = InsertType(self.assemblies["insert"].type)
370
390
  self.id = self.assemblies["host"].id
@@ -413,8 +433,15 @@ class SwidgetDevice:
413
433
  for id, component in self.assemblies[assembly].components.items():
414
434
  try:
415
435
  component.functions.update(state[assembly]["components"][id])
416
- except Exception:
417
- pass
436
+ except Exception as exc:
437
+ # Don't fail the whole state-process loop on one bad
438
+ # component, but DO surface what was skipped — silent
439
+ # failures here are how is_on ends up reading from a
440
+ # never-populated None placeholder.
441
+ _LOGGER.debug(
442
+ f"process_state: skipped {assembly}/{id} "
443
+ f"({type(exc).__name__}: {exc})"
444
+ )
418
445
  self._last_update = int(time.time())
419
446
 
420
447
  async def update(self) -> None:
@@ -27,7 +27,7 @@ class SwidgetWebsocket:
27
27
  session: aiohttp.ClientSession | None = None,
28
28
  use_security: bool = True,
29
29
  verify_ssl: bool = False,
30
- retry_interval: int = 30, # Initial retry interval in seconds
30
+ retry_interval: int = 5, # Initial retry interval in seconds
31
31
  max_retries: int | None = None, # Maximum number of reconnection attempts
32
32
  ):
33
33
  """Initialize the SwidgetWebsocket.
@@ -106,27 +106,31 @@ class SwidgetWebsocket:
106
106
  self._client = None
107
107
 
108
108
  async def send_str(self, message: str) -> None:
109
- """Send a string message through the websocket with retry attempts."""
109
+ """Send a string message through the websocket.
110
+
111
+ Drops the message (no retry) if the connection is missing or in
112
+ a closing state. Retrying against a closing transport just wastes
113
+ time — the right recovery is to invalidate the client so the
114
+ run() loop's reconnect kicks in on the next iteration.
115
+ """
110
116
  _LOGGER.debug(f"[{self.host}] websocket.send_str: {message}")
111
- max_send_retries = 3
112
- for attempt in range(max_send_retries):
113
- try:
114
- if self._client is not None:
115
- await self._client.send_str(message)
116
- return
117
- else:
118
- _LOGGER.warning("Websocket is not connected, not sending")
119
- return
120
- except Exception as e:
121
- _LOGGER.warning(
122
- f"Error sending message, attempt {attempt}/{max_send_retries}: {e}"
123
- )
124
- if attempt < max_send_retries - 1:
125
- await asyncio.sleep(5**attempt)
126
- else:
127
- _LOGGER.error(
128
- f"Failed to send message after {max_send_retries} attempts."
129
- )
117
+ if self._client is None or self._client.closed:
118
+ if self._client is not None:
119
+ # aiohttp says it's closed but we still hold a reference;
120
+ # clear it so run() reconnects on the next loop.
121
+ self._client = None
122
+ _LOGGER.warning(
123
+ f"[{self.host}] websocket not connected, dropping message"
124
+ )
125
+ return
126
+ try:
127
+ await self._client.send_str(message)
128
+ except Exception as e:
129
+ _LOGGER.warning(
130
+ f"[{self.host}] websocket send failed ({e}); invalidating client"
131
+ )
132
+ # Force the run() loop's reconnect path on the next iteration.
133
+ self._client = None
130
134
 
131
135
  async def receive(self) -> Any | None:
132
136
  """Receive a message from the WebSocket server."""
@@ -180,8 +184,9 @@ class SwidgetWebsocket:
180
184
  )
181
185
 
182
186
  self.retry_count += 1
183
- # Implement exponential backoff for reconnection delay
184
- delay = self.retry_interval * (2 ** (self.retry_count - 1))
187
+ # Exponential backoff capped at 60s so a long outage doesn't push
188
+ # the next attempt out by hours.
189
+ delay = min(self.retry_interval * (2 ** (self.retry_count - 1)), 60)
185
190
  _LOGGER.warning(
186
191
  f"Reconnecting to Swidget device: {self.host} in {delay} seconds (attempt {self.retry_count})..."
187
192
  )
File without changes