pySmartHashtag 0.12.2__tar.gz → 0.13.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 (102) hide show
  1. {pysmarthashtag-0.12.2/pySmartHashtag.egg-info → pysmarthashtag-0.13.0}/PKG-INFO +1 -1
  2. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0/pySmartHashtag.egg-info}/PKG-INFO +1 -1
  3. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/SOURCES.txt +4 -0
  4. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/scm_file_list.json +4 -0
  5. pysmarthashtag-0.13.0/pySmartHashtag.egg-info/scm_version.json +8 -0
  6. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/account.py +18 -1
  7. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/cli.py +36 -4
  8. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/control/climate.py +27 -2
  9. pysmarthashtag-0.13.0/pysmarthashtag/control/lock.py +128 -0
  10. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_actions.py +59 -0
  11. pysmarthashtag-0.13.0/pysmarthashtag/tests/test_cli.py +44 -0
  12. pysmarthashtag-0.13.0/pysmarthashtag/tests/test_lock_control.py +105 -0
  13. pysmarthashtag-0.13.0/pysmarthashtag/tests/test_ota_info.py +73 -0
  14. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/vehicle.py +7 -0
  15. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/requirements-test.txt +2 -2
  16. pysmarthashtag-0.12.2/pySmartHashtag.egg-info/scm_version.json +0 -8
  17. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.devcontainer.json +0 -0
  18. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/copilot-instructions.md +0 -0
  19. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/dependabot.yml +0 -0
  20. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/workflows/python-package.yml +0 -0
  21. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/workflows/python-publish.yml +0 -0
  22. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.gitignore +0 -0
  23. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.pre-commit-config.yaml +0 -0
  24. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.vscode/launch.json +0 -0
  25. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.vscode/settings.json +0 -0
  26. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/AUTHORS +0 -0
  27. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/CODE_OF_CONDUCT.md +0 -0
  28. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/CONTRIBUTING.md +0 -0
  29. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/ChangeLog +0 -0
  30. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/LICENSE +0 -0
  31. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/README.md +0 -0
  32. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/dependency_links.txt +0 -0
  33. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/requires.txt +0 -0
  34. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/top_level.txt +0 -0
  35. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pyproject.toml +0 -0
  36. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/__init__.py +0 -0
  37. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/__init__.py +0 -0
  38. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/authentication.py +0 -0
  39. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/client.py +0 -0
  40. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/log_sanitizer.py +0 -0
  41. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/ssl_context.py +0 -0
  42. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/utils.py +0 -0
  43. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/const.py +0 -0
  44. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/control/charging.py +0 -0
  45. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/control/journal.py +0 -0
  46. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/models.py +0 -0
  47. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/__init__.py +0 -0
  48. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/common.py +0 -0
  49. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/conftest.py +0 -0
  50. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/Human_and_vehicle_relationship_does_not_exist.json +0 -0
  51. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/api_access.json +0 -0
  52. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_context.url +0 -0
  53. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_intermediate.url +0 -0
  54. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_result.url +0 -0
  55. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/charging_success.json +0 -0
  56. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/climate_success.json +0 -0
  57. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/journal_response.json +0 -0
  58. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/journal_toggle_success.json +0 -0
  59. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/login_result.json +0 -0
  60. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/ota_response.json +0 -0
  61. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/soc_80.json +0 -0
  62. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/soc_90.json +0 -0
  63. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/token_expired.json +0 -0
  64. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/trackpoints_response.json +0 -0
  65. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info.json +0 -0
  66. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info2.json +0 -0
  67. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info_dc_charging.json +0 -0
  68. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_response.json +0 -0
  69. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_result.json +0 -0
  70. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_account.py +0 -0
  71. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_authentication_backoff.py +0 -0
  72. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_charging.py +0 -0
  73. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_charging_states.py +0 -0
  74. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_dc_charging.py +0 -0
  75. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_endpoint_urls.py +0 -0
  76. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal.py +0 -0
  77. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal_control.py +0 -0
  78. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal_pagination.py +0 -0
  79. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_log_sanitizer.py +0 -0
  80. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_missing_fields.py +0 -0
  81. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_ssl_context.py +0 -0
  82. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_token_lifecycle.py +0 -0
  83. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_tracked_vins.py +0 -0
  84. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_trackpoints.py +0 -0
  85. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_vehicle_state.py +0 -0
  86. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_vin_headers.py +0 -0
  87. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/__init__.py +0 -0
  88. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/battery.py +0 -0
  89. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/climate.py +0 -0
  90. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/journal.py +0 -0
  91. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/maintenance.py +0 -0
  92. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/position.py +0 -0
  93. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/running.py +0 -0
  94. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/safety.py +0 -0
  95. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/tires.py +0 -0
  96. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/trackpoints.py +0 -0
  97. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/vehicle_state.py +0 -0
  98. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/requirements-cli.txt +0 -0
  99. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/requirements.txt +0 -0
  100. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/run.sh +0 -0
  101. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/setup.cfg +0 -0
  102. {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pySmartHashtag
3
- Version: 0.12.2
3
+ Version: 0.13.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.12.2
3
+ Version: 0.13.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
@@ -41,6 +41,7 @@ pysmarthashtag/api/utils.py
41
41
  pysmarthashtag/control/charging.py
42
42
  pysmarthashtag/control/climate.py
43
43
  pysmarthashtag/control/journal.py
44
+ pysmarthashtag/control/lock.py
44
45
  pysmarthashtag/tests/__init__.py
45
46
  pysmarthashtag/tests/common.py
46
47
  pysmarthashtag/tests/conftest.py
@@ -49,13 +50,16 @@ pysmarthashtag/tests/test_actions.py
49
50
  pysmarthashtag/tests/test_authentication_backoff.py
50
51
  pysmarthashtag/tests/test_charging.py
51
52
  pysmarthashtag/tests/test_charging_states.py
53
+ pysmarthashtag/tests/test_cli.py
52
54
  pysmarthashtag/tests/test_dc_charging.py
53
55
  pysmarthashtag/tests/test_endpoint_urls.py
54
56
  pysmarthashtag/tests/test_journal.py
55
57
  pysmarthashtag/tests/test_journal_control.py
56
58
  pysmarthashtag/tests/test_journal_pagination.py
59
+ pysmarthashtag/tests/test_lock_control.py
57
60
  pysmarthashtag/tests/test_log_sanitizer.py
58
61
  pysmarthashtag/tests/test_missing_fields.py
62
+ pysmarthashtag/tests/test_ota_info.py
59
63
  pysmarthashtag/tests/test_ssl_context.py
60
64
  pysmarthashtag/tests/test_token_lifecycle.py
61
65
  pysmarthashtag/tests/test_tracked_vins.py
@@ -29,6 +29,7 @@
29
29
  "pysmarthashtag/control/charging.py",
30
30
  "pysmarthashtag/control/climate.py",
31
31
  "pysmarthashtag/control/journal.py",
32
+ "pysmarthashtag/control/lock.py",
32
33
  "pysmarthashtag/models.py",
33
34
  "pysmarthashtag/tests/__init__.py",
34
35
  "pysmarthashtag/tests/common.py",
@@ -58,13 +59,16 @@
58
59
  "pysmarthashtag/tests/test_authentication_backoff.py",
59
60
  "pysmarthashtag/tests/test_charging.py",
60
61
  "pysmarthashtag/tests/test_charging_states.py",
62
+ "pysmarthashtag/tests/test_cli.py",
61
63
  "pysmarthashtag/tests/test_dc_charging.py",
62
64
  "pysmarthashtag/tests/test_endpoint_urls.py",
63
65
  "pysmarthashtag/tests/test_journal.py",
64
66
  "pysmarthashtag/tests/test_journal_control.py",
65
67
  "pysmarthashtag/tests/test_journal_pagination.py",
68
+ "pysmarthashtag/tests/test_lock_control.py",
66
69
  "pysmarthashtag/tests/test_log_sanitizer.py",
67
70
  "pysmarthashtag/tests/test_missing_fields.py",
71
+ "pysmarthashtag/tests/test_ota_info.py",
68
72
  "pysmarthashtag/tests/test_ssl_context.py",
69
73
  "pysmarthashtag/tests/test_token_lifecycle.py",
70
74
  "pysmarthashtag/tests/test_tracked_vins.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.13.0",
3
+ "distance": 0,
4
+ "node": "ge800cedef883c6b06cee7b097669761c4cd4b1dd",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-29"
8
+ }
@@ -50,6 +50,13 @@ VALID_UNTIL_OFFSET = datetime.timedelta(seconds=10)
50
50
  # blip mid-loop doesn't fail the whole poll.
