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.
- {smartbox-2.4.0 → smartbox-2.5.0}/.github/workflows/publish-to-pypi.yml +2 -2
- {smartbox-2.4.0 → smartbox-2.5.0}/.github/workflows/python-package.yml +6 -6
- {smartbox-2.4.0 → smartbox-2.5.0}/.gitignore +1 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/.pre-commit-config.yaml +2 -2
- {smartbox-2.4.0 → smartbox-2.5.0}/PKG-INFO +2 -2
- {smartbox-2.4.0 → smartbox-2.5.0}/pyproject.toml +17 -2
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/models.py +4 -1
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/session.py +81 -44
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/socket.py +125 -44
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/update_manager.py +6 -2
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/conftest.py +35 -3
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_session.py +152 -45
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_update_manager.py +0 -1
- smartbox-2.5.0/uv.lock +1065 -0
- smartbox-2.4.0/tox.ini +0 -14
- smartbox-2.4.0/uv.lock +0 -1293
- {smartbox-2.4.0 → smartbox-2.5.0}/.github/FUNDING.yml +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/.github/ISSUE_TEMPLATE/new-reseller.md +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/.github/dependabot.yml +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/.vscode/settings.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/CHANGELOG.md +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/CODEOWNERS +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/LICENSE +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/README.md +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/api-notes.md +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/codecov.yml +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/__init__.py +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/cmd.py +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/error.py +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/py.typed +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/src/smartbox/reseller.py +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/__init__.py +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/common.py +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/acm/1/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/acm/1/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/acm/1/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/connected.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr/0/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr/0/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr/0/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/htr_system/power_limit.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/mgr/away_status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/mgr/nodes.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/pmo/3/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/pmo/3/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device1/pmo/3/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/connected.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/0/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/0/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/0/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/1/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/1/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/1/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/2/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/2/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/2/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/3/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/3/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/3/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/4/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/4/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/4/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/5/samples.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/5/setup.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_mod/5/status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/htr_system/power_limit.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/mgr/away_status.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs/device2/mgr/nodes.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/devs.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/grouped_devs.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/groups/test_home/guest_users.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/fixtures/test_path.json +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_cmd.py +0 -0
- {smartbox-2.4.0 → smartbox-2.5.0}/tests/test_models.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
72
|
+
uses: actions/setup-python@v6.2.0
|
|
73
73
|
with:
|
|
74
74
|
python-version: ${{ matrix.python-version }}
|
|
75
75
|
- name: Install the project
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
+
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
|
-
requires-python = ">=3.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
252
|
-
api_url,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 =
|
|
18
|
-
_DEFAULT_BACKOFF_FACTOR = 0
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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."""
|