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.
Files changed (91) hide show
  1. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.github/workflows/python-package.yml +2 -0
  2. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.github/workflows/python-publish.yml +2 -0
  3. {pysmarthashtag-0.9.2/pySmartHashtag.egg-info → pysmarthashtag-0.11.0}/PKG-INFO +1 -1
  4. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0/pySmartHashtag.egg-info}/PKG-INFO +1 -1
  5. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/SOURCES.txt +7 -1
  6. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/account.py +374 -10
  7. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/models.py +13 -0
  8. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/common.py +16 -0
  9. pysmarthashtag-0.11.0/pysmarthashtag/tests/replys/trackpoints_response.json +20 -0
  10. pysmarthashtag-0.11.0/pysmarthashtag/tests/test_journal_pagination.py +289 -0
  11. pysmarthashtag-0.11.0/pysmarthashtag/tests/test_trackpoints.py +256 -0
  12. pysmarthashtag-0.11.0/pysmarthashtag/tests/test_vehicle_state.py +105 -0
  13. pysmarthashtag-0.11.0/pysmarthashtag/vehicle/trackpoints.py +131 -0
  14. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/vehicle.py +10 -0
  15. pysmarthashtag-0.11.0/pysmarthashtag/vehicle/vehicle_state.py +169 -0
  16. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.devcontainer.json +0 -0
  17. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.github/copilot-instructions.md +0 -0
  18. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.github/dependabot.yml +0 -0
  19. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.gitignore +0 -0
  20. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.pre-commit-config.yaml +0 -0
  21. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.vscode/launch.json +0 -0
  22. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/.vscode/settings.json +0 -0
  23. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/AUTHORS +0 -0
  24. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/CODE_OF_CONDUCT.md +0 -0
  25. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/CONTRIBUTING.md +0 -0
  26. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/ChangeLog +0 -0
  27. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/LICENSE +0 -0
  28. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/README.md +0 -0
  29. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/dependency_links.txt +0 -0
  30. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/requires.txt +0 -0
  31. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pySmartHashtag.egg-info/top_level.txt +0 -0
  32. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pyproject.toml +0 -0
  33. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/__init__.py +0 -0
  34. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/__init__.py +0 -0
  35. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/authentication.py +0 -0
  36. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/client.py +0 -0
  37. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/log_sanitizer.py +0 -0
  38. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/ssl_context.py +0 -0
  39. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/api/utils.py +0 -0
  40. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/cli.py +0 -0
  41. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/const.py +0 -0
  42. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/control/charging.py +0 -0
  43. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/control/climate.py +0 -0
  44. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/control/journal.py +0 -0
  45. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/__init__.py +0 -0
  46. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/conftest.py +0 -0
  47. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/Human_and_vehicle_relationship_does_not_exist.json +0 -0
  48. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/api_access.json +0 -0
  49. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/auth_context.url +0 -0
  50. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/auth_intermediate.url +0 -0
  51. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/auth_result.url +0 -0
  52. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/charging_success.json +0 -0
  53. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/climate_success.json +0 -0
  54. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/journal_response.json +0 -0
  55. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/journal_toggle_success.json +0 -0
  56. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/login_result.json +0 -0
  57. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/ota_response.json +0 -0
  58. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/soc_80.json +0 -0
  59. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/soc_90.json +0 -0
  60. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/token_expired.json +0 -0
  61. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_info.json +0 -0
  62. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_info2.json +0 -0
  63. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_info_dc_charging.json +0 -0
  64. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_response.json +0 -0
  65. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/replys/vehicle_result.json +0 -0
  66. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_account.py +0 -0
  67. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_actions.py +0 -0
  68. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_authentication_backoff.py +0 -0
  69. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_charging.py +0 -0
  70. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_dc_charging.py +0 -0
  71. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_endpoint_urls.py +0 -0
  72. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_journal.py +0 -0
  73. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_journal_control.py +0 -0
  74. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_log_sanitizer.py +0 -0
  75. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_missing_fields.py +0 -0
  76. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/tests/test_ssl_context.py +0 -0
  77. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/__init__.py +0 -0
  78. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/battery.py +0 -0
  79. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/climate.py +0 -0
  80. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/journal.py +0 -0
  81. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/maintenance.py +0 -0
  82. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/position.py +0 -0
  83. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/running.py +0 -0
  84. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/safety.py +0 -0
  85. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/pysmarthashtag/vehicle/tires.py +0 -0
  86. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/requirements-cli.txt +0 -0
  87. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/requirements-test.txt +0 -0
  88. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/requirements.txt +0 -0
  89. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/run.sh +0 -0
  90. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/setup.cfg +0 -0
  91. {pysmarthashtag-0.9.2 → pysmarthashtag-0.11.0}/setup.py +0 -0
@@ -24,6 +24,8 @@ jobs:
24
24
 
25
25
  steps:
26
26
  - uses: actions/checkout@v4.1.1
27
+ with:
28
+ fetch-depth: 0
27
29
  - name: Set up Python ${{ matrix.python-version }}
28
30
  uses: actions/setup-python@v5
29
31
  with:
@@ -21,6 +21,8 @@ jobs:
21
21
 
22
22
  steps:
23
23
  - uses: actions/checkout@v4.1.1
24
+ with:
25
+ fetch-depth: 0
24
26
  - name: Set up Python
25
27
  uses: actions/setup-python@v5
26
28
  with:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pySmartHashtag
3
- Version: 0.9.2
3
+ Version: 0.11.0
4
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pySmartHashtag
3
- Version: 0.9.2
3
+ Version: 0.11.0
4
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
@@ -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/vehicle.py
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 SmartAuthError, SmartHumanCarConnectionError, SmartTokenRefreshNecessary
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(self, vin, page_size: int = 20, window_days: int = 14) -> dict:
364
- """Fetch the most recent trip-journal entries for a vehicle.
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
- Returns the raw response dict (with ``code``/``message``/``data``)
379
- or an empty dict when the request fails server-side; raising is
380
- avoided so a single flaky vehicle doesn't break the whole refresh.
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": "1",
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
- r_journal = await client.get(
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", r_journal.status_code)
412
- data = r_journal.json()
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
+ }