pySmartHashtag 0.9.2__tar.gz → 0.11.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.2 → pysmarthashtag-0.11.0}/.github/workflows/python-package.yml +2 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.github/workflows/python-publish.yml +2 -0
- {pysmarthashtag-0.9.2/pySmartHashtag.egg-info → pysmarthashtag-0.11.0}/PKG-INFO +1 -1
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0/pySmartHashtag.egg-info}/PKG-INFO +1 -1
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/SOURCES.txt +7 -1
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/account.py +374 -10
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/models.py +13 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/common.py +16 -0
- pysmarthashtag-0.11.0/pysmarthashtag/tests/replys/trackpoints_response.json +20 -0
- pysmarthashtag-0.11.0/pysmarthashtag/tests/test_journal_pagination.py +289 -0
- pysmarthashtag-0.11.0/pysmarthashtag/tests/test_trackpoints.py +256 -0
- pysmarthashtag-0.11.0/pysmarthashtag/tests/test_vehicle_state.py +105 -0
- pysmarthashtag-0.11.0/pysmarthashtag/vehicle/trackpoints.py +131 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/vehicle.py +10 -0
- pysmarthashtag-0.11.0/pysmarthashtag/vehicle/vehicle_state.py +169 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.devcontainer.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.github/copilot-instructions.md +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.github/dependabot.yml +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.gitignore +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.pre-commit-config.yaml +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.vscode/launch.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.vscode/settings.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/AUTHORS +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/CODE_OF_CONDUCT.md +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/CONTRIBUTING.md +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/ChangeLog +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/LICENSE +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/README.md +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/dependency_links.txt +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/requires.txt +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/top_level.txt +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pyproject.toml +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/__init__.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/__init__.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/authentication.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/client.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/log_sanitizer.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/ssl_context.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/utils.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/cli.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/const.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/control/charging.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/control/climate.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/control/journal.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/__init__.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/conftest.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/Human_and_vehicle_relationship_does_not_exist.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/api_access.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/auth_context.url +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/auth_intermediate.url +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/auth_result.url +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/charging_success.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/climate_success.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/journal_response.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/journal_toggle_success.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/login_result.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/ota_response.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/soc_80.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/soc_90.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/token_expired.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_info.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_info2.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_info_dc_charging.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_response.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_result.json +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_account.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_actions.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_authentication_backoff.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_charging.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_dc_charging.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_endpoint_urls.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_journal.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_journal_control.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_log_sanitizer.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_missing_fields.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_ssl_context.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/__init__.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/battery.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/climate.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/journal.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/maintenance.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/position.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/running.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/safety.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/tires.py +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/requirements-cli.txt +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/requirements-test.txt +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/requirements.txt +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/run.sh +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/setup.cfg +0 -0
- {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/setup.py +0 -0
|
@@ -50,9 +50,12 @@ pysmarthashtag/tests/test_dc_charging.py
|
|
|
50
50
|
pysmarthashtag/tests/test_endpoint_urls.py
|
|
51
51
|
pysmarthashtag/tests/test_journal.py
|
|
52
52
|
pysmarthashtag/tests/test_journal_control.py
|
|
53
|
+
pysmarthashtag/tests/test_journal_pagination.py
|
|
53
54
|
pysmarthashtag/tests/test_log_sanitizer.py
|
|
54
55
|
pysmarthashtag/tests/test_missing_fields.py
|
|
55
56
|
pysmarthashtag/tests/test_ssl_context.py
|
|
57
|
+
pysmarthashtag/tests/test_trackpoints.py
|
|
58
|
+
pysmarthashtag/tests/test_vehicle_state.py
|
|
56
59
|
pysmarthashtag/tests/replys/Human_and_vehicle_relationship_does_not_exist.json
|
|
57
60
|
pysmarthashtag/tests/replys/api_access.json
|
|
58
61
|
pysmarthashtag/tests/replys/auth_context.url
|
|
@@ -67,6 +70,7 @@ pysmarthashtag/tests/replys/ota_response.json
|
|
|
67
70
|
pysmarthashtag/tests/replys/soc_80.json
|
|
68
71
|
pysmarthashtag/tests/replys/soc_90.json
|
|
69
72
|
pysmarthashtag/tests/replys/token_expired.json
|
|
73
|
+
pysmarthashtag/tests/replys/trackpoints_response.json
|
|
70
74
|
pysmarthashtag/tests/replys/vehicle_info.json
|
|
71
75
|
pysmarthashtag/tests/replys/vehicle_info2.json
|
|
72
76
|
pysmarthashtag/tests/replys/vehicle_info_dc_charging.json
|
|
@@ -81,4 +85,6 @@ pysmarthashtag/vehicle/position.py
|
|
|
81
85
|
pysmarthashtag/vehicle/running.py
|
|
82
86
|
pysmarthashtag/vehicle/safety.py
|
|
83
87
|
pysmarthashtag/vehicle/tires.py
|
|
84
|
-
pysmarthashtag/vehicle/
|
|
88
|
+
pysmarthashtag/vehicle/trackpoints.py
|
|
89
|
+
pysmarthashtag/vehicle/vehicle.py
|
|
90
|
+
pysmarthashtag/vehicle/vehicle_state.py
|
|
@@ -1,24 +1,108 @@
|
|
|
1
1
|
"""Access to Smart account for your vehicles therin."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import datetime
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
6
7
|
from dataclasses import InitVar, dataclass, field
|
|
7
8
|
from typing import Optional
|
|
8
9
|
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
9
12
|
from pysmarthashtag.api import utils
|
|
10
13
|
from pysmarthashtag.api.authentication import SmartAuthentication
|
|
11
14
|
from pysmarthashtag.api.client import SmartClient, SmartClientConfiguration
|
|
12
15
|
from pysmarthashtag.api.log_sanitizer import sanitize_log_data
|
|
13
16
|
from pysmarthashtag.const import API_CARS_URL, API_SELECT_CAR_URL, EndpointUrls
|
|
14
|
-
from pysmarthashtag.models import
|
|
17
|
+
from pysmarthashtag.models import (
|
|
18
|
+
JournalTruncationError,
|
|
19
|
+
SmartAuthError,
|
|
20
|
+
SmartHumanCarConnectionError,
|
|
21
|
+
SmartTokenRefreshNecessary,
|
|
22
|
+
)
|
|
23
|
+
from pysmarthashtag.vehicle.trackpoints import TripTrackpoints, parse_trackpoints_response
|
|
15
24
|
from pysmarthashtag.vehicle.vehicle import SmartVehicle
|
|
16
25
|
|
|
26
|
+
# Path prefix for the per-trip GPS trackpoints endpoint. Cloud-side
|
|
27
|
+
# service is ``vehicle-history-service / journal-service`` (same
|
|
28
|
+
# ``apiv2.ecloudeu.com`` host as journalLogV4); the trailing ``history``
|
|
29
|
+
# segment is the cloud's own naming.
|
|
30
|
+
TRIP_TRACKPOINTS_PATH_PREFIX = "/vehicle-history-service/journal-service/vehicle/status/history/"
|
|
31
|
+
|
|
32
|
+
# Default page size for the trackpoints endpoint, matching the cap the
|
|
33
|
+
# cloud advertises in its own ``pagination`` block. In observed responses
|
|
34
|
+
# ``totleSize`` never exceeded this cap; the wrapper logs a WARNING if it
|
|
35
|
+
# ever does so a future maintainer knows to add pagination here.
|
|
36
|
+
TRIP_TRACKPOINTS_PAGE_SIZE = 500
|
|
37
|
+
|
|
38
|
+
# Cloud status code returned by both journalLogV4 and the trackpoints
|
|
39
|
+
# endpoint when there's no data to report. The SDK raises
|
|
40
|
+
# :class:`httpx.HTTPStatusError` for any non-1000 code with a
|
|
41
|
+
# ``message`` key; 8153 surfaces here so callers can normalise it to an
|
|
42
|
+
# empty result without propagating to the caller.
|
|
43
|
+
_BENIGN_EMPTY_CODE = "8153"
|
|
44
|
+
|
|
17
45
|
VALID_UNTIL_OFFSET = datetime.timedelta(seconds=10)
|
|
18
46
|
|
|
47
|
+
# Cloud's "data unavailable" code on the journal endpoints. The SDK's
|
|
48
|
+
# raise_for_status hook surfaces it as :class:`httpx.HTTPStatusError`;
|
|
49
|
+
# the page-loop normalises it to "end of data" so a transient cloud
|
|
50
|
+
# blip mid-loop doesn't fail the whole poll.
|
|
51
|
+
_BENIGN_EMPTY_CODE = "8153"
|
|
52
|
+
|
|
19
53
|
_LOGGER = logging.getLogger(__name__)
|
|
20
54
|
|
|
21
55
|
|
|
56
|
+
def _is_benign_empty(exc: httpx.HTTPStatusError) -> bool:
|
|
57
|
+
"""Return True iff ``exc`` carries the cloud's 8153 "data unavailable" code.
|
|
58
|
+
|
|
59
|
+
The SDK's :func:`raise_for_status_event_handler` raises for any
|
|
60
|
+
non-1000 ``code`` with a ``message`` key. ``8153`` is the cloud's
|
|
61
|
+
end-of-data signal for journal endpoints — callers normalise it to
|
|
62
|
+
an empty result rather than propagating it as an error.
|
|
63
|
+
"""
|
|
64
|
+
response = exc.response
|
|
65
|
+
if response is None:
|
|
66
|
+
return False
|
|
67
|
+
try:
|
|
68
|
+
body = response.json()
|
|
69
|
+
except ValueError:
|
|
70
|
+
return False
|
|
71
|
+
return str(body.get("code")) == _BENIGN_EMPTY_CODE
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _unwrap_journal_page(body: dict) -> tuple[list, Optional[int]]:
|
|
75
|
+
"""Pull ``data.list`` and ``data.pagination.totleSize`` out of a journal body.
|
|
76
|
+
|
|
77
|
+
Returns ``([], None)`` for missing/None ``data`` (the cloud
|
|
78
|
+
occasionally returns a top-level ``code: 1000`` with ``data: null``
|
|
79
|
+
on empty windows, which we treat as a benign empty page).
|
|
80
|
+
|
|
81
|
+
The cloud's response envelope uses the typo "totleSize" (sic) for
|
|
82
|
+
the total-records field — preserve the misspelling so the existing
|
|
83
|
+
parser keeps working.
|
|
84
|
+
"""
|
|
85
|
+
if not isinstance(body, dict):
|
|
86
|
+
return [], None
|
|
87
|
+
data = body.get("data")
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
return [], None
|
|
90
|
+
items_raw = data.get("list")
|
|
91
|
+
items = items_raw if isinstance(items_raw, list) else []
|
|
92
|
+
pagination = data.get("pagination")
|
|
93
|
+
total_raw = pagination.get("totleSize") if isinstance(pagination, dict) else None
|
|
94
|
+
if isinstance(total_raw, bool):
|
|
95
|
+
# bool is an int subclass — guard so True/False never become 1/0.
|
|
96
|
+
total = None
|
|
97
|
+
elif isinstance(total_raw, (int, float)):
|
|
98
|
+
total = int(total_raw)
|
|
99
|
+
elif isinstance(total_raw, str) and total_raw.strip().isdigit():
|
|
100
|
+
total = int(total_raw.strip())
|
|
101
|
+
else:
|
|
102
|
+
total = None
|
|
103
|
+
return list(items), total
|
|
104
|
+
|
|
105
|
+
|
|
22
106
|
@dataclass
|
|
23
107
|
class SmartAccount:
|
|
24
108
|
"""Create a new connection to the Smart web service."""
|
|
@@ -155,11 +239,21 @@ class SmartAccount:
|
|
|
155
239
|
_LOGGER.debug(
|
|
156
240
|
"Trip journal fetch failed for %s", sanitize_log_data(vin), exc_info=True
|
|
157
241
|
)
|
|
242
|
+
# Per-VIN TBox state flags (engine/journal/valet/etc.) — best-effort,
|
|
243
|
+
# same reasoning as the journal call above.
|
|
244
|
+
state_response = None
|
|
245
|
+
try:
|
|
246
|
+
state_response = await self.get_vehicle_state(vin)
|
|
247
|
+
except Exception: # noqa: BLE001 # Best-effort: state fetch must not break refresh.
|
|
248
|
+
_LOGGER.debug(
|
|
249
|
+
"Vehicle-state fetch failed for %s", sanitize_log_data(vin), exc_info=True
|
|
250
|
+
)
|
|
158
251
|
vehicle.combine_data(
|
|
159
252
|
vehicle_info,
|
|
160
253
|
charging_settings=vehicle_soc,
|
|
161
254
|
ota_info=vehicle_ota_info,
|
|
162
255
|
journal_response=journal_response,
|
|
256
|
+
state_response=state_response,
|
|
163
257
|
)
|
|
164
258
|
|
|
165
259
|
async def select_active_vehicle(self, vin) -> None:
|
|
@@ -242,6 +336,47 @@ class SmartAccount:
|
|
|
242
336
|
raise SmartAuthError("Could not get vehicle information")
|
|
243
337
|
return data
|
|
244
338
|
|
|
339
|
+
async def get_vehicle_state(self, vin) -> dict:
|
|
340
|
+
"""Fetch the small flat-dict state-flag response for a vehicle.
|
|
341
|
+
|
|
342
|
+
Hits ``/remote-control/vehicle/status/state/{vin}`` (no params) —
|
|
343
|
+
the same endpoint Hello # uses to confirm command dispatch (e.g.
|
|
344
|
+
polls ``journalLogState`` after toggling 'Record trips on vehicle').
|
|
345
|
+
|
|
346
|
+
Returns the raw response dict; caller wraps via
|
|
347
|
+
``VehicleState.from_response()``. Errors are logged but not raised
|
|
348
|
+
so a single flaky vehicle doesn't break the wider refresh.
|
|
349
|
+
"""
|
|
350
|
+
_LOGGER.debug("Getting vehicle state for %s", sanitize_log_data(vin))
|
|
351
|
+
path = "/remote-control/vehicle/status/state/" + vin
|
|
352
|
+
data: dict = {}
|
|
353
|
+
async with SmartClient(self.config) as client:
|
|
354
|
+
for retry in range(3):
|
|
355
|
+
try:
|
|
356
|
+
r_state = await client.get(
|
|
357
|
+
self.vehicles[vin].base_url + path,
|
|
358
|
+
headers={
|
|
359
|
+
**utils.generate_default_header(
|
|
360
|
+
client.config.authentication.device_id,
|
|
361
|
+
client.config.authentication.api_access_token,
|
|
362
|
+
params={},
|
|
363
|
+
method="GET",
|
|
364
|
+
url=path,
|
|
365
|
+
)
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
_LOGGER.debug("Got response %d", r_state.status_code)
|
|
369
|
+
data = r_state.json()
|
|
370
|
+
except SmartTokenRefreshNecessary:
|
|
371
|
+
_LOGGER.debug("Got Token Error, retry: %d", retry)
|
|
372
|
+
continue
|
|
373
|
+
except SmartHumanCarConnectionError:
|
|
374
|
+
_LOGGER.debug("Got Human Car Connection Error, retry: %d", retry)
|
|
375
|
+
await self.select_active_vehicle(vin)
|
|
376
|
+
continue
|
|
377
|
+
break
|
|
378
|
+
return data
|
|
379
|
+
|
|
245
380
|
async def get_vehicle_soc(self, vin) -> str:
|
|
246
381
|
"""Get information about a vehicle."""
|
|
247
382
|
_LOGGER.debug("Getting vehicle SOC")
|
|
@@ -360,8 +495,15 @@ class SmartAccount:
|
|
|
360
495
|
break
|
|
361
496
|
return False
|
|
362
497
|
|
|
363
|
-
async def get_trip_journal(
|
|
364
|
-
|
|
498
|
+
async def get_trip_journal(
|
|
499
|
+
self,
|
|
500
|
+
vin,
|
|
501
|
+
page_size: int = 20,
|
|
502
|
+
window_days: int = 14,
|
|
503
|
+
raise_on_truncation: bool = False,
|
|
504
|
+
page_gap_seconds: float = 0.0,
|
|
505
|
+
) -> dict:
|
|
506
|
+
"""Fetch trip-journal entries for a vehicle, page-looping ``pageIndex=1..N``.
|
|
365
507
|
|
|
366
508
|
Hits ``/geelyTCAccess/tcservices/vehicle/status/journalLogV4/{vin}``
|
|
367
509
|
— the endpoint that carries server-side reverse-geocoded start/end
|
|
@@ -375,17 +517,142 @@ class SmartAccount:
|
|
|
375
517
|
is cached per-VIN per-access-token, so subsequent calls under the
|
|
376
518
|
same token skip the grant POST.
|
|
377
519
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
520
|
+
Page-loops ``pageIndex=2, 3, ...`` until **either** the
|
|
521
|
+
cloud-reported ``totleSize`` is reached **or** the previous page
|
|
522
|
+
came back short (the cloud's "no more pages" signal — fewer rows
|
|
523
|
+
than the requested ``page_size``). Accounts with more trips in
|
|
524
|
+
the window than ``page_size`` no longer silently lose the tail.
|
|
525
|
+
|
|
526
|
+
A ``code=8153`` mid-loop is treated as end-of-data and breaks
|
|
527
|
+
the loop with whatever's accumulated; the first page's 8153
|
|
528
|
+
still propagates as :class:`httpx.HTTPStatusError` so callers
|
|
529
|
+
with their own benign-empty handling (e.g. :meth:`get_vehicles`)
|
|
530
|
+
keep working unchanged.
|
|
531
|
+
|
|
532
|
+
After the loop, if the accumulated count disagrees with the
|
|
533
|
+
cloud's ``totleSize``:
|
|
534
|
+
|
|
535
|
+
* with ``raise_on_truncation=False`` (default), logs a WARNING
|
|
536
|
+
with both numbers and returns the partial accumulated dict —
|
|
537
|
+
routine polls keep going so the next iteration can pick up
|
|
538
|
+
the missing trips.
|
|
539
|
+
* with ``raise_on_truncation=True``, raises
|
|
540
|
+
:class:`pysmarthashtag.models.JournalTruncationError` instead.
|
|
541
|
+
Used by backfill/archive callers where silent data loss is
|
|
542
|
+
worse than a hard failure that forces an operator to look.
|
|
543
|
+
|
|
544
|
+
``page_gap_seconds`` lets callers insert a polite-API sleep
|
|
545
|
+
between successive page fetches (default 0.0 — the SDK has no
|
|
546
|
+
opinion; consumers that want one set their own).
|
|
547
|
+
|
|
548
|
+
Returns a dict with the same shape as the cloud's first-page
|
|
549
|
+
response (``code`` / ``message`` / ``data``) but with
|
|
550
|
+
``data.list`` containing the merged entries from every page.
|
|
551
|
+
``data.pagination.totleSize`` carries the cloud's most recent
|
|
552
|
+
report. Returns an empty dict only when the page-1 request
|
|
553
|
+
itself fails server-side (preserved from the prior single-page
|
|
554
|
+
behaviour so a single flaky vehicle doesn't break the refresh).
|
|
381
555
|
"""
|
|
382
556
|
await self.grant_journal_authorization(vin)
|
|
383
557
|
_LOGGER.debug("Getting trip journal for vehicle")
|
|
384
558
|
end_ms = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)
|
|
385
559
|
start_ms = end_ms - window_days * 86400 * 1000
|
|
560
|
+
|
|
561
|
+
first = await self._fetch_journal_page(vin, 1, page_size, start_ms, end_ms)
|
|
562
|
+
if not first:
|
|
563
|
+
return {}
|
|
564
|
+
|
|
565
|
+
accumulated, total = _unwrap_journal_page(first)
|
|
566
|
+
if not accumulated:
|
|
567
|
+
return first
|
|
568
|
+
last_page_count = len(accumulated)
|
|
569
|
+
|
|
570
|
+
page_index = 2
|
|
571
|
+
while True:
|
|
572
|
+
# Stop conditions before issuing another request:
|
|
573
|
+
# - cloud-reported total has been reached/exceeded, OR
|
|
574
|
+
# - the previous page came back short (cloud's "no more
|
|
575
|
+
# pages" signal — fewer rows than requested).
|
|
576
|
+
if total is not None and len(accumulated) >= total:
|
|
577
|
+
break
|
|
578
|
+
if last_page_count < page_size:
|
|
579
|
+
break
|
|
580
|
+
|
|
581
|
+
if page_gap_seconds > 0:
|
|
582
|
+
await asyncio.sleep(page_gap_seconds)
|
|
583
|
+
|
|
584
|
+
try:
|
|
585
|
+
page = await self._fetch_journal_page(
|
|
586
|
+
vin, page_index, page_size, start_ms, end_ms
|
|
587
|
+
)
|
|
588
|
+
except httpx.HTTPStatusError as exc:
|
|
589
|
+
if _is_benign_empty(exc):
|
|
590
|
+
_LOGGER.debug(
|
|
591
|
+
"journalLogV4 returned 8153 mid-loop for %s at page %d; treating as end-of-data",
|
|
592
|
+
sanitize_log_data(vin),
|
|
593
|
+
page_index,
|
|
594
|
+
)
|
|
595
|
+
break
|
|
596
|
+
raise
|
|
597
|
+
|
|
598
|
+
page_items, page_total = _unwrap_journal_page(page)
|
|
599
|
+
if page_total is not None:
|
|
600
|
+
total = page_total
|
|
601
|
+
if not page_items:
|
|
602
|
+
break
|
|
603
|
+
accumulated.extend(page_items)
|
|
604
|
+
last_page_count = len(page_items)
|
|
605
|
+
page_index += 1
|
|
606
|
+
|
|
607
|
+
if total is not None and total != len(accumulated):
|
|
608
|
+
if raise_on_truncation:
|
|
609
|
+
raise JournalTruncationError(
|
|
610
|
+
f"page-loop for {sanitize_log_data(vin)} accumulated "
|
|
611
|
+
f"{len(accumulated)} items but cloud reported "
|
|
612
|
+
f"totleSize={total}; aborting rather than silently "
|
|
613
|
+
"returning an incomplete history"
|
|
614
|
+
)
|
|
615
|
+
_LOGGER.warning(
|
|
616
|
+
"Journal page-loop truncation for %s: accumulated %d items "
|
|
617
|
+
"but cloud reported totleSize=%d. Possible silent data loss — "
|
|
618
|
+
"investigate page-loop termination.",
|
|
619
|
+
sanitize_log_data(vin),
|
|
620
|
+
len(accumulated),
|
|
621
|
+
total,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Rebuild the response shape with the merged list and the
|
|
625
|
+
# final totleSize so consumers parsing ``data.list`` see all
|
|
626
|
+
# pages without needing to know there was a loop.
|
|
627
|
+
merged = dict(first)
|
|
628
|
+
merged_data = dict(merged.get("data") or {})
|
|
629
|
+
merged_data["list"] = accumulated
|
|
630
|
+
if total is not None:
|
|
631
|
+
merged_pagination = dict(merged_data.get("pagination") or {})
|
|
632
|
+
merged_pagination["totleSize"] = total
|
|
633
|
+
merged_data["pagination"] = merged_pagination
|
|
634
|
+
merged["data"] = merged_data
|
|
635
|
+
return merged
|
|
636
|
+
|
|
637
|
+
async def _fetch_journal_page(
|
|
638
|
+
self,
|
|
639
|
+
vin: str,
|
|
640
|
+
page_index: int,
|
|
641
|
+
page_size: int,
|
|
642
|
+
start_ms: int,
|
|
643
|
+
end_ms: int,
|
|
644
|
+
) -> dict:
|
|
645
|
+
"""Fetch a single page of journalLogV4 for ``vin``.
|
|
646
|
+
|
|
647
|
+
Mirrors the request construction the previous single-page
|
|
648
|
+
:meth:`get_trip_journal` did, parameterised by ``page_index``.
|
|
649
|
+
The auth-grant POST is NOT issued here — the caller does it
|
|
650
|
+
once before the loop, since the cache short-circuits subsequent
|
|
651
|
+
fetches under the same token anyway.
|
|
652
|
+
"""
|
|
386
653
|
params = {
|
|
387
654
|
"endTime": str(end_ms),
|
|
388
|
-
"pageIndex":
|
|
655
|
+
"pageIndex": str(page_index),
|
|
389
656
|
"pageSize": str(page_size),
|
|
390
657
|
"startTime": str(start_ms),
|
|
391
658
|
"userId": str(self.config.authentication.api_user_id),
|
|
@@ -396,7 +663,7 @@ class SmartAccount:
|
|
|
396
663
|
async with SmartClient(self.config) as client:
|
|
397
664
|
for retry in range(3):
|
|
398
665
|
try:
|
|
399
|
-
|
|
666
|
+
response = await client.get(
|
|
400
667
|
self.vehicles[vin].base_url + url,
|
|
401
668
|
headers={
|
|
402
669
|
**utils.generate_default_header(
|
|
@@ -408,8 +675,8 @@ class SmartAccount:
|
|
|
408
675
|
)
|
|
409
676
|
},
|
|
410
677
|
)
|
|
411
|
-
_LOGGER.debug("Got response %d",
|
|
412
|
-
data =
|
|
678
|
+
_LOGGER.debug("Got response %d", response.status_code)
|
|
679
|
+
data = response.json()
|
|
413
680
|
except SmartTokenRefreshNecessary:
|
|
414
681
|
_LOGGER.debug("Got Token Error, retry: %d", retry)
|
|
415
682
|
continue
|
|
@@ -420,6 +687,103 @@ class SmartAccount:
|
|
|
420
687
|
break
|
|
421
688
|
return data
|
|
422
689
|
|
|
690
|
+
async def get_trip_trackpoints(
|
|
691
|
+
self,
|
|
692
|
+
vin: str,
|
|
693
|
+
start_time_ms: int,
|
|
694
|
+
end_time_ms: int,
|
|
695
|
+
page_size: int = TRIP_TRACKPOINTS_PAGE_SIZE,
|
|
696
|
+
) -> TripTrackpoints:
|
|
697
|
+
"""Fetch the per-trip GPS trackpoints for one finished trip.
|
|
698
|
+
|
|
699
|
+
Hits ``/vehicle-history-service/journal-service/vehicle/status/history/{vin}``
|
|
700
|
+
on the same host as journalLogV4, with the ``(startTime, endTime)``
|
|
701
|
+
window identifying the trip (the cloud has no separate trip-id
|
|
702
|
+
for this endpoint — the time window IS the identifier).
|
|
703
|
+
|
|
704
|
+
Unlike :meth:`get_trip_journal`, this endpoint does NOT require
|
|
705
|
+
:meth:`grant_journal_authorization` — calling that defensively
|
|
706
|
+
before each fetch would burn an extra cloud round-trip per trip.
|
|
707
|
+
The minimum required flow is auth (already done by
|
|
708
|
+
:meth:`get_vehicles` at session startup) plus the single signed
|
|
709
|
+
``GET`` to ``/.../history/{VIN}``.
|
|
710
|
+
|
|
711
|
+
Cloud direction is ``desc`` (newest-first); the returned
|
|
712
|
+
:attr:`TripTrackpoints.points` are reversed to chronological
|
|
713
|
+
order so callers don't need to know the wire format.
|
|
714
|
+
|
|
715
|
+
Three benign-empty cloud responses all map to
|
|
716
|
+
``TripTrackpoints(points=[], total_size=0)``:
|
|
717
|
+
|
|
718
|
+
* ``code: "1000"`` with ``data.list = []``
|
|
719
|
+
* ``code: "1000"`` with ``data: null``
|
|
720
|
+
* ``code: "8153"`` ("data unavailable" — surfaces as
|
|
721
|
+
:class:`httpx.HTTPStatusError` from the SDK)
|
|
722
|
+
|
|
723
|
+
Other ``HTTPStatusError`` exceptions (cloud 5xx, network,
|
|
724
|
+
non-1402 auth failure) propagate up so the caller can decide
|
|
725
|
+
whether to retry or surface the error.
|
|
726
|
+
|
|
727
|
+
Pagination is intentionally NOT implemented: the cloud's own
|
|
728
|
+
``pagination`` block reports ``pageSize=500`` as the cap, and in
|
|
729
|
+
observed responses ``totleSize`` never exceeded it. If a future
|
|
730
|
+
trip ever does, this method logs a WARNING so we know to revisit.
|
|
731
|
+
"""
|
|
732
|
+
_LOGGER.debug("Getting trip trackpoints for vehicle")
|
|
733
|
+
params = {
|
|
734
|
+
"endTime": str(end_time_ms),
|
|
735
|
+
"pageIndex": "0",
|
|
736
|
+
"pageSize": str(page_size),
|
|
737
|
+
"source": "tc",
|
|
738
|
+
"startTime": str(start_time_ms),
|
|
739
|
+
}
|
|
740
|
+
path = TRIP_TRACKPOINTS_PATH_PREFIX + vin
|
|
741
|
+
url = path + "?" + utils.join_url_params(params)
|
|
742
|
+
async with SmartClient(self.config) as client:
|
|
743
|
+
for retry in range(3):
|
|
744
|
+
try:
|
|
745
|
+
response = await client.get(
|
|
746
|
+
self.vehicles[vin].base_url + url,
|
|
747
|
+
headers={
|
|
748
|
+
**utils.generate_default_header(
|
|
749
|
+
client.config.authentication.device_id,
|
|
750
|
+
client.config.authentication.api_access_token,
|
|
751
|
+
params=params,
|
|
752
|
+
method="GET",
|
|
753
|
+
url=path,
|
|
754
|
+
)
|
|
755
|
+
},
|
|
756
|
+
)
|
|
757
|
+
_LOGGER.debug("Got response %d", response.status_code)
|
|
758
|
+
body = response.json()
|
|
759
|
+
except SmartTokenRefreshNecessary:
|
|
760
|
+
_LOGGER.debug("Got Token Error, retry: %d", retry)
|
|
761
|
+
continue
|
|
762
|
+
except SmartHumanCarConnectionError:
|
|
763
|
+
_LOGGER.debug("Got Human Car Connection Error, retry: %d", retry)
|
|
764
|
+
await self.select_active_vehicle(vin)
|
|
765
|
+
continue
|
|
766
|
+
except httpx.HTTPStatusError as exc:
|
|
767
|
+
if _is_benign_empty(exc):
|
|
768
|
+
_LOGGER.debug(
|
|
769
|
+
"trackpoints endpoint returned 8153 (data unavailable) for %s; treating as empty",
|
|
770
|
+
sanitize_log_data(vin),
|
|
771
|
+
)
|
|
772
|
+
return TripTrackpoints(points=[], total_size=0)
|
|
773
|
+
raise
|
|
774
|
+
break
|
|
775
|
+
trackpoints = parse_trackpoints_response(body)
|
|
776
|
+
if trackpoints.total_size > page_size:
|
|
777
|
+
_LOGGER.warning(
|
|
778
|
+
"Trackpoints page-size cap exceeded for %s: totleSize=%d > pageSize=%d. "
|
|
779
|
+
"Wrapper does not loop pageIndex; returned points are the head only. "
|
|
780
|
+
"Add pagination if this fires for real trips.",
|
|
781
|
+
sanitize_log_data(vin),
|
|
782
|
+
trackpoints.total_size,
|
|
783
|
+
page_size,
|
|
784
|
+
)
|
|
785
|
+
return trackpoints
|
|
786
|
+
|
|
423
787
|
async def get_vehicle_ota_info(self, vin) -> dict:
|
|
424
788
|
"""Get information about a vehicle from OTA server."""
|
|
425
789
|
_LOGGER.debug("Getting OTA information for vehicle")
|
|
@@ -96,6 +96,19 @@ class SmartRemoteServiceError(SmartAPIError):
|
|
|
96
96
|
"""Error when executing web services."""
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
class JournalTruncationError(SmartAPIError):
|
|
100
|
+
"""Page-loop accumulated count disagreed with the cloud-reported total.
|
|
101
|
+
|
|
102
|
+
Raised by :meth:`pysmarthashtag.account.SmartAccount.get_trip_journal`
|
|
103
|
+
when ``raise_on_truncation=True`` and the merged page-loop ended with
|
|
104
|
+
``len(data.list) != totleSize``. The default of ``raise_on_truncation
|
|
105
|
+
=False`` instead logs a WARNING — routine polls keep going so the
|
|
106
|
+
next iteration can pick up the missing trips. Backfill / archive
|
|
107
|
+
callers can opt into the hard failure: a backfill that silently lost
|
|
108
|
+
data is worse than one that aborts and forces investigation.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
99
112
|
def get_element_from_dict_maybe(
|
|
100
113
|
data: dict, *path: str, default: "Any|None" = None
|
|
101
114
|
) -> Optional[Union[dict, str, int, float]]:
|
|
@@ -107,6 +107,22 @@ class SmartMockRouter(respx.MockRouter):
|
|
|
107
107
|
200,
|
|
108
108
|
json=load_response(RESPONSE_DIR / "soc_90.json"),
|
|
109
109
|
)
|
|
110
|
+
# Per-trip GPS-trackpoints endpoint takes
|
|
111
|
+
# endTime/startTime/pageIndex/pageSize/source query params.
|
|
112
|
+
# Match by regex prefix so the route fires regardless of
|
|
113
|
+
# param values.
|
|
114
|
+
self.get(re.compile(re.escape(
|
|
115
|
+
base_url + "/vehicle-history-service/journal-service/vehicle/status/history/TestVIN0000000001"
|
|
116
|
+
) + r".*")).respond(
|
|
117
|
+
200,
|
|
118
|
+
json=load_response(RESPONSE_DIR / "trackpoints_response.json"),
|
|
119
|
+
)
|
|
120
|
+
self.get(re.compile(re.escape(
|
|
121
|
+
base_url + "/vehicle-history-service/journal-service/vehicle/status/history/TestVIN0000000002"
|
|
122
|
+
) + r".*")).respond(
|
|
123
|
+
200,
|
|
124
|
+
json=load_response(RESPONSE_DIR / "trackpoints_response.json"),
|
|
125
|
+
)
|
|
110
126
|
# journalLogV4 takes endTime/startTime/pageIndex/pageSize/userId
|
|
111
127
|
# query params; respx matches by URL prefix without query when
|
|
112
128
|
# we use route() with `host=` + `path__startswith=`. Keep
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"code": "1000",
|
|
3
|
+
"message": null,
|
|
4
|
+
"data": {
|
|
5
|
+
"pagination": {
|
|
6
|
+
"pageIndex": 1,
|
|
7
|
+
"sortField": "startTime",
|
|
8
|
+
"start": 1,
|
|
9
|
+
"pageSize": 500,
|
|
10
|
+
"totleSize": 4,
|
|
11
|
+
"direction": "desc"
|
|
12
|
+
},
|
|
13
|
+
"list": [
|
|
14
|
+
{"basicVehicleStatus": {"position": {"latitude": 217471769, "longitude": 80503026}}},
|
|
15
|
+
{"basicVehicleStatus": {"position": {"latitude": 217470945, "longitude": 80502024}}},
|
|
16
|
+
{"basicVehicleStatus": {"position": {"latitude": 217469078, "longitude": 80504125}}},
|
|
17
|
+
{"basicVehicleStatus": {"position": {"latitude": 217468000, "longitude": 80505500}}}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|