51
51
  _BENIGN_EMPTY_CODE = "8153"
52
52
 
53
+ # Cloud's code on the OTA endpoint for vehicles it has no update record
54
+ # for (reported on Smart #5). It is a property of the OTA service, not of
55
+ # the session: refreshing the token does not clear it, so a poll loop that
56
+ # treats it as an unmapped code refreshes the session on every cycle for
57
+ # nothing. OTA data is optional, so callers normalise this to "no OTA info".
58
+ _OTA_NO_DATA_CODE = "1003"
59
+
53
60
  _LOGGER = logging.getLogger(__name__)
54
61
 
55
62
 
@@ -931,8 +938,18 @@ class SmartAccount:
931
938
  await self.select_active_vehicle(vin)
932
939
  continue
933
940
  except httpx.HTTPStatusError as exc:
934
- # Unmapped code: one refresh in case the session is stale, then surface.
935
941
  code = _cloud_code(exc)
942
+ if code == _OTA_NO_DATA_CODE:
943
+ # The OTA service has nothing to report for this VIN.
944
+ # Optional data, and no session remedy applies — return
945
+ # empty rather than burning a refresh on every poll.
946
+ _LOGGER.debug(
947
+ "OTA endpoint returned cloud code %s for %s; no OTA info available",
948
+ code,
949
+ sanitize_log_data(vin),
950
+ )
951
+ return {}
952
+ # Unmapped code: one refresh in case the session is stale, then surface.
936
953
  if code is None or refreshed_unmapped:
