pySmartHashtag 0.8.2__tar.gz → 0.9.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.
- pysmarthashtag-0.9.0/.devcontainer.json +27 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/workflows/python-package.yml +2 -1
- {pysmarthashtag-0.8.2/pySmartHashtag.egg-info → pysmarthashtag-0.9.0}/PKG-INFO +3 -3
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/README.md +1 -1
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0/pySmartHashtag.egg-info}/PKG-INFO +3 -3
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/SOURCES.txt +2 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pyproject.toml +1 -1
- pysmarthashtag-0.9.0/pysmarthashtag/__init__.py +9 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/authentication.py +129 -6
- pysmarthashtag-0.9.0/pysmarthashtag/tests/test_authentication_backoff.py +193 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/requirements-test.txt +1 -1
- pysmarthashtag-0.8.2/pysmarthashtag/__init__.py +0 -5
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/copilot-instructions.md +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/dependabot.yml +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/workflows/python-publish.yml +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.gitignore +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.pre-commit-config.yaml +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.vscode/launch.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.vscode/settings.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/AUTHORS +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/CODE_OF_CONDUCT.md +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/CONTRIBUTING.md +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/ChangeLog +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/LICENSE +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/dependency_links.txt +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/requires.txt +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/top_level.txt +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/account.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/__init__.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/client.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/log_sanitizer.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/ssl_context.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/utils.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/cli.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/const.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/control/charging.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/control/climate.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/models.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/__init__.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/common.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/conftest.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/Human_and_vehicle_relationship_does_not_exist.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/api_access.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/auth_context.url +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/auth_intermediate.url +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/auth_result.url +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/charging_success.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/climate_success.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/login_result.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/ota_response.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/soc_80.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/soc_90.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/token_expired.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_info.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_info2.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_info_dc_charging.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_response.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_result.json +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_account.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_actions.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_charging.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_dc_charging.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_endpoint_urls.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_log_sanitizer.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_missing_fields.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_ssl_context.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/__init__.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/battery.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/climate.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/maintenance.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/position.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/running.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/safety.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/tires.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/vehicle.py +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/requirements-cli.txt +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/requirements.txt +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/run.sh +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/setup.cfg +0 -0
- {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/setup.py +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pySmartHashtag",
|
|
3
|
+
"image": "mcr.microsoft.com/devcontainers/python:dev-3.14-bookworm",
|
|
4
|
+
"postCreateCommand": "pip install -r requirements.txt && pip install -r requirements-test.txt && pip install -e .",
|
|
5
|
+
"customizations": {
|
|
6
|
+
"vscode": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"ms-python.python",
|
|
9
|
+
"charliermarsh.ruff",
|
|
10
|
+
"github.vscode-pull-request-github",
|
|
11
|
+
"ryanluker.vscode-coverage-gutters",
|
|
12
|
+
"ms-python.vscode-pylance"
|
|
13
|
+
],
|
|
14
|
+
"settings": {
|
|
15
|
+
"files.eol": "\n",
|
|
16
|
+
"editor.tabSize": 4,
|
|
17
|
+
"python.analysis.autoSearchPaths": false,
|
|
18
|
+
"editor.defaultFormatter": "charliermarsh.ruff",
|
|
19
|
+
"editor.formatOnPaste": false,
|
|
20
|
+
"editor.formatOnSave": true,
|
|
21
|
+
"editor.formatOnType": true,
|
|
22
|
+
"files.trimTrailingWhitespace": true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"remoteUser": "vscode"
|
|
27
|
+
}
|
|
@@ -20,7 +20,7 @@ jobs:
|
|
|
20
20
|
strategy:
|
|
21
21
|
fail-fast: false
|
|
22
22
|
matrix:
|
|
23
|
-
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
|
23
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
24
24
|
|
|
25
25
|
steps:
|
|
26
26
|
- uses: actions/checkout@v4.1.1
|
|
@@ -47,6 +47,7 @@ jobs:
|
|
|
47
47
|
pytest --cov=pysmarthashtag --cov-report=xml --cov-report=term-missing --cov-fail-under=80 pysmarthashtag/tests/
|
|
48
48
|
- name: Pytest coverage comment
|
|
49
49
|
id: coverageComment
|
|
50
|
+
if: github.event_name != 'pull_request'
|
|
50
51
|
uses: MishaKav/pytest-coverage-comment@main
|
|
51
52
|
with:
|
|
52
53
|
pytest-xml-coverage-path: ./coverage.xml
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pySmartHashtag
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: A python library to get information from Smart #1 and #
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: A python library to get information from Smart #1, #3 and #5 web API
|
|
5
5
|
Home-page: https://github.com/dasBasti/pySmartHashtag
|
|
6
6
|
Author: dasBasti
|
|
7
7
|
Author-email: Bastian Neumann <neumann.bastian@gmail.com>
|
|
@@ -52,7 +52,7 @@ Dynamic: license-file
|
|
|
52
52
|
[![CodeQL Validation][codeql-shield]][codeql]
|
|
53
53
|
[![Dependency Validation][tests-shield]][tests]
|
|
54
54
|
|
|
55
|
-
API wrapper for Smart #1 and #
|
|
55
|
+
API wrapper for Smart #1, #3 and #5 Cloud Service
|
|
56
56
|
|
|
57
57
|
Regard this to be kind of stable. This library is used in custom [Homeassistant](https://homeassistant.io) component [Smart Hashtag](https://github.com/DasBasti/SmartHashtag)
|
|
58
58
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[![CodeQL Validation][codeql-shield]][codeql]
|
|
6
6
|
[![Dependency Validation][tests-shield]][tests]
|
|
7
7
|
|
|
8
|
-
API wrapper for Smart #1 and #
|
|
8
|
+
API wrapper for Smart #1, #3 and #5 Cloud Service
|
|
9
9
|
|
|
10
10
|
Regard this to be kind of stable. This library is used in custom [Homeassistant](https://homeassistant.io) component [Smart Hashtag](https://github.com/DasBasti/SmartHashtag)
|
|
11
11
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pySmartHashtag
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: A python library to get information from Smart #1 and #
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: A python library to get information from Smart #1, #3 and #5 web API
|
|
5
5
|
Home-page: https://github.com/dasBasti/pySmartHashtag
|
|
6
6
|
Author: dasBasti
|
|
7
7
|
Author-email: Bastian Neumann <neumann.bastian@gmail.com>
|
|
@@ -52,7 +52,7 @@ Dynamic: license-file
|
|
|
52
52
|
[![CodeQL Validation][codeql-shield]][codeql]
|
|
53
53
|
[![Dependency Validation][tests-shield]][tests]
|
|
54
54
|
|
|
55
|
-
API wrapper for Smart #1 and #
|
|
55
|
+
API wrapper for Smart #1, #3 and #5 Cloud Service
|
|
56
56
|
|
|
57
57
|
Regard this to be kind of stable. This library is used in custom [Homeassistant](https://homeassistant.io) component [Smart Hashtag](https://github.com/DasBasti/SmartHashtag)
|
|
58
58
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
.devcontainer.json
|
|
1
2
|
.gitignore
|
|
2
3
|
.pre-commit-config.yaml
|
|
3
4
|
AUTHORS
|
|
@@ -42,6 +43,7 @@ pysmarthashtag/tests/common.py
|
|
|
42
43
|
pysmarthashtag/tests/conftest.py
|
|
43
44
|
pysmarthashtag/tests/test_account.py
|
|
44
45
|
pysmarthashtag/tests/test_actions.py
|
|
46
|
+
pysmarthashtag/tests/test_authentication_backoff.py
|
|
45
47
|
pysmarthashtag/tests/test_charging.py
|
|
46
48
|
pysmarthashtag/tests/test_dc_charging.py
|
|
47
49
|
pysmarthashtag/tests/test_endpoint_urls.py
|
|
@@ -8,7 +8,7 @@ dynamic = ["version"]
|
|
|
8
8
|
authors = [
|
|
9
9
|
{"name" = "Bastian Neumann", "email" = "neumann.bastian@gmail.com"},
|
|
10
10
|
]
|
|
11
|
-
description = "A python library to get information from Smart #1 and #
|
|
11
|
+
description = "A python library to get information from Smart #1, #3 and #5 web API"
|
|
12
12
|
license = {file = "LICENSE"}
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.9"
|
|
@@ -28,9 +28,34 @@ EXPIRES_AT_OFFSET = datetime.timedelta(seconds=HTTPX_TIMEOUT * 2)
|
|
|
28
28
|
_LOGGER = logging.getLogger(__name__)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
# PATCH: shared breaker state so HA's config_entries retry storm (which
|
|
32
|
+
# creates fresh SmartAuthentication instances) cannot bypass the quiet
|
|
33
|
+
# window. Keyed by username — state persists for the lifetime of the
|
|
34
|
+
# Python process.
|
|
35
|
+
class _BackoffState:
|
|
36
|
+
__slots__ = ("backoff", "quiet_until")
|
|
37
|
+
|
|
38
|
+
def __init__(self, backoff: datetime.timedelta) -> None:
|
|
39
|
+
self.backoff: datetime.timedelta = backoff
|
|
40
|
+
self.quiet_until: Optional[datetime.datetime] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_BACKOFF_REGISTRY: dict[str, _BackoffState] = {}
|
|
44
|
+
|
|
45
|
+
|
|
31
46
|
class SmartAuthentication(httpx.Auth):
|
|
32
47
|
"""Authentication and Retry Handler for the Smart API."""
|
|
33
48
|
|
|
49
|
+
# PATCH: Adaptive rate-limit backoff (AIMD).
|
|
50
|
+
# On rate-limit failure (HTTP 403/429), multiply current backoff by GROW (capped at CAP).
|
|
51
|
+
# On success, subtract SHRINK (floored at FLOOR). On non-rate-limit failure,
|
|
52
|
+
# use a fixed short backoff so transient blips don't grow the window.
|
|
53
|
+
_BACKOFF_FLOOR = datetime.timedelta(minutes=2)
|
|
54
|
+
_BACKOFF_CAP = datetime.timedelta(minutes=90)
|
|
55
|
+
_BACKOFF_GROW = 1.5
|
|
56
|
+
_BACKOFF_SHRINK = datetime.timedelta(minutes=1)
|
|
57
|
+
_OTHER_FAILURE_BACKOFF = datetime.timedelta(seconds=60)
|
|
58
|
+
|
|
34
59
|
def __init__(
|
|
35
60
|
self,
|
|
36
61
|
username: str,
|
|
@@ -53,6 +78,13 @@ class SmartAuthentication(httpx.Auth):
|
|
|
53
78
|
self.api_user_id: Optional[str] = None
|
|
54
79
|
self.ssl_context: Optional[ssl.SSLContext] = ssl_context
|
|
55
80
|
self.endpoint_urls: EndpointUrls = endpoint_urls if endpoint_urls is not None else EndpointUrls()
|
|
81
|
+
# PATCH: shared adaptive-backoff state (per username, module-scoped).
|
|
82
|
+
# Sharing across instances is essential because HA's config_entries
|
|
83
|
+
# retry mechanism creates fresh SmartAuthentication instances on
|
|
84
|
+
# every retry — per-instance state would never accumulate.
|
|
85
|
+
if username not in _BACKOFF_REGISTRY:
|
|
86
|
+
_BACKOFF_REGISTRY[username] = _BackoffState(self._BACKOFF_FLOOR)
|
|
87
|
+
self._state: _BackoffState = _BACKOFF_REGISTRY[username]
|
|
56
88
|
_LOGGER.debug("Device ID initialized")
|
|
57
89
|
|
|
58
90
|
async def get_ssl_context(self) -> ssl.SSLContext:
|
|
@@ -167,8 +199,87 @@ class SmartAuthentication(httpx.Auth):
|
|
|
167
199
|
_LOGGER.debug("Refreshing access token failed. Logging in again")
|
|
168
200
|
return {}
|
|
169
201
|
|
|
170
|
-
async def _login(self):
|
|
171
|
-
"""Login to Smart web services.
|
|
202
|
+
async def _login(self) -> dict:
|
|
203
|
+
"""Login to Smart web services with adaptive rate-limit backoff (PATCH).
|
|
204
|
+
|
|
205
|
+
Wraps the original login flow (now ``_do_login``) with:
|
|
206
|
+
* an in-memory circuit breaker (shared per-username) that suppresses
|
|
207
|
+
calls while inside an adaptive quiet window, and
|
|
208
|
+
* AIMD backoff: rate-limit failures grow the window geometrically,
|
|
209
|
+
successes shrink it additively.
|
|
210
|
+
"""
|
|
211
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
212
|
+
if self._state.quiet_until is not None and now < self._state.quiet_until:
|
|
213
|
+
wait_s = int((self._state.quiet_until - now).total_seconds())
|
|
214
|
+
raise SmartAPIError(
|
|
215
|
+
"Smart API in adaptive quiet window for another "
|
|
216
|
+
f"{wait_s}s (until "
|
|
217
|
+
f"{self._state.quiet_until.isoformat(timespec='seconds')}, "
|
|
218
|
+
f"backoff={self._state.backoff})"
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
result = await self._do_login()
|
|
222
|
+
except (SmartAPIError, httpx.HTTPStatusError) as exc:
|
|
223
|
+
self._on_login_failure(exc)
|
|
224
|
+
raise
|
|
225
|
+
else:
|
|
226
|
+
self._on_login_success()
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
def _on_login_failure(self, exc: Exception) -> None:
|
|
230
|
+
"""Update backoff state after a failed login attempt (PATCH)."""
|
|
231
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
232
|
+
if self._is_rate_limit_error(exc):
|
|
233
|
+
new_backoff = min(
|
|
234
|
+
self._state.backoff * self._BACKOFF_GROW, self._BACKOFF_CAP
|
|
235
|
+
)
|
|
236
|
+
self._state.backoff = new_backoff
|
|
237
|
+
self._state.quiet_until = now + new_backoff
|
|
238
|
+
_LOGGER.warning(
|
|
239
|
+
"Smart API rate-limit detected (%s). Adaptive quiet window: "
|
|
240
|
+
"%s, until %s",
|
|
241
|
+
exc, new_backoff,
|
|
242
|
+
self._state.quiet_until.isoformat(timespec='seconds'),
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
self._state.quiet_until = now + self._OTHER_FAILURE_BACKOFF
|
|
246
|
+
_LOGGER.info(
|
|
247
|
+
"Smart API login failed (%s). Short retry-suppress until %s",
|
|
248
|
+
exc, self._state.quiet_until.isoformat(timespec='seconds'),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _on_login_success(self) -> None:
|
|
252
|
+
"""Update backoff state after a successful login (PATCH)."""
|
|
253
|
+
prev = self._state.backoff
|
|
254
|
+
new_backoff = max(
|
|
255
|
+
self._state.backoff - self._BACKOFF_SHRINK, self._BACKOFF_FLOOR
|
|
256
|
+
)
|
|
257
|
+
self._state.backoff = new_backoff
|
|
258
|
+
self._state.quiet_until = None
|
|
259
|
+
if prev > self._BACKOFF_FLOOR:
|
|
260
|
+
_LOGGER.info(
|
|
261
|
+
"Smart API login succeeded; backoff %s -> %s", prev, new_backoff
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _is_rate_limit_error(exc: Exception) -> bool:
|
|
266
|
+
"""Heuristic: was *exc* caused by Smart's WAF / rate-limiter."""
|
|
267
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
268
|
+
return exc.response.status_code in (403, 429)
|
|
269
|
+
text = str(exc).lower()
|
|
270
|
+
# Match the embellished error messages from _do_login below, plus
|
|
271
|
+
# explicit Smart strings ("Api Rate limit exceeded").
|
|
272
|
+
return (
|
|
273
|
+
"rate limit" in text
|
|
274
|
+
or "rate-limit" in text
|
|
275
|
+
or "quota" in text
|
|
276
|
+
or "http 403" in text
|
|
277
|
+
or "http 429" in text
|
|
278
|
+
or "forbidden" in text
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def _do_login(self):
|
|
282
|
+
"""Original login flow (extracted by PATCH)."""
|
|
172
283
|
ssl_ctx = await self.get_ssl_context()
|
|
173
284
|
async with SmartLoginClient(ssl_context=ssl_ctx) as client:
|
|
174
285
|
_LOGGER.info("Acquiring access token.")
|
|
@@ -189,8 +300,14 @@ class SmartAuthentication(httpx.Auth):
|
|
|
189
300
|
try:
|
|
190
301
|
context = r_context.url.params["context"]
|
|
191
302
|
_LOGGER.debug("Context: %s", context)
|
|
192
|
-
except KeyError:
|
|
193
|
-
|
|
303
|
+
except KeyError as err:
|
|
304
|
+
# PATCH: include status + body so the breaker can classify (rate-limit vs other).
|
|
305
|
+
# Body is run through sanitize_log_data() so any tokens/PII are masked.
|
|
306
|
+
raise SmartAPIError(
|
|
307
|
+
"Could not get context from login page "
|
|
308
|
+
f"(HTTP {r_context.status_code}, "
|
|
309
|
+
f"body={sanitize_log_data(r_context.text[:200])!r})"
|
|
310
|
+
) from err
|
|
194
311
|
|
|
195
312
|
# Get login token from Smart API
|
|
196
313
|
r_login = await client.post(
|
|
@@ -228,8 +345,14 @@ class SmartAuthentication(httpx.Auth):
|
|
|
228
345
|
expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
|
|
229
346
|
seconds=int(login_result["sessionInfo"]["expires_in"])
|
|
230
347
|
)
|
|
231
|
-
except (KeyError, ValueError):
|
|
232
|
-
|
|
348
|
+
except (KeyError, ValueError) as err:
|
|
349
|
+
# PATCH: include status + body so the breaker can classify.
|
|
350
|
+
# Body is run through sanitize_log_data() so any tokens/PII are masked.
|
|
351
|
+
raise SmartAPIError(
|
|
352
|
+
"Could not get login token from login page "
|
|
353
|
+
f"(HTTP {r_login.status_code}, "
|
|
354
|
+
f"body={sanitize_log_data(r_login.text[:200])!r})"
|
|
355
|
+
) from err
|
|
233
356
|
|
|
234
357
|
auth_url = self.endpoint_urls.get_auth_url() + "?context=" + context + "&login_token=" + login_token
|
|
235
358
|
cookie = f"gmid=gmid.ver4.AcbHPqUK5Q.xOaWPhRTb7gy-6-GUW6cxQVf_t7LhbmeabBNXqqqsT6dpLJLOWCGWZM07EkmfM4j.u2AMsCQ9ZsKc6ugOIoVwCgryB2KJNCnbBrlY6pq0W2Ww7sxSkUa9_WTPBIwAufhCQYkb7gA2eUbb6EIZjrl5mQ.sc3; ucid=hPzasmkDyTeHN0DinLRGvw; hasGmid=ver4; gig_bootstrap_3_L94eyQ-wvJhWm7Afp1oBhfTGXZArUfSHHW9p9Pncg513hZELXsxCfMWHrF8f5P5a=auth_ver4; glt_3_L94eyQ-wvJhWm7Afp1oBhfTGXZArUfSHHW9p9Pncg513hZELXsxCfMWHrF8f5P5a={login_token}" # noqa: E501
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Tests for the adaptive rate-limit backoff in ``SmartAuthentication``.
|
|
2
|
+
|
|
3
|
+
These tests stub ``_do_login`` so no network calls happen and exercise:
|
|
4
|
+
|
|
5
|
+
* AIMD growth on rate-limit failures (HTTP 403/429)
|
|
6
|
+
* Circuit-breaker suppression while the quiet window is active
|
|
7
|
+
* Multiplicative shrink on successful login
|
|
8
|
+
* Fixed short suppress (no growth) on non-rate-limit failures
|
|
9
|
+
* Floor invariant after many successes
|
|
10
|
+
* Module-level state shared across ``SmartAuthentication`` instances
|
|
11
|
+
for the same username
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
from unittest.mock import patch
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from pysmarthashtag.api.authentication import (
|
|
23
|
+
_BACKOFF_REGISTRY,
|
|
24
|
+
SmartAPIError,
|
|
25
|
+
SmartAuthentication,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_auth(username: str = "user@example.com") -> SmartAuthentication:
|
|
30
|
+
"""Reset shared backoff state for ``username`` and return a fresh auth."""
|
|
31
|
+
_BACKOFF_REGISTRY.pop(username, None)
|
|
32
|
+
return SmartAuthentication(username=username, password="p")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _http_403() -> httpx.HTTPStatusError:
|
|
36
|
+
req = httpx.Request("GET", "https://example/")
|
|
37
|
+
resp = httpx.Response(403, request=req, content=b'{"message":"Forbidden"}')
|
|
38
|
+
return httpx.HTTPStatusError("forbidden", request=req, response=resp)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _login_ok() -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"access_token": "a",
|
|
44
|
+
"refresh_token": "r",
|
|
45
|
+
"api_access_token": "x",
|
|
46
|
+
"api_refresh_token": "y",
|
|
47
|
+
"api_user_id": "z",
|
|
48
|
+
"expires_at": datetime.datetime.now(datetime.timezone.utc)
|
|
49
|
+
+ datetime.timedelta(hours=1),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestAdaptiveBackoff:
|
|
54
|
+
"""Adaptive AIMD rate-limit backoff."""
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_grow_on_rate_limit(self):
|
|
58
|
+
"""Each rate-limit hit grows the backoff up to ``_BACKOFF_CAP``."""
|
|
59
|
+
auth = _make_auth()
|
|
60
|
+
floor_min = auth._BACKOFF_FLOOR.total_seconds() / 60
|
|
61
|
+
cap_min = auth._BACKOFF_CAP.total_seconds() / 60
|
|
62
|
+
grow = auth._BACKOFF_GROW
|
|
63
|
+
|
|
64
|
+
expected: list[float] = []
|
|
65
|
+
v = floor_min
|
|
66
|
+
for _ in range(11):
|
|
67
|
+
v = min(v * grow, cap_min)
|
|
68
|
+
expected.append(round(v, 6))
|
|
69
|
+
|
|
70
|
+
async def boom():
|
|
71
|
+
raise _http_403()
|
|
72
|
+
|
|
73
|
+
seen: list[float] = []
|
|
74
|
+
with patch.object(auth, "_do_login", boom):
|
|
75
|
+
for _ in expected:
|
|
76
|
+
# Pretend the previous quiet window has just elapsed so
|
|
77
|
+
# this probe is allowed through to ``_do_login``.
|
|
78
|
+
auth._state.quiet_until = None
|
|
79
|
+
with pytest.raises((SmartAPIError, httpx.HTTPStatusError)):
|
|
80
|
+
await auth._login()
|
|
81
|
+
seen.append(round(auth._state.backoff.total_seconds() / 60, 6))
|
|
82
|
+
|
|
83
|
+
assert seen == expected
|
|
84
|
+
assert auth._state.backoff <= auth._BACKOFF_CAP
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_breaker_blocks_during_quiet_window(self):
|
|
88
|
+
"""During the quiet window the breaker must skip ``_do_login``."""
|
|
89
|
+
auth = _make_auth()
|
|
90
|
+
|
|
91
|
+
async def boom():
|
|
92
|
+
raise _http_403()
|
|
93
|
+
|
|
94
|
+
with patch.object(auth, "_do_login", boom):
|
|
95
|
+
with pytest.raises((SmartAPIError, httpx.HTTPStatusError)):
|
|
96
|
+
await auth._login()
|
|
97
|
+
|
|
98
|
+
calls = {"n": 0}
|
|
99
|
+
|
|
100
|
+
async def count_calls():
|
|
101
|
+
calls["n"] += 1
|
|
102
|
+
raise _http_403()
|
|
103
|
+
|
|
104
|
+
with patch.object(auth, "_do_login", count_calls):
|
|
105
|
+
with pytest.raises(SmartAPIError) as excinfo:
|
|
106
|
+
await auth._login()
|
|
107
|
+
|
|
108
|
+
assert calls["n"] == 0, "breaker did not suppress the second probe"
|
|
109
|
+
assert "quiet window" in str(excinfo.value)
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_shrink_on_success(self):
|
|
113
|
+
"""Success shrinks the backoff and clears the quiet window."""
|
|
114
|
+
auth = _make_auth()
|
|
115
|
+
auth._state.backoff = datetime.timedelta(minutes=30)
|
|
116
|
+
auth._state.quiet_until = None
|
|
117
|
+
|
|
118
|
+
async def ok():
|
|
119
|
+
return _login_ok()
|
|
120
|
+
|
|
121
|
+
with patch.object(auth, "_do_login", ok):
|
|
122
|
+
await auth._login()
|
|
123
|
+
|
|
124
|
+
assert auth._state.backoff == datetime.timedelta(minutes=29)
|
|
125
|
+
assert auth._state.quiet_until is None
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_other_failure_uses_fixed_suppress_no_grow(self):
|
|
129
|
+
"""Non-rate-limit failures only set a short suppress, no grow."""
|
|
130
|
+
auth = _make_auth()
|
|
131
|
+
before = auth._state.backoff
|
|
132
|
+
|
|
133
|
+
async def boom():
|
|
134
|
+
raise SmartAPIError("Could not get access token from auth page")
|
|
135
|
+
|
|
136
|
+
with patch.object(auth, "_do_login", boom):
|
|
137
|
+
with pytest.raises(SmartAPIError):
|
|
138
|
+
await auth._login()
|
|
139
|
+
|
|
140
|
+
assert auth._state.backoff == before, "backoff grew on non-rate-limit failure"
|
|
141
|
+
assert auth._state.quiet_until is not None
|
|
142
|
+
|
|
143
|
+
delta = auth._state.quiet_until - datetime.datetime.now(datetime.timezone.utc)
|
|
144
|
+
# Allow a small clock-skew tolerance below the configured suppress window.
|
|
145
|
+
lower_bound = auth._OTHER_FAILURE_BACKOFF - datetime.timedelta(seconds=5)
|
|
146
|
+
assert lower_bound < delta <= auth._OTHER_FAILURE_BACKOFF
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_floor_invariant(self):
|
|
150
|
+
"""Repeated successes never push the backoff below the floor."""
|
|
151
|
+
auth = _make_auth()
|
|
152
|
+
|
|
153
|
+
async def ok():
|
|
154
|
+
return _login_ok()
|
|
155
|
+
|
|
156
|
+
with patch.object(auth, "_do_login", ok):
|
|
157
|
+
for _ in range(20):
|
|
158
|
+
await auth._login()
|
|
159
|
+
|
|
160
|
+
assert auth._state.backoff == auth._BACKOFF_FLOOR
|
|
161
|
+
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_state_shared_across_instances_same_user(self):
|
|
164
|
+
"""Two ``SmartAuthentication`` objects for the same user share state."""
|
|
165
|
+
a1 = _make_auth("shared_user")
|
|
166
|
+
|
|
167
|
+
async def boom():
|
|
168
|
+
raise _http_403()
|
|
169
|
+
|
|
170
|
+
with patch.object(a1, "_do_login", boom):
|
|
171
|
+
a1._state.quiet_until = None
|
|
172
|
+
with pytest.raises((SmartAPIError, httpx.HTTPStatusError)):
|
|
173
|
+
await a1._login()
|
|
174
|
+
|
|
175
|
+
grew_to = a1._state.backoff
|
|
176
|
+
assert grew_to > a1._BACKOFF_FLOOR
|
|
177
|
+
|
|
178
|
+
# New instance for the same username — must reuse the same state.
|
|
179
|
+
a2 = SmartAuthentication(username="shared_user", password="p")
|
|
180
|
+
assert a2._state is a1._state
|
|
181
|
+
assert a2._state.backoff == grew_to
|
|
182
|
+
|
|
183
|
+
calls = {"n": 0}
|
|
184
|
+
|
|
185
|
+
async def count_calls():
|
|
186
|
+
calls["n"] += 1
|
|
187
|
+
raise _http_403()
|
|
188
|
+
|
|
189
|
+
with patch.object(a2, "_do_login", count_calls):
|
|
190
|
+
with pytest.raises(SmartAPIError):
|
|
191
|
+
await a2._login()
|
|
192
|
+
|
|
193
|
+
assert calls["n"] == 0, "shared quiet window did not suppress second instance"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/auth_intermediate.url
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/charging_success.json
RENAMED
|
File without changes
|
{pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/climate_success.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/token_expired.json
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_info2.json
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_response.json
RENAMED
|
File without changes
|
{pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_result.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|