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.
- {pysmarthashtag-0.12.2/pySmartHashtag.egg-info → pysmarthashtag-0.13.0}/PKG-INFO +1 -1
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0/pySmartHashtag.egg-info}/PKG-INFO +1 -1
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/SOURCES.txt +4 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/scm_file_list.json +4 -0
- pysmarthashtag-0.13.0/pySmartHashtag.egg-info/scm_version.json +8 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/account.py +18 -1
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/cli.py +36 -4
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/control/climate.py +27 -2
- pysmarthashtag-0.13.0/pysmarthashtag/control/lock.py +128 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_actions.py +59 -0
- pysmarthashtag-0.13.0/pysmarthashtag/tests/test_cli.py +44 -0
- pysmarthashtag-0.13.0/pysmarthashtag/tests/test_lock_control.py +105 -0
- pysmarthashtag-0.13.0/pysmarthashtag/tests/test_ota_info.py +73 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/vehicle.py +7 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/requirements-test.txt +2 -2
- pysmarthashtag-0.12.2/pySmartHashtag.egg-info/scm_version.json +0 -8
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.devcontainer.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/copilot-instructions.md +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/dependabot.yml +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/workflows/python-package.yml +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.github/workflows/python-publish.yml +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.gitignore +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.pre-commit-config.yaml +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.vscode/launch.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/.vscode/settings.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/AUTHORS +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/CODE_OF_CONDUCT.md +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/CONTRIBUTING.md +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/ChangeLog +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/LICENSE +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/README.md +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/dependency_links.txt +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/requires.txt +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/top_level.txt +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pyproject.toml +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/__init__.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/__init__.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/authentication.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/client.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/log_sanitizer.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/ssl_context.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/api/utils.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/const.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/control/charging.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/control/journal.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/models.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/__init__.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/common.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/conftest.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/Human_and_vehicle_relationship_does_not_exist.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/api_access.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_context.url +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_intermediate.url +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_result.url +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/charging_success.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/climate_success.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/journal_response.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/journal_toggle_success.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/login_result.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/ota_response.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/soc_80.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/soc_90.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/token_expired.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/trackpoints_response.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info2.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info_dc_charging.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_response.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_result.json +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_account.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_authentication_backoff.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_charging.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_charging_states.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_dc_charging.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_endpoint_urls.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal_control.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal_pagination.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_log_sanitizer.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_missing_fields.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_ssl_context.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_token_lifecycle.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_tracked_vins.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_trackpoints.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_vehicle_state.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_vin_headers.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/__init__.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/battery.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/climate.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/journal.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/maintenance.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/position.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/running.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/safety.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/tires.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/trackpoints.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/vehicle/vehicle_state.py +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/requirements-cli.txt +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/requirements.txt +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/run.sh +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/setup.cfg +0 -0
- {pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/setup.py +0 -0
|
@@ -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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pySmartHashtag.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_context.url
RENAMED
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/auth_intermediate.url
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/charging_success.json
RENAMED
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/climate_success.json
RENAMED
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/journal_response.json
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/login_result.json
RENAMED
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/ota_response.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/token_expired.json
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info.json
RENAMED
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_info2.json
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_response.json
RENAMED
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/replys/vehicle_result.json
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_authentication_backoff.py
RENAMED
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_charging_states.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal_control.py
RENAMED
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_journal_pagination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pysmarthashtag-0.12.2 → pysmarthashtag-0.13.0}/pysmarthashtag/tests/test_token_lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|