937
954
  raise
938
955
  refreshed_unmapped = True
@@ -6,6 +6,7 @@ import asyncio
6
6
  import logging.config
7
7
  import os
8
8
  import time
9
+ from typing import Optional
9
10
 
10
11
  from pysmarthashtag.account import SmartAccount
11
12
  from pysmarthashtag.control.climate import HeatingLocation
@@ -73,6 +74,10 @@ def main_parser() -> argparse.ArgumentParser:
73
74
  seatheating_parser.add_argument("--temp", help="Temperature", default=22)
74
75
  seatheating_parser.add_argument("--active", help="Active", action="store_true")
75
76
 
77
+ defrost_parser = subparsers.add_parser("defrost", help="Set front windscreen defrost of vehicle.")
78
+ defrost_parser.add_argument("--vin", help="VIN of vehicle", default=None)
79
+ defrost_parser.add_argument("--active", help="Active", action="store_true")
80
+
76
81
  _add_default_args(parser)
77
82
  parser.set_defaults(func=parse_command)
78
83
 
@@ -91,6 +96,8 @@ async def parse_command(args) -> None:
91
96
  await set_climate(args)
92
97
  elif args.command == "seatheating":
93
98
  await set_seatheating(args)
99
+ elif args.command == "defrost":
100
+ await set_defrost(args)
94
101
  else:
95
102
  raise NotImplementedError(f"Command {args.command} not implemented.")
96
103
 
@@ -127,12 +134,27 @@ async def watch_car(args) -> None:
127
134
  time.sleep(args.i)
128
135
 
129
136
 
137
+ def _select_vin(account: SmartAccount, vin: Optional[str]) -> str:
138
+ """Return the VIN to control, defaulting to the first vehicle of the account.
139
+
140
+ Raises SystemExit with an actionable message if the account has no vehicles
141
+ or the requested VIN does not belong to the account.
142
+ """
143
+ vehicles = account.vehicles or {}
144
+ if not vehicles:
145
+ raise SystemExit("No vehicles found for this account; nothing to control.")
146
+ if not vin:
147
+ return next(iter(vehicles))
148
+ if vin not in vehicles:
149
+ raise SystemExit(f"VIN {vin} not found in this account. Available VINs: {', '.join(vehicles)}")
150
+ return vin
151
+
152
+
130
153
  async def set_climate(args) -> None:
131
154
  """Set climate of vehicle."""
132
155
  account = SmartAccount(args.username, args.password)
133
156
  await account.get_vehicles()
134
- if not args.vin:
135
- args.vin = list(account.vehicles.keys())[0]
157
+ args.vin = _select_vin(account, args.vin)
136
158
  await account.get_vehicle_information(args.vin)
137
159
 
138
160
  climate_ctrl = account.vehicles[args.vin].climate_control
@@ -143,8 +165,7 @@ async def set_seatheating(args) -> None:
143
165
  """Set heating of driver's seat in vehicle."""
144
166
  account = SmartAccount(args.username, args.password)
145
167
  await account.get_vehicles()
146
- if not args.vin:
147
- args.vin = list(account.vehicles.keys())[0]
168
+ args.vin = _select_vin(account, args.vin)
148
169
  await account.get_vehicle_information(args.vin)
149
170
 
150
171
  climate_ctrl = account.vehicles[args.vin].climate_control
@@ -152,6 +173,17 @@ async def set_seatheating(args) -> None:
152
173
  await climate_ctrl.set_climate_conditioning(args.temp, args.active)
153
174
 
154
175
 
176
+ async def set_defrost(args) -> None:
177
+ """Set front windscreen defrost of vehicle."""
178
+ account = SmartAccount(args.username, args.password)
179
+ await account.get_vehicles()
180
+ args.vin = _select_vin(account, args.vin)
181
+ await account.get_vehicle_information(args.vin)
182
+
183
+ climate_ctrl = account.vehicles[args.vin].climate_control
184
+ await climate_ctrl.set_defrost(args.active)
185
+
186
+
155
187
  def _add_default_args(parser: argparse.ArgumentParser):
156
188
  """Add the default arguments username, password to the parser."""
157
189
  parser.add_argument("--username", help="Smart username", **environ_or_required("SMART_USERNAME"))
@@ -42,6 +42,7 @@ class ClimateControll:
42
42
  },
43
43
  "serviceId": "RCE_2",
44
44
  }
45
+ DEFROST_DURATION = 90
45
46
 
46
47
  def __init__(self, account: SmartAccount, vin: str):
47
48
  """Initialize the vehicle."""
@@ -101,14 +102,38 @@ class ClimateControll:
101
102
  raise ValueError("Temperature must be between 16 and 30 degrees.")
102
103
  self.conditioning_temp = float(temp)
103
104
 
105
+ _LOGGER.debug("Setting climate conditioning: active=%s, temp=%.1f", active, self.conditioning_temp)
106
+ return await self._send_rce_command(self._get_payload(active))
107
+
108
+ def _get_defrost_payload(self, active: bool) -> str:
109
+ _payload = self.BASE_PAYLOAD_TEMPLATE.copy()
110
+ _payload["command"] = "start" if active else "stop"
111
+ _payload["timestamp"] = utils.create_correct_timestamp()
112
+ _payload["operationScheduling"] = {
113
+ **self.BASE_PAYLOAD_TEMPLATE["operationScheduling"],
114
+ "duration": self.DEFROST_DURATION if active else 0,
115
+ }
116
+ _payload["serviceParameters"] = [
117
+ {"key": "rce.conditioner", "value": "2"},
118
+ {"key": "rce.level", "value": "2"},
119
+ ]
120
+ return json.dumps(_payload).replace(" ", "")
121
+
122
+ async def set_defrost(self, active: bool) -> bool:
123
+ """Start or stop the front windscreen defrost."""
124
+ if not isinstance(active, bool):
125
+ raise TypeError("Defrost state must be a boolean")
126
+ _LOGGER.debug("Setting front windscreen defrost: active=%s", active)
127
+ return await self._send_rce_command(self._get_defrost_payload(active))
128
+
129
+ async def _send_rce_command(self, params: str) -> bool:
130
+ """Send a remote climate command to the vehicle."""
104
131
  # Ensure SSL context is created before using the client
105
132
  await self.account._ensure_ssl_context()
106
133
 
107
134
  await self.account.select_active_vehicle(self.vin)
108
135
 
109
136
  async with SmartClient(self.config) as client:
110
- params = self._get_payload(active)
111
- _LOGGER.debug("Setting climate conditioning: active=%s, temp=%.1f", active, self.conditioning_temp)
112
137
  for retry in range(3):
113
138
  try:
114
139
  vehicles_response = await client.put(
@@ -0,0 +1,128 @@
1
+ """Provides an accessible control of the vehicle's door locks."""
2
+
3
+ import json
4
+ import logging
5
+
6
+ from pysmarthashtag.account import SmartAccount
7
+ from pysmarthashtag.api import utils
8
+ from pysmarthashtag.api.client import SmartClient
9
+ from pysmarthashtag.const import API_TELEMATICS_URL
10
+ from pysmarthashtag.models import (
11
+ SmartHumanCarConnectionError,
12
+ SmartTokenRefreshNecessary,
13
+ SmartVehicleNotInUseError,
14
+ )
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ class DoorLockControl:
20
+ """Provides an accessible control of the vehicle's door locks.
21
+
22
+ Uses the remote door lock (RDL_2) and remote door unlock (RDU_2)
23
+ telematics services.
24
+ """
25
+
26
+ LOCK_SERVICE_ID = "RDL_2"
27
+ UNLOCK_SERVICE_ID = "RDU_2"
28
+
29
+ def __init__(self, account: SmartAccount, vin: str):
30
+ """Initialize the door lock control.
31
+
32
+ Args:
33
+ ----
34
+ account: The Smart account instance
35
+ vin: Vehicle identification number
36
+
37
+ """
38
+ self.account = account
39
+ self.config = account.config
40
+ self.vin = vin
41
+
42
+ def _get_payload(self, service_id: str, parameter: dict[str, str]) -> str:
43
+ """Create the payload for a door lock command.
44
+
45
+ Args:
46
+ ----
47
+ service_id: The telematics service to call (RDL_2 or RDU_2)
48
+ parameter: The service parameter selecting what to (un)lock
49
+
50
+ Returns:
51
+ -------
52
+ JSON string payload for the API request
53
+
54
+ """
55
+ _payload = {
56
+ "creator": "tc",
57
+ "command": "start",
58
+ "operationScheduling": {
59
+ "duration": 6,
60
+ "interval": 0,
61
+ "occurs": 1,
62
+ "recurrentOperation": False,
63
+ },
64
+ "serviceId": service_id,
65
+ "timestamp": utils.create_correct_timestamp(),
66
+ "serviceParameters": [parameter],
67
+ }
68
+ return json.dumps(_payload).replace(" ", "")
69
+
70
+ async def lock(self) -> bool:
71
+ """Lock all doors of the vehicle.
72
+
73
+ Returns
74
+ -------
75
+ True if the command was accepted, False otherwise
76
+
77
+ """
78
+ _LOGGER.debug("Locking all doors")
79
+ return await self._send_command(self._get_payload(self.LOCK_SERVICE_ID, {"key": "door", "value": "all"}))
80
+
81
+ async def unlock(self) -> bool:
82
+ """Unlock all doors of the vehicle.
83
+
84
+ Returns
85
+ -------
86
+ True if the command was accepted, False otherwise
87
+
88
+ """
89
+ _LOGGER.debug("Unlocking all doors")
90
+ return await self._send_command(self._get_payload(self.UNLOCK_SERVICE_ID, {"key": "door", "value": "all"}))
91
+
92
+ async def _send_command(self, params: str) -> bool:
93
+ """Send a door lock command to the vehicle."""
94
+ # Ensure SSL context is created before using the client
95
+ await self.account._ensure_ssl_context()
96
+
97
+ await self.account.select_active_vehicle(self.vin)
98
+
99
+ async with SmartClient(self.config) as client:
100
+ for retry in range(3):
101
+ try:
102
+ response = await client.put(
103
+ self.account.vehicles[self.vin].base_url + API_TELEMATICS_URL + self.vin,
104
+ headers={
105
+ **utils.generate_default_header(
106
+ client.config.authentication.device_id,
107
+ client.config.authentication.api_access_token,
108
+ params={},
109
+ method="PUT",
110
+ url=API_TELEMATICS_URL + self.vin,
111
+ body=params,
112
+ vin=self.vin,
113
+ model_code=self.account._vin_model_code(self.vin),
114
+ )
115
+ },
116
+ content=params.encode("utf-8"),
117
+ )
118
+ api_result = response.json()
119
+ return api_result["success"]
120
+ except SmartTokenRefreshNecessary:
121
+ _LOGGER.debug("Session token expired; refreshing (retry %d)", retry)
122
+ await self.config.authentication.refresh()
123
+ continue
124
+ except (SmartHumanCarConnectionError, SmartVehicleNotInUseError):
125
+ _LOGGER.debug("VIN binding lost (8006/4038); re-binding vehicle (retry %d)", retry)
126
+ await self.account.select_active_vehicle(self.vin)
127
+ continue
128
+ return False
@@ -1,3 +1,5 @@
1
+ import json
2
+
1
3
  import pytest
2
4
  import respx
3
5
 
@@ -72,3 +74,60 @@ async def test_enable_seatheating_invalid_level(smart_fixture: respx.Router):
72
74
  with pytest.raises(ValueError) as excinfo:
73
75
  climate_ctrl.set_heating_level(HeatingLocation.DRIVER_SEAT, 4)
74
76
  assert str(excinfo.value) == "Seat heating level must be between 0 and 3."
77
+
78
+
79
+ def _last_telematics_payload(router: respx.Router) -> dict:
80
+ """Return the JSON body of the most recent telematics PUT request."""
81
+ calls = [c for c in router.calls if c.request.method == "PUT" and "/vehicle/telematics/" in c.request.url.path]
82
+ assert calls, "No telematics request was sent"
83
+ return json.loads(calls[-1].request.content)
84
+
85
+
86
+ @pytest.mark.asyncio
87
+ @pytest.mark.parametrize(
88
+ ("active", "command", "duration"),
89
+ [(True, "start", 90), (False, "stop", 0)],
90
+ )
91
+ async def test_set_defrost(smart_fixture: respx.Router, active: bool, command: str, duration: int):
92
+ """Test that set_defrost sends the RCE_2 defrost command."""
93
+ account = await prepare_account_with_vehicles()
94
+ await account.get_vehicle_information("TestVIN0000000001")
95
+ climate_ctrl = account.vehicles["TestVIN0000000001"].climate_control
96
+
97
+ result = await climate_ctrl.set_defrost(active)
98
+
99
+ assert result
100
+ payload = _last_telematics_payload(smart_fixture)
101
+ assert payload["serviceId"] == "RCE_2"
102
+ assert payload["command"] == command
103
+ assert payload["operationScheduling"]["duration"] == duration
104
+ assert payload["serviceParameters"] == [
105
+ {"key": "rce.conditioner", "value": "2"},
106
+ {"key": "rce.level", "value": "2"},
107
+ ]
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_set_defrost_does_not_touch_conditioning_template(smart_fixture: respx.Router):
112
+ """Test that a defrost command leaves the climate conditioning payload unchanged."""
113
+ account = await prepare_account_with_vehicles()
114
+ await account.get_vehicle_information("TestVIN0000000001")
115
+ climate_ctrl = account.vehicles["TestVIN0000000001"].climate_control
116
+
117
+ await climate_ctrl.set_defrost(True)
118
+ await climate_ctrl.set_climate_conditioning(20, True)
119
+
120
+ payload = _last_telematics_payload(smart_fixture)
121
+ assert payload["operationScheduling"]["duration"] == 180
122
+ assert {"key": "rce.conditioner", "value": "1"} in payload["serviceParameters"]
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_set_defrost_invalid_state(smart_fixture: respx.Router):
127
+ """Test that set_defrost rejects non-boolean states."""
128
+ account = await prepare_account_with_vehicles()
129
+ await account.get_vehicle_information("TestVIN0000000001")
130
+ climate_ctrl = account.vehicles["TestVIN0000000001"].climate_control
131
+
132
+ with pytest.raises(TypeError):
133
+ await climate_ctrl.set_defrost("on")
@@ -0,0 +1,44 @@
1
+ """Tests for the command line interface helpers."""
2
+
3
+ import pytest
4
+ import respx
5
+
6
+ from pysmarthashtag.cli import _select_vin
7
+ from pysmarthashtag.tests.conftest import prepare_account_with_vehicles
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_select_vin_defaults_to_first_vehicle(smart_fixture: respx.Router):
12
+ """Test that the first vehicle is selected when no VIN is given."""
13
+ account = await prepare_account_with_vehicles()
14
+
15
+ assert _select_vin(account, None) == next(iter(account.vehicles))
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_select_vin_accepts_known_vin(smart_fixture: respx.Router):
20
+ """Test that a VIN belonging to the account is returned unchanged."""
21
+ account = await prepare_account_with_vehicles()
22
+
23
+ assert _select_vin(account, "TestVIN0000000002") == "TestVIN0000000002"
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_select_vin_rejects_unknown_vin(smart_fixture: respx.Router):
28
+ """Test that a VIN not in the account is rejected with the available VINs."""
29
+ account = await prepare_account_with_vehicles()
30
+
31
+ with pytest.raises(SystemExit, match="VIN UnknownVIN not found in this account") as excinfo:
32
+ _select_vin(account, "UnknownVIN")
33
+ assert "TestVIN0000000001" in str(excinfo.value)
34
+
35
+
36
+ @pytest.mark.parametrize("vin", [None, "TestVIN0000000001"])
37
+ def test_select_vin_rejects_account_without_vehicles(vin):
38
+ """Test that an account without vehicles is rejected."""
39
+
40
+ class _EmptyAccount:
41
+ vehicles: dict = {}
42
+
43
+ with pytest.raises(SystemExit, match="No vehicles found"):
44
+ _select_vin(_EmptyAccount(), vin)
@@ -0,0 +1,105 @@
1
+ """Tests for the remote door lock control."""
2
+
3
+ import json
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import httpx
7
+ import pytest
8
+ import respx
9
+
10
+ from pysmarthashtag.tests.conftest import prepare_account_with_vehicles
11
+
12
+
13
+ def _last_telematics_payload(router: respx.Router) -> dict:
14
+ """Return the JSON body of the most recent telematics PUT request."""
15
+ calls = [c for c in router.calls if c.request.method == "PUT" and "/vehicle/telematics/" in c.request.url.path]
16
+ assert calls, "No telematics request was sent"
17
+ return json.loads(calls[-1].request.content)
18
+
19
+
20
+ @pytest.mark.asyncio
21
+ @pytest.mark.parametrize(
22
+ ("method", "service_id", "parameter"),
23
+ [
24
+ ("lock", "RDL_2", {"key": "door", "value": "all"}),
25
+ ("unlock", "RDU_2", {"key": "door", "value": "all"}),
26
+ ],
27
+ )
28
+ async def test_door_lock_commands(smart_fixture: respx.Router, method: str, service_id: str, parameter: dict):
29
+ """Test that each lock command sends the matching telematics service."""
30
+ account = await prepare_account_with_vehicles()
31
+ await account.get_vehicle_information("TestVIN0000000001")
32
+ lock_ctrl = account.vehicles["TestVIN0000000001"].door_lock_control
33
+ assert lock_ctrl is not None
34
+
35
+ result = await getattr(lock_ctrl, method)()
36
+
37
+ assert result
38
+ payload = _last_telematics_payload(smart_fixture)
39
+ assert payload["serviceId"] == service_id
40
+ assert payload["command"] == "start"
41
+ assert payload["serviceParameters"] == [parameter]
42
+
43
+
44
+ TELEMATICS_URL = "https://api.ecloudeu.com/remote-control/vehicle/telematics/TestVIN0000000001"
45
+ SUCCESS = {"code": 1000, "success": True, "message": "operation succeed", "data": {}}
46
+
47
+
48
+ def _last_telematics_request(router: respx.Router):
49
+ calls = [c for c in router.calls if c.request.method == "PUT" and "/vehicle/telematics/" in c.request.url.path]
50
+ assert calls, "No telematics request was sent"
51
+ return calls[-1].request
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_door_lock_sends_vin_headers(smart_fixture: respx.Router):
56
+ """Test that lock commands are bound to the VIN instead of the active vehicle."""
57
+ account = await prepare_account_with_vehicles()
58
+ await account.get_vehicle_information("TestVIN0000000001")
59
+
60
+ await account.vehicles["TestVIN0000000001"].door_lock_control.lock()
61
+
62
+ request = _last_telematics_request(smart_fixture)
63
+ assert request.headers["X-Vehicle-IDENTIFIER"] == "TestVIN0000000001"
64
+ assert request.headers["X-VEHICLE-MODEL"] == "TestVIN0000000001"
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_door_lock_refreshes_token_before_retry(smart_fixture: respx.Router):
69
+ """Test that an expired session token is refreshed before retrying."""
70
+ account = await prepare_account_with_vehicles()
71
+ await account.get_vehicle_information("TestVIN0000000001")
72
+ smart_fixture.put(TELEMATICS_URL).mock(
73
+ side_effect=[
74
+ httpx.Response(200, json={"code": "1402", "message": "token expired"}),
75
+ httpx.Response(200, json=SUCCESS),
76
+ ]
77
+ )
78
+
79
+ with patch.object(account.config.authentication, "refresh", new=AsyncMock()) as refresh:
80
+ result = await account.vehicles["TestVIN0000000001"].door_lock_control.unlock()
81
+
82
+ assert result
83
+ refresh.assert_awaited_once()
84
+
85
+
86
+ @pytest.mark.asyncio
87
+ @pytest.mark.parametrize("code", ["8006", "4038"])
88
+ async def test_door_lock_rebinds_vehicle_before_retry(smart_fixture: respx.Router, code: str):
89
+ """Test that a lost vehicle binding is restored before retrying."""
90
+ account = await prepare_account_with_vehicles()
91
+ await account.get_vehicle_information("TestVIN0000000001")
92
+ smart_fixture.put(TELEMATICS_URL).mock(
93
+ side_effect=[
94
+ httpx.Response(200, json={"code": code, "message": "binding lost"}),
95
+ httpx.Response(200, json=SUCCESS),
96
+ ]
97
+ )
98
+
99
+ with patch.object(account, "select_active_vehicle", new=AsyncMock()) as select:
100
+ result = await account.vehicles["TestVIN0000000001"].door_lock_control.lock()
101
+
102
+ assert result
103
+ # Once before the first attempt, once more before the retry
104
+ assert select.await_count == 2
105
+ select.assert_awaited_with("TestVIN0000000001")
@@ -0,0 +1,73 @@
1
+ """Tests for OTA info fetching.
2
+
3
+ The OTA endpoint is optional data, so its failures must never cost the
4
+ session. In particular cloud code 1003 ("no OTA record for this VIN",
5
+ observed on Smart #5) used to fall into the unmapped-code branch, which
6
+ refreshed the authentication session on every single poll — 153 times in
7
+ one day in the report — without ever clearing the 1003.
8
+ """
9
+
10
+ import httpx
11
+ import pytest
12
+ import respx
13
+ from httpx import Response
14
+
15
+ from pysmarthashtag.account import SmartAccount
16
+ from pysmarthashtag.const import OTA_SERVER_URL
17
+ from pysmarthashtag.tests.conftest import prepare_account_with_vehicles
18
+
19
+ _OTA_URL = OTA_SERVER_URL + "app/info/TestVIN0000000001"
20
+
21
+
22
+ def _count_refreshes(account: SmartAccount) -> list[int]:
23
+ """Replace ``authentication.refresh`` with a counting no-op."""
24
+ calls: list[int] = []
25
+
26
+ async def fake_refresh() -> None:
27
+ calls.append(1)
28
+
29
+ account.config.authentication.refresh = fake_refresh
30
+ return calls
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_ota_1003_returns_empty_without_refresh(smart_fixture: respx.Router) -> None:
35
+ """Code 1003 is normalised to "no OTA info" — no refresh, no retry."""
36
+ account = await prepare_account_with_vehicles()
37
+ refreshes = _count_refreshes(account)
38
+
39
+ route = smart_fixture.get(_OTA_URL).mock(
40
+ return_value=Response(200, json={"code": "1003", "message": "no ota info"})
41
+ )
42
+ # respx reuses the route registered by the fixture, so its counter already
43
+ # carries the OTA call made while setting the account up.
44
+ before = route.call_count
45
+
46
+ assert await account.get_vehicle_ota_info("TestVIN0000000001") == {}
47
+ assert refreshes == [], "1003 must not refresh the session"
48
+ assert route.call_count - before == 1, "1003 must not be retried"
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_ota_1003_does_not_break_refresh_cycle(smart_fixture: respx.Router) -> None:
53
+ """A permanent 1003 still leaves every other vehicle sensor populated."""
54
+ smart_fixture.get(_OTA_URL).mock(return_value=Response(200, json={"code": "1003", "message": "no ota info"}))
55
+
56
+ account = await prepare_account_with_vehicles()
57
+
58
+ vehicle = account.vehicles["TestVIN0000000001"]
59
+ assert vehicle.vin == "TestVIN0000000001"
60
+ assert "ota" not in vehicle.data
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_ota_unmapped_code_still_refreshes_once(smart_fixture: respx.Router) -> None:
65
+ """Regression guard: other unmapped codes keep the one-refresh remedy."""
66
+ account = await prepare_account_with_vehicles()
67
+ refreshes = _count_refreshes(account)
68
+
69
+ smart_fixture.get(_OTA_URL).mock(return_value=Response(200, json={"code": "9999", "message": "something new"}))
70
+
71
+ with pytest.raises(httpx.HTTPStatusError):
72
+ await account.get_vehicle_ota_info("TestVIN0000000001")
73
+ assert len(refreshes) == 1, "unmapped codes still get exactly one refresh"
@@ -79,6 +79,9 @@ class SmartVehicle:
79
79
  journal_recording_control: Optional["JournalRecordingControl"] = None # noqa: F821
80
80
  """Control for enabling/disabling on-vehicle trip recording."""
81
81
 
82
+ door_lock_control: Optional["DoorLockControl"] = None # noqa: F821
83
+ """Control for remotely locking/unlocking the doors."""
84
+
82
85
  engine_state: Optional[str] = None
83
86
  """The state of the engine."""
84
87
 
@@ -163,6 +166,10 @@ class SmartVehicle:
163
166
 
164
167
  self.journal_recording_control = JournalRecordingControl(self.account, self.vin)
165
168
 
169
+ from pysmarthashtag.control.lock import DoorLockControl
170
+
171
+ self.door_lock_control = DoorLockControl(self.account, self.vin)
172
+
166
173
  def _parse_data(self) -> None:
167
174
  self.vin = self.data.get("vin")
168
175
  self.name = self.data.get("modelName")
@@ -7,7 +7,7 @@ black
7
7
  mypy
8
8
  types-setuptools
9
9
  pbr
10
- time_machine<3.4.0
10
+ time_machine<3.6.0
11
11
  pre-commit
12
12
  backports.zoneinfo;python_version<"3.9"
13
- ruff==0.16.1
13
+ ruff==0.16.9
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.12.2",
3
- "distance": 0,
4
- "node": "g800eaf2bed8475decf036d35ccdea3178897c327",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-15"
8
- }
File without changes
File without changes
File without changes