Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,6 @@ venv.bak/

# Generated response files
response-*.json

.copilot*
external
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ exclude = [

[tool.ruff.per-file-ignores]
"docs/source/conf.py" = ["D100"]
"pysamrthashtag/api/authentication.py" = ["D102", "D107"]
"pysmarthashtag/api/authentication.py" = ["D102", "D107"]
"pysmarthashtag/vehicle/battery.py" = ["C901"] # Complex data parsing function

Copilot AI Feb 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a per-file Ruff ignore for C901 on battery.py hides an increasing complexity issue in parsing logic. Prefer refactoring _parse_vehicle_data() into smaller helpers (e.g., per feature group like V2L/charge lid/DCDC) so complexity stays within the configured limit and remains maintainable.

Copilot generated this review using guidance from repository custom instructions.

[tool.ruff.mccabe]
max-complexity = 25
114 changes: 86 additions & 28 deletions pysmarthashtag/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from typing import Optional

from pysmarthashtag.api import utils
from pysmarthashtag.api.authentication import SmartAuthentication
from pysmarthashtag.api.authentication import SmartAuthentication, SmartAuthenticationINTL
from pysmarthashtag.api.client import SmartClient, SmartClientConfiguration
from pysmarthashtag.api.log_sanitizer import sanitize_log_data
from pysmarthashtag.const import API_CARS_URL, API_SELECT_CAR_URL, EndpointUrls
from pysmarthashtag.const import API_CARS_URL, API_SELECT_CAR_URL, EndpointUrls, SmartRegion
from pysmarthashtag.models import SmartAuthError, SmartHumanCarConnectionError, SmartTokenRefreshNecessary
from pysmarthashtag.vehicle.vehicle import SmartVehicle

Expand Down Expand Up @@ -38,21 +38,72 @@ class SmartAccount:
endpoint_urls: Optional[EndpointUrls] = None
"""Optional. Custom endpoint URLs for international API support."""

region: Optional[SmartRegion] = None
"""Optional. Region preset (EU or INTL). If set, overrides endpoint_urls."""

vehicles: dict[str, SmartVehicle] = field(default_factory=dict, init=False)
"""Vehicles associated with the account."""

def __post_init__(self, password, log_responses):
"""Initialize the account."""
# Ensure endpoint_urls is set
if self.endpoint_urls is None:
# Import get_endpoint_urls_for_region here to avoid circular imports
from pysmarthashtag.const import get_endpoint_urls_for_region

# If region is specified, use it to get endpoint URLs
if self.region is not None:
self.endpoint_urls = get_endpoint_urls_for_region(self.region)
elif self.endpoint_urls is None:
self.endpoint_urls = EndpointUrls()

# Choose authentication class based on region
if self.region == SmartRegion.INTL:
_LOGGER.info("Using INTL (International) authentication for region: %s", self.region)
auth = SmartAuthenticationINTL(self.username, password, endpoint_urls=self.endpoint_urls)
else:
_LOGGER.info("Using EU authentication (default)")
auth = SmartAuthentication(self.username, password, endpoint_urls=self.endpoint_urls)

if self.config is None:
self.config = SmartClientConfiguration(
SmartAuthentication(self.username, password, endpoint_urls=self.endpoint_urls),
auth,
log_responses=log_responses,
)

def _is_intl_region(self) -> bool:
"""Check if using INTL (International) region."""
return self.region == SmartRegion.INTL

def _generate_api_headers(self, params: dict, method: str, url: str, body=None) -> dict[str, str]:
"""Generate API headers based on region (EU or INTL).

This method automatically selects the correct header generator
based on whether the account is configured for INTL region.
"""
auth = self.config.authentication

if self._is_intl_region():
# INTL region uses different headers and signing
client_id = getattr(auth, "api_client_id", None)
return utils.generate_intl_header(
device_id=auth.device_id,

Copilot AI Feb 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In INTL mode this uses auth.device_id for x-device-identifier, but SmartAuthenticationINTL generates and uses self.device_identifier for the INTL login/session flow. Using a different identifier for subsequent vehicle API requests can break auth/signatures. Align these by either setting self.device_id = self.device_identifier in SmartAuthenticationINTL.__init__, or by passing auth.device_identifier here (and keep it consistent everywhere).

Suggested change
device_id=auth.device_id,
device_id=getattr(auth, "device_identifier", auth.device_id),

Copilot uses AI. Check for mistakes.
access_token=auth.api_access_token,
params=params,
method=method,
url=url,
body=body,
client_id=client_id,
)
else:
# EU region uses default headers
return utils.generate_default_header(
device_id=auth.device_id,
access_token=auth.api_access_token,
params=params,
method=method,
url=url,
body=body,
)

async def _ensure_ssl_context(self) -> None:
"""Ensure SSL context is created asynchronously.

Expand Down Expand Up @@ -90,9 +141,7 @@ async def _init_vehicles(self) -> None:
# we do not know what type of car we have in our list so we fall back to the old API URL
self.endpoint_urls.get_api_base_url() + API_CARS_URL + "?" + utils.join_url_params(params),
headers={
**utils.generate_default_header(
client.config.authentication.device_id,
client.config.authentication.api_access_token,
**self._generate_api_headers(
params=params,
method="GET",
url=API_CARS_URL,
Expand Down Expand Up @@ -152,9 +201,7 @@ async def select_active_vehicle(self, vin) -> None:
r_car_info = await client.post(
self.vehicles[vin].base_url + API_SELECT_CAR_URL,
headers={
**utils.generate_default_header(
client.config.authentication.device_id,
client.config.authentication.api_access_token,
**self._generate_api_headers(
params={},
method="POST",
url=API_SELECT_CAR_URL,
Expand All @@ -176,28 +223,41 @@ async def select_active_vehicle(self, vin) -> None:
async def get_vehicle_information(self, vin) -> str:
"""Get information about a vehicle."""
_LOGGER.debug("Getting information for vehicle")
params = {
"latest": True,
"target": "basic%2Cmore",
"userId": self.config.authentication.api_user_id,
}

# INTL uses different API path and parameters than EU
if self._is_intl_region():
# INTL: No /api/v1/ prefix, latest=False to get all data including climate/doors/pollution
params = {
"latest": False,
"target": "basic,more",
"userId": self.config.authentication.api_user_id,
}
url_path = "/remote-control/vehicle/status/" + vin
# INTL uses api.ecloudeu.com without /api/v1/ prefix
base_url = self.endpoint_urls.get_api_base_url()
else:
# EU: Uses /api/v1/ prefix in the base_url setting
params = {
"latest": True,
"target": "basic,more",
"userId": self.config.authentication.api_user_id,
}
url_path = "/remote-control/vehicle/status/" + vin
base_url = self.vehicles[vin].base_url

data = {}
async with SmartClient(self.config) as client:
for retry in range(3):
try:
# Build URL with URL-encoded params for GET request
url_params = {k: str(v).replace(",", "%2C") for k, v in params.items()}
r_car_info = await client.get(
self.vehicles[vin].base_url
+ "/remote-control/vehicle/status/"
+ vin
+ "?"
+ utils.join_url_params(params),
base_url + url_path + "?" + utils.join_url_params(url_params),
headers={
**utils.generate_default_header(
client.config.authentication.device_id,
client.config.authentication.api_access_token,
**self._generate_api_headers(
params=params,

Copilot AI Feb 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request URL is built with URL-encoded params (url_params), but the signature/header generation is passed the unencoded params dict. For EU requests, _create_sign() does not URL-encode params, so the signature payload can diverge from the actual request URL (notably target=basic%2Cmore). Use a single canonical params representation for both the URL and signature (e.g., pass url_params into header generation too, or centralize encoding inside _create_sign() for all regions).

Suggested change
params=params,
params=url_params,

Copilot uses AI. Check for mistakes.
method="GET",
url="/remote-control/vehicle/status/" + vin,
url=url_path,
)
},
)
Expand Down Expand Up @@ -233,9 +293,7 @@ async def get_vehicle_soc(self, vin) -> str:
+ "?"
+ utils.join_url_params(params),
headers={
**utils.generate_default_header(
client.config.authentication.device_id,
client.config.authentication.api_access_token,
**self._generate_api_headers(
params=params,
method="GET",
url="/remote-control/vehicle/status/soc/" + vin,
Expand Down
Loading