smartbox 2.4.0__tar.gz → 2.5.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.
Files changed (77) hide show
  1. {smartbox-2.4.0 → smartbox-2.5.0}/.github/workflows/publish-to-pypi.yml +2 -2
  2. {smartbox-2.4.0 → smartbox-2.5.0}/.github/workflows/python-package.yml +6 -6
  3. {smartbox-2.4.0 → smartbox-2.5.0}/.gitignore +1 -0
  4. {smartbox-2.4.0 → smartbox-2.5.0}/.pre-commit-config.yaml +2 -2
  5. {smartbox-2.4.0 → smartbox-2.5.0}/PKG-INFO +2 -2
  6. {smartbox-2.4.0 → smartbox-2.5.0}/pyproject.toml +17 -2
  7. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/models.py +4 -1
  8. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/session.py +81 -44
  9. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/socket.py +125 -44
  10. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/update_manager.py +6 -2
  11. {smartbox-2.4.0 → smartbox-2.5.0}/tests/conftest.py +35 -3
  12. {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_session.py +152 -45
  13. {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_update_manager.py +0 -1
  14. smartbox-2.5.0/uv.lock +1065 -0
  15. smartbox-2.4.0/tox.ini +0 -14
  16. smartbox-2.4.0/uv.lock +0 -1293
  17. {smartbox-2.4.0 → smartbox-2.5.0}/.github/FUNDING.yml +0 -0
  18. {smartbox-2.4.0 → smartbox-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  19. {smartbox-2.4.0 → smartbox-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  20. {smartbox-2.4.0 → smartbox-2.5.0}/.github/ISSUE_TEMPLATE/new-reseller.md +0 -0
  21. {smartbox-2.4.0 → smartbox-2.5.0}/.github/dependabot.yml +0 -0
  22. {smartbox-2.4.0 → smartbox-2.5.0}/.vscode/settings.json +0 -0
  23. {smartbox-2.4.0 → smartbox-2.5.0}/CHANGELOG.md +0 -0
  24. {smartbox-2.4.0 → smartbox-2.5.0}/CODEOWNERS +0 -0
  25. {smartbox-2.4.0 → smartbox-2.5.0}/LICENSE +0 -0
  26. {smartbox-2.4.0 → smartbox-2.5.0}/README.md +0 -0
  27. {smartbox-2.4.0 → smartbox-2.5.0}/api-notes.md +0 -0
  28. {smartbox-2.4.0 → smartbox-2.5.0}/codecov.yml +0 -0
  29. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/__init__.py +0 -0
  30. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/cmd.py +0 -0
  31. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/error.py +0 -0
  32. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/py.typed +0 -0
  33. {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/reseller.py +0 -0
  34. {smartbox-2.4.0 → smartbox-2.5.0}/tests/__init__.py +0 -0
  35. {smartbox-2.4.0 → smartbox-2.5.0}/tests/common.py +0 -0
  36. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/acm/1/samples.json +0 -0
  37. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/acm/1/setup.json +0 -0
  38. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/acm/1/status.json +0 -0
  39. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/connected.json +0 -0
  40. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr/0/samples.json +0 -0
  41. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr/0/setup.json +0 -0
  42. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr/0/status.json +0 -0
  43. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr_system/power_limit.json +0 -0
  44. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/mgr/away_status.json +0 -0
  45. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/mgr/nodes.json +0 -0
  46. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/pmo/3/samples.json +0 -0
  47. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/pmo/3/setup.json +0 -0
  48. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/pmo/3/status.json +0 -0
  49. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/connected.json +0 -0
  50. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/0/samples.json +0 -0
  51. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/0/setup.json +0 -0
  52. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/0/status.json +0 -0
  53. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/1/samples.json +0 -0
  54. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/1/setup.json +0 -0
  55. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/1/status.json +0 -0
  56. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/2/samples.json +0 -0
  57. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/2/setup.json +0 -0
  58. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/2/status.json +0 -0
  59. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/3/samples.json +0 -0
  60. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/3/setup.json +0 -0
  61. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/3/status.json +0 -0
  62. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/4/samples.json +0 -0
  63. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/4/setup.json +0 -0
  64. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/4/status.json +0 -0
  65. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/5/samples.json +0 -0
  66. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/5/setup.json +0 -0
  67. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/5/status.json +0 -0
  68. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_system/power_limit.json +0 -0
  69. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/mgr/away_status.json +0 -0
  70. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/mgr/nodes.json +0 -0
  71. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs.json +0 -0
  72. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/grouped_devs.json +0 -0
  73. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/groups/test_home/guest_users.json +0 -0
  74. {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/test_path.json +0 -0
  75. {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_cmd.py +0 -0
  76. {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_models.py +0 -0
  77. {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_reseller.py +0 -0
@@ -5,7 +5,7 @@ on:
5
5
  tags:
6
6
  - "v*"
7
7
  env:
8
- PYTHON_VERSION: 3.13
8
+ PYTHON_VERSION: 3.14
9
9
 
10
10
  jobs:
11
11
  build-n-publish:
@@ -22,7 +22,7 @@ jobs:
22
22
  with:
23
23
  python-version: ${{ env.PYTHON_VERSION }}
24
24
  - name: Set up Python ${{ env.PYTHON_VERSION }}
25
- uses: actions/setup-python@v6.1.0
25
+ uses: actions/setup-python@v6.2.0
26
26
  with:
27
27
  python-version: ${{ env.PYTHON_VERSION }}
28
28
  - name: Build a binary wheel and a source tarball
@@ -16,7 +16,7 @@ jobs:
16
16
  strategy:
17
17
  max-parallel: 4
18
18
  matrix:
19
- python-version: [3.13, 3.14]
19
+ python-version: [3.14]
20
20
  steps:
21
21
  - uses: actions/checkout@v6
22
22
  - name: Install uv
@@ -24,7 +24,7 @@ jobs:
24
24
  with:
25
25
  python-version: ${{ matrix.python-version }}
26
26
  - name: Set up Python ${{ matrix.python-version }}
27
- uses: actions/setup-python@v6.1.0
27
+ uses: actions/setup-python@v6.2.0
28
28
  with:
29
29
  python-version: ${{ matrix.python-version }}
30
30
  - name: Install the project
@@ -37,7 +37,7 @@ jobs:
37
37
  strategy:
38
38
  max-parallel: 1
39
39
  matrix:
40
- python-version: [3.13, 3.14]
40
+ python-version: [3.14]
41
41
 
42
42
  steps:
43
43
  - uses: actions/checkout@v6
@@ -46,7 +46,7 @@ jobs:
46
46
  with:
47
47
  python-version: ${{ matrix.python-version }}
48
48
  - name: Set up Python ${{ matrix.python-version }}
49
- uses: actions/setup-python@v6.1.0
49
+ uses: actions/setup-python@v6.2.0
50
50
  with:
51
51
  python-version: ${{ matrix.python-version }}
52
52
  - name: Install the project
@@ -59,7 +59,7 @@ jobs:
59
59
  strategy:
60
60
  max-parallel: 4
61
61
  matrix:
62
- python-version: [3.13, 3.14]
62
+ python-version: [3.14]
63
63
 
64
64
  steps:
65
65
  - uses: actions/checkout@v6
@@ -69,7 +69,7 @@ jobs:
69
69
  python-version: ${{ matrix.python-version }}
70
70
 
71
71
  - name: Set up Python ${{ matrix.python-version }}
72
- uses: actions/setup-python@v6.1.0
72
+ uses: actions/setup-python@v6.2.0
73
73
  with:
74
74
  python-version: ${{ matrix.python-version }}
75
75
  - name: Install the project
@@ -30,6 +30,7 @@ share/python-wheels/
30
30
  .installed.cfg
31
31
  *.egg
32
32
  MANIFEST
33
+ .sonar/
33
34
 
34
35
  # PyInstaller
35
36
  # Usually these files are written by a python script from a template
@@ -2,7 +2,7 @@ repos:
2
2
 
3
3
  - repo: https://github.com/astral-sh/uv-pre-commit
4
4
  # uv version.
5
- rev: 0.9.25
5
+ rev: 0.10.8
6
6
  hooks:
7
7
  # Update the uv lockfile
8
8
  - id: uv-lock
@@ -22,7 +22,7 @@ repos:
22
22
  - "--indent=4"
23
23
 
24
24
  - repo: https://github.com/astral-sh/ruff-pre-commit
25
- rev: v0.14.11
25
+ rev: v0.15.4
26
26
  hooks:
27
27
  - id: ruff
28
28
  args: [--fix, --exit-non-zero-on-fix]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartbox
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Python API to control heating 'smart boxes'
5
5
  Project-URL: Homepage, https://github.com/ajtudela/smartbox
6
6
  Project-URL: Issues, https://github.com/ajtudela/smartbox/issues
@@ -10,7 +10,7 @@ License-File: LICENSE
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
- Requires-Python: >=3.13
13
+ Requires-Python: >=3.14.2
14
14
  Requires-Dist: aiohttp
15
15
  Requires-Dist: asyncclick
16
16
  Requires-Dist: jq
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "smartbox"
3
- version = "2.4.0"
4
- requires-python = ">=3.13"
3
+ version = "2.5.0"
4
+ requires-python = ">=3.14.2"
5
5
  authors = [
6
6
  { name="Alberto Tudela", email="ajtudela@gmail.com" },
7
7
  { name="Delmael", email="delmael@outlook.com" },
@@ -136,3 +136,18 @@ max-complexity = 25
136
136
 
137
137
  [tool.setuptools.packages.find]
138
138
  where = ["src"]
139
+
140
+ [tool.tox]
141
+ env_list = ["3.14"]
142
+
143
+ [tool.tox.env_run_base]
144
+ isolated_build = true
145
+ description = "Run test under {base_python}"
146
+ dependency_groups = ["dev"]
147
+ commands = [["python", "-m", "pytest", "--cov=smartbox", "--cov-append", "--cov-report=term", "--cov-report=xml", "--junitxml=junit.xml"]]
148
+
149
+ [tool.sonar]
150
+ "sonar.python.version" = "3.14"
151
+ "python.coverage.reportPaths" = "coverage.xml"
152
+ "sonar.tests" = "tests"
153
+ "python.xunit.reportPath" = "junit.xml"
@@ -83,14 +83,16 @@ class NodeSetup(RootModel[DefaultNodeSetup | PmoSetup]):
83
83
  """Get the root model directly."""
84
84
  return getattr(self.root, name)
85
85
 
86
+
86
87
  class NodeVersion(BaseModel):
87
88
  """NodeVersion model."""
88
-
89
+
89
90
  hw_version: str
90
91
  fw_version: str
91
92
  uid: str
92
93
  pid: str
93
94
 
95
+
94
96
  class DefaultNodeStatus(BaseModel):
95
97
  """Default Node Status."""
96
98
 
@@ -173,6 +175,7 @@ class Node(BaseModel):
173
175
  installed: bool
174
176
  lost: bool | None = False
175
177
 
178
+
176
179
  class Nodes(BaseModel):
177
180
  """Nodes model."""
178
181
 
@@ -5,7 +5,7 @@ import datetime
5
5
  import json
6
6
  import logging
7
7
  import time
8
- from typing import Any
8
+ from typing import Any, Self
9
9
 
10
10
  import aiohttp
11
11
  from aiohttp import ClientSession
@@ -72,6 +72,10 @@ class AsyncSession:
72
72
  self._username: str = username
73
73
  self._password: str = password
74
74
  self._access_token: str = ""
75
+ self._refresh_token: str = ""
76
+ self._expires_at: datetime.datetime = datetime.datetime.now(
77
+ datetime.UTC
78
+ )
75
79
  self._client_session: ClientSession | None = websession
76
80
  self.raw_response: bool = raw_response
77
81
  self._headers: dict[str, str] = {
@@ -83,6 +87,29 @@ class AsyncSession:
83
87
  if self.reseller.web_url:
84
88
  self._headers.update({"x-referer": self.reseller.web_url})
85
89
 
90
+ async def __aenter__(self) -> Self:
91
+ """Async context manager entry."""
92
+ _LOGGER.debug(
93
+ "__aenter__ of AsyncSmartboxSession, authenticating and creating client session if not provided",
94
+ )
95
+ return self
96
+
97
+ async def __aexit__(
98
+ self,
99
+ exc_type: type[BaseException] | None,
100
+ exc_val: BaseException | None,
101
+ exc_tb: object,
102
+ ) -> None:
103
+ """Async context manager exit."""
104
+ _LOGGER.debug(
105
+ "Async context manager exit, closing client session and socket if exists"
106
+ )
107
+ if self._client_session:
108
+ await self._client_session.close()
109
+ # Cleanup socket if exists
110
+ if hasattr(self, "_socket") and self._socket:
111
+ await self._socket.disconnect()
112
+
86
113
  @property
87
114
  def reseller(self) -> SmartboxReseller:
88
115
  """Get the reseller."""
@@ -117,37 +144,39 @@ class AsyncSession:
117
144
  def client(self) -> ClientSession:
118
145
  """Return the underlying http client."""
119
146
  if not self._client_session:
120
- return ClientSession()
147
+ self._client_session = ClientSession()
121
148
  return self._client_session
122
149
 
123
150
  async def health_check(self) -> dict[str, Any]:
124
151
  """Check if the API is alived."""
125
152
  api_url = f"{self._api_host}/health_check"
126
153
  try:
127
- response = await self.client.get(api_url)
154
+ async with self.client.get(api_url) as response:
155
+ response.raise_for_status()
156
+ return await response.json()
128
157
  except (
129
158
  aiohttp.ClientConnectionError,
130
159
  aiohttp.ClientConnectorError,
131
160
  ) as e:
132
161
  raise APIUnavailableError(e) from e
133
- return await response.json()
134
162
 
135
163
  async def api_version(self) -> dict[str, str]:
136
164
  """Check if the API is alived."""
137
165
  api_url = f"{self._api_host}/version"
138
166
  try:
139
- response = await self.client.get(api_url)
167
+ async with self.client.get(api_url) as response:
168
+ response.raise_for_status()
169
+ return await response.json()
140
170
  except (
141
171
  aiohttp.ClientConnectionError,
142
172
  aiohttp.ClientConnectorError,
143
173
  ) as e:
144
174
  raise APIUnavailableError(e) from e
145
- return await response.json()
146
175
 
147
176
  async def _authentication(self, credentials: dict[str, str]) -> None:
148
177
  """Do the authentication process to Smartbox. First one use login/mdp/basic_auth. Then the tokens."""
149
178
  token_headers = self._headers.copy()
150
- del token_headers["Authorization"]
179
+ token_headers.pop("Authorization", None)
151
180
  token_headers.update(
152
181
  {
153
182
  "authorization": f"Basic {self.reseller.basic_auth}",
@@ -157,11 +186,40 @@ class AsyncSession:
157
186
 
158
187
  token_url = f"{self._api_host}/client/token"
159
188
  try:
160
- response = await self.client.post(
189
+ async with self.client.post(
161
190
  url=token_url,
162
191
  headers=token_headers,
163
192
  data=credentials,
164
- )
193
+ ) as response:
194
+ response.raise_for_status()
195
+ response_json = await response.json()
196
+ try:
197
+ rtoken: Token = Token.model_validate(response_json)
198
+ self._access_token = rtoken.access_token
199
+ self._headers["Authorization"] = (
200
+ f"Bearer {self._access_token}"
201
+ )
202
+ self._refresh_token = rtoken.refresh_token
203
+ if rtoken.expires_in < _MIN_TOKEN_LIFETIME:
204
+ _LOGGER.warning(
205
+ "Token expires in %ss which is below minimum lifetime of %ss- will refresh again on next operation",
206
+ rtoken.expires_in,
207
+ _MIN_TOKEN_LIFETIME,
208
+ )
209
+ self._expires_at = datetime.datetime.now(
210
+ datetime.UTC
211
+ ) + datetime.timedelta(
212
+ seconds=rtoken.expires_in,
213
+ )
214
+ _LOGGER.debug(
215
+ "Authenticated session (%s), access_token=%s, expires at %s",
216
+ credentials["grant_type"],
217
+ self.access_token,
218
+ self.expiry_time,
219
+ )
220
+ except ValidationError as e:
221
+ msg = f"Received invalid auth response {response.status} with msg: {response.reason}"
222
+ raise InvalidAuthError(msg) from e
165
223
  except (
166
224
  aiohttp.ClientConnectionError,
167
225
  aiohttp.ClientConnectorError,
@@ -169,31 +227,6 @@ class AsyncSession:
169
227
  raise APIUnavailableError(e) from e
170
228
  except aiohttp.ClientResponseError as e:
171
229
  raise InvalidAuthError(e) from e
172
- try:
173
- rtoken: Token = Token.model_validate(await response.json())
174
- self._access_token = rtoken.access_token
175
- self._headers["Authorization"] = f"Bearer {self._access_token}"
176
- self._refresh_token = rtoken.refresh_token
177
- if rtoken.expires_in < _MIN_TOKEN_LIFETIME:
178
- _LOGGER.warning(
179
- "Token expires in %ss which is below minimum lifetime of %ss- will refresh again on next operation",
180
- rtoken.expires_in,
181
- _MIN_TOKEN_LIFETIME,
182
- )
183
- self._expires_at = datetime.datetime.now(
184
- datetime.UTC
185
- ) + datetime.timedelta(
186
- seconds=rtoken.expires_in,
187
- )
188
- _LOGGER.debug(
189
- "Authenticated session (%s), access_token=%s, expires at %s",
190
- credentials["grant_type"],
191
- self.access_token,
192
- self.expiry_time,
193
- )
194
- except ValidationError as e:
195
- msg = f"Received invalid auth response {response.status} with msg: {response.reason}"
196
- raise InvalidAuthError(msg) from e
197
230
 
198
231
  async def check_refresh_auth(self) -> None:
199
232
  """Do we have to refresh auth."""
@@ -221,8 +254,13 @@ class AsyncSession:
221
254
  api_url = f"{self._api_host}/api/v2/{path}"
222
255
  try:
223
256
  _LOGGER.debug("Getting %s.", api_url)
224
- response = await self.client.get(api_url, headers=self._headers)
225
- _LOGGER.debug("Response %s.", (await response.json()))
257
+ async with self.client.get(
258
+ api_url, headers=self._headers
259
+ ) as response:
260
+ response.raise_for_status()
261
+ data = await response.json()
262
+ _LOGGER.debug("Response %s.", data)
263
+ return data
226
264
  except (
227
265
  aiohttp.ClientConnectionError,
228
266
  aiohttp.ClientConnectorError,
@@ -235,7 +273,6 @@ class AsyncSession:
235
273
  e.status,
236
274
  )
237
275
  raise SmartboxError(e) from e
238
- return await response.json()
239
276
 
240
277
  async def _api_post(
241
278
  self,
@@ -248,12 +285,13 @@ class AsyncSession:
248
285
  try:
249
286
  data_str = json.dumps(data)
250
287
  _LOGGER.debug("Posting %s to %s.", data_str, api_url)
251
- response = await self.client.post(
252
- api_url,
253
- data=data_str,
254
- headers=self._headers,
255
- )
256
- _LOGGER.debug("Response %s.", (await response.json()))
288
+ async with self.client.post(
289
+ api_url, headers=self._headers, data=data_str
290
+ ) as response:
291
+ response.raise_for_status()
292
+ data = await response.json()
293
+ _LOGGER.debug("Response %s.", data)
294
+ return data
257
295
  except (
258
296
  aiohttp.ClientConnectionError,
259
297
  aiohttp.ClientConnectorError,
@@ -266,7 +304,6 @@ class AsyncSession:
266
304
  e.status,
267
305
  )
268
306
  raise SmartboxError(e) from e
269
- return await response.json()
270
307
 
271
308
 
272
309
  class AsyncSmartboxSession(AsyncSession):
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  from collections.abc import Callable
5
+ import contextlib
5
6
  import logging
6
7
  import signal
7
8
  from typing import Any
@@ -14,8 +15,8 @@ from smartbox.session import AsyncSmartboxSession
14
15
  _API_V2_NAMESPACE = "/api/v2/socket_io"
15
16
  # We most commonly get disconnected when the session
16
17
  # expires, so we don't want to try many times
17
- _DEFAULT_RECONNECT_ATTEMPTS = 3
18
- _DEFAULT_BACKOFF_FACTOR = 0.1
18
+ _DEFAULT_RECONNECT_ATTEMPTS = 10
19
+ _DEFAULT_BACKOFF_FACTOR = 1.0
19
20
 
20
21
  _LOGGER = logging.getLogger(__name__)
21
22
 
@@ -106,17 +107,22 @@ class SocketSession:
106
107
  self._ping_interval = ping_interval
107
108
  self._reconnect_attempts = reconnect_attempts
108
109
  self._backoff_factor = backoff_factor
110
+ self._background_tasks: set[asyncio.Task] = set()
109
111
 
110
112
  if verbose:
111
113
  self._sio = socketio.AsyncClient(
112
114
  logger=True,
113
115
  engineio_logger=True,
114
- reconnection_attempts=reconnect_attempts,
116
+ http_session=self._session.client,
117
+ reconnection=False,
115
118
  )
116
119
  else:
117
120
  logging.getLogger("socketio").setLevel(logging.ERROR)
118
121
  logging.getLogger("engineio").setLevel(logging.ERROR)
119
- self._sio = socketio.AsyncClient()
122
+ self._sio = socketio.AsyncClient(
123
+ http_session=self._session.client,
124
+ reconnection=False,
125
+ )
120
126
 
121
127
  self._api_v2_ns = SmartboxAPIV2Namespace(
122
128
  session,
@@ -138,7 +144,9 @@ class SocketSession:
138
144
 
139
145
  def sigint_handler() -> None:
140
146
  _LOGGER.debug("Caught SIGINT, cancelling loop")
141
- asyncio.ensure_future(self.cancel())
147
+ task = asyncio.ensure_future(self.cancel())
148
+ self._background_tasks.add(task)
149
+ task.add_done_callback(self._background_tasks.discard)
142
150
 
143
151
  event_loop.add_signal_handler(signal.SIGINT, sigint_handler)
144
152
 
@@ -160,32 +168,101 @@ class SocketSession:
160
168
  _LOGGER.debug("Sending ping")
161
169
  await self._sio.send("ping", namespace=_API_V2_NAMESPACE)
162
170
 
171
+ async def _attempt_connection(self, url: str) -> bool:
172
+ """Attempt to connect to the websocket URL.
173
+
174
+ Returns True if connection was successful, False otherwise.
175
+ """
176
+ _LOGGER.debug("Connecting to %s", url)
177
+ try:
178
+ connect_task = asyncio.create_task(
179
+ self._sio.connect(url, transports=["websocket"])
180
+ )
181
+ try:
182
+ await asyncio.shield(connect_task)
183
+ except asyncio.CancelledError:
184
+ _LOGGER.debug(
185
+ "HA stops during connection, scheduled cleaning..."
186
+ )
187
+ if not connect_task.done():
188
+
189
+ async def _cleanup_dangling_socket(
190
+ task_to_cleanup: asyncio.Task = connect_task,
191
+ ) -> None:
192
+ try:
193
+ await task_to_cleanup
194
+ await self._sio.disconnect()
195
+ except (
196
+ AttributeError,
197
+ RuntimeError,
198
+ OSError,
199
+ ) as e:
200
+ _LOGGER.debug(
201
+ "Error occurred while _cleanup_dangling_socket: %s",
202
+ e,
203
+ )
204
+
205
+ task = asyncio.create_task(_cleanup_dangling_socket())
206
+ self._background_tasks.add(task)
207
+ task.add_done_callback(self._background_tasks.discard)
208
+
209
+ raise
210
+ _LOGGER.info("Successfully connected to %s", url)
211
+ await self._dev_data()
212
+ await self._sio.wait()
213
+ await self._cleanup_websocket()
214
+ with contextlib.suppress(Exception):
215
+ await self._sio.disconnect()
216
+ except socketio.exceptions.ConnectionError:
217
+ return False
218
+ return True
219
+
220
+ async def _cleanup_websocket(self) -> None:
221
+ """Clean up orphaned WebSocket connections."""
222
+ _LOGGER.debug("Exiting wait(), forcing socketio cleanup...")
223
+ try:
224
+ if (
225
+ hasattr(self._sio, "eio")
226
+ and hasattr(self._sio.eio, "ws")
227
+ and self._sio.eio.ws
228
+ ) and not self._sio.eio.ws.closed:
229
+ _LOGGER.debug("Manually closing the orphaned WebSocket")
230
+ await self._sio.eio.ws.close()
231
+ except (AttributeError, RuntimeError, OSError) as e:
232
+ _LOGGER.debug(
233
+ "Error occurred while manually closing the WebSocket: %s",
234
+ e,
235
+ )
236
+
163
237
  async def run(self) -> None:
164
238
  """Run the websocket."""
165
239
  self._ping_task = self._sio.start_background_task(self._send_ping)
166
-
167
- # Will loop indefinitely unless our signal handler is set and called
168
240
  self._loop_should_exit = False
169
241
 
170
242
  _LOGGER.debug("Starting main loop")
171
- while not self._loop_should_exit:
172
- encoded_token = urllib.parse.quote(
173
- self._session.access_token,
174
- safe="~()*!.'",
175
- )
176
- url = f"{self._session.api_host}/?token={encoded_token}&dev_id={self._device_id}"
243
+ try:
244
+ while not self._loop_should_exit:
245
+ encoded_token = urllib.parse.quote(
246
+ self._session.access_token,
247
+ safe="~()*!.'",
248
+ )
249
+ url = f"{self._session.api_host}/?token={encoded_token}&dev_id={self._device_id}"
250
+
251
+ # Try to connect
252
+ _LOGGER.debug(
253
+ "Connecting to %s (will try %s times)",
254
+ url,
255
+ self._reconnect_attempts,
256
+ )
257
+ for attempt in range(self._reconnect_attempts):
258
+ _LOGGER.debug(
259
+ "Connecting to %s (attempt #%s)", url, attempt
260
+ )
261
+
262
+ if await self._attempt_connection(url):
263
+ _LOGGER.debug("Breaking loop to refresh token")
264
+ break
177
265
 
178
- # Try to connect
179
- _LOGGER.debug(
180
- "Connecting to %s (will try %s times)",
181
- url,
182
- self._reconnect_attempts,
183
- )
184
- for attempt in range(self._reconnect_attempts):
185
- _LOGGER.debug("Connecting to %s (attempt #%s)", url, attempt)
186
- try:
187
- await self._sio.connect(url, transports=["websocket"])
188
- except socketio.exceptions.ConnectionError:
189
266
  remaining = self._reconnect_attempts - attempt - 1
190
267
  sleep_time = self._backoff_factor * (2**attempt)
191
268
  _LOGGER.exception(
@@ -200,31 +277,35 @@ class SocketSession:
200
277
  "Failed to connect after %s attempts, falling through to refresh token",
201
278
  self._reconnect_attempts,
202
279
  )
203
- else:
204
- _LOGGER.info("Successfully connected to %s", url)
205
- await self._dev_data()
206
- await self._sio.wait()
207
- _LOGGER.info("Socket loop exited, disconnecting")
208
- await self._sio.disconnect()
209
- _LOGGER.debug("Breaking loop to refresh token")
210
- break
211
-
212
- # Refresh token
213
- await self._session.check_refresh_auth()
214
-
215
- # Update the query string with the new access token
216
- encoded_token = urllib.parse.quote(
217
- self._session.access_token,
218
- safe="~()*!.'",
219
- )
220
- url = f"{self._session.api_host}/?token={encoded_token}&dev_id={self._device_id}"
280
+ await self._session.check_refresh_auth()
281
+ except asyncio.CancelledError:
282
+ _LOGGER.debug("WebSocket loop cancelled by Home Assistant")
283
+ raise
284
+ finally:
285
+ _LOGGER.debug("Cleaning up socketio...")
286
+ await self.shutdown()
221
287
 
222
288
  async def cancel(self) -> None:
223
289
  """Disconnecting and cancelling tasks."""
224
290
  _LOGGER.debug("Disconnecting and cancelling tasks")
225
291
  self._loop_should_exit = True
226
- await self._sio.disconnect()
227
- self._ping_task.cancel()
292
+ if hasattr(self, "_ping_task") and not self._ping_task.done():
293
+ self._ping_task.cancel()
294
+ try:
295
+ await asyncio.shield(self._sio.disconnect())
296
+ except (asyncio.CancelledError, OSError) as e:
297
+ _LOGGER.debug("Silent error on disconnect: %s", e)
298
+
299
+ async def shutdown(self) -> None:
300
+ """Shutdown the socket session."""
301
+ self._loop_should_exit = True
302
+ if hasattr(self, "_ping_task") and not self._ping_task.done():
303
+ self._ping_task.cancel()
304
+
305
+ try:
306
+ await asyncio.shield(self._sio.disconnect())
307
+ except (asyncio.CancelledError, OSError) as e:
308
+ _LOGGER.debug("Silent error on disconnect: %s", e)
228
309
 
229
310
  @property
230
311
  def namespace(self) -> SmartboxAPIV2Namespace:
@@ -3,7 +3,7 @@
3
3
  from collections.abc import Callable, Iterable
4
4
  import logging
5
5
  import re
6
- from typing import Any
6
+ from typing import Any, ClassVar
7
7
 
8
8
  import jq
9
9
 
@@ -18,6 +18,8 @@ _SIMPLE_JQ_RE = re.compile(r"^\.(\w+)$")
18
18
  class OptimisedJQMatcher:
19
19
  """jq matcher that doesn't bother with jq for simple one-level element queries."""
20
20
 
21
+ _jq_cache: ClassVar[dict[str, Any]] = {}
22
+
21
23
  def __init__(self, jq_expr: str) -> None:
22
24
  """Create an OptimisedJQMatcher for any jq expression."""
23
25
  m = _SIMPLE_JQ_RE.match(jq_expr)
@@ -26,7 +28,9 @@ class OptimisedJQMatcher:
26
28
  self._fast_path = True
27
29
  self._simple_elem = m.group(1)
28
30
  else:
29
- self._compiled_jq = jq.compile(jq_expr)
31
+ if jq_expr not in self._jq_cache:
32
+ self._jq_cache[jq_expr] = jq.compile(jq_expr)
33
+ self._compiled_jq = self._jq_cache[jq_expr]
30
34
 
31
35
  def match(self, input_data: dict[str, Any]) -> Iterable[Any]:
32
36
  """Return matches for the given dev data."""