feat(svm): add SVM / Find My Car support for Hyundai BlueLink USA - #1203
feat(svm): add SVM / Find My Car support for Hyundai BlueLink USA#1203blka wants to merge 19 commits into
Conversation
|
@ijojog — here's a standalone test script you can run against this PR to verify the SVM implementation with your live account. Quick start
python -m pip install -e .
python -m pip install python-dotenv
HYUNDAI_USERNAME=you@example.com
HYUNDAI_PASSWORD=yourpassword
HYUNDAI_PIN=1234
python test_svm_usa.pyUse python test_svm_usa.py --skip-captureWhat it does
Expected results
Test script"""Standalone test script for Hyundai BlueLink USA SVM support.
Usage:
1. Install the library in editable mode:
python -m pip install -e .
2. Create a .env file next to this script:
HYUNDAI_USERNAME=you@example.com
HYUNDAI_PASSWORD=yourpassword
HYUNDAI_PIN=1234
3. Run:
python test_svm_usa.py
The script writes two files to the current directory:
- svm_latest.jpg latest cached composite from get_svm_details
- svm_fresh.jpg freshly captured composite from request_svm_capture
Credentials, tokens, GPS coordinates, and image base64 are never printed.
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from hyundai_kia_connect_api.VehicleManager import VehicleManager
from hyundai_kia_connect_api.const import BRAND_HYUNDAI, REGION_USA
from hyundai_kia_connect_api.exceptions import (
APIError,
DuplicateRequestError,
SafetyAcknowledgmentError,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
def _load_env():
env_path = Path(__file__).with_suffix(".env")
if env_path.exists():
load_dotenv(env_path)
load_dotenv()
def _get_manager():
username = os.environ.get("HYUNDAI_USERNAME")
password = os.environ.get("HYUNDAI_PASSWORD")
pin = os.environ.get("HYUNDAI_PIN")
missing = [
k
for k, v in {
"HYUNDAI_USERNAME": username,
"HYUNDAI_PASSWORD": password,
"HYUNDAI_PIN": pin,
}.items()
if not v
]
if missing:
raise SystemExit(
f"Missing environment variables: {', '.join(missing)}. "
"Set them in a .env file or export them."
)
manager = VehicleManager(
region=REGION_USA,
brand=BRAND_HYUNDAI,
username=username,
password=password,
pin=pin,
geocode_api_enable=False,
)
return manager
def _describe_details(details, label: str):
print(f"\n{label}")
print(f" image size: {len(details.image_bytes)} bytes")
print(f" captured at: {details.captured_at}")
print(f" heading: {details.heading}")
print(f" speed: {details.speed}")
print(f" door open: {details.door_open}")
print(f" trunk open: {details.trunk_open}")
print(f" image dimensions: {details.image_size}")
def main():
parser = argparse.ArgumentParser(description="Test SVM support for Hyundai USA")
parser.add_argument(
"--skip-capture",
action="store_true",
help="Only test get_svm_details, do not trigger a new capture",
)
args = parser.parse_args()
_load_env()
manager = _get_manager()
print("Logging in...")
login_result = manager.login()
if login_result is not True:
raise SystemExit(f"Login did not succeed: {login_result}")
if not manager.vehicles:
raise SystemExit("No vehicles found on this account.")
vehicle = next(iter(manager.vehicles.values()))
print(f"Using vehicle: {vehicle.name} ({vehicle.VIN})")
api = manager.api
token = manager.token
print("Fetching latest cached SVM image...")
try:
latest = api.get_svm_details(token, vehicle)
except APIError as exc:
raise SystemExit(f"get_svm_details failed: {exc}") from exc
_describe_details(latest, "get_svm_details result")
Path("svm_latest.jpg").write_bytes(latest.image_bytes)
print(" saved: svm_latest.jpg")
if args.skip_capture:
print("\n--skip-capture: skipping request_svm_capture")
return 0
print("\nTriggering fresh SVM capture...")
print("(this may take up to ~120 seconds as the library polls for the new image)")
try:
fresh = api.request_svm_capture(
token, vehicle, acknowledged_warning=True
)
except SafetyAcknowledgmentError as exc:
raise SystemExit(f"Safety acknowledgment required: {exc}") from exc
except DuplicateRequestError as exc:
print(f"\nPrevious SVM request is still pending: {exc}")
print("Wait a few minutes and try again, or use --skip-capture.")
return 1
except APIError as exc:
raise SystemExit(f"request_svm_capture failed: {exc}") from exc
_describe_details(fresh, "request_svm_capture result")
Path("svm_fresh.jpg").write_bytes(fresh.image_bytes)
print(" saved: svm_fresh.jpg")
if latest.captured_at and fresh.captured_at:
if fresh.captured_at <= latest.captured_at:
print("\nWARNING: captured timestamp did not advance; image may still be cached")
else:
print("\nFresh image timestamp is newer than cached image.")
return 0
if __name__ == "__main__":
sys.exit(main())If you run it, please paste the output (with credentials/VIN redacted) so we can confirm the implementation matches the real API behavior. |
|
I tested the standalone SVM script against the First I ran cached-only with Then I ran the full capture test, and it also worked. Output, with VIN redacted: Both output files were created: The fresh image timestamp advanced from: to: So from my live vehicle test: |
|
@ijojog thank you for the live validation — results look exactly right. Confirmed behavior on a Hyundai USA 2025 Tucson Hybrid:
This gives us confidence the implementation matches the real BlueLink USA API contract captured in #1194. Marking the PR ready for review. |
|
Great, happy to help validate it live. Everything looked good on my side as well. Thanks for implementing this and for moving the PR forward. |
|
Great work — happy to help validate it live. Everything looked correct from my side as well during testing. Thanks for implementing the SVM / Find My Car support and for including the privacy protections around image and GPS logging. I’m glad the live Hyundai USA Tucson validation helped confirm the API behavior. Happy to test again when the Home Assistant integration follow-up PR is ready. |
|
Added capability detection on top of the SVM implementation. Detection is lazy, cached, and safe: it probes once and returns False on any failure. Only Hyundai BlueLink USA probes; other regions return False via the base class. |
|
That sounds good. The lazy cached detection makes sense to me, especially if it avoids extra API calls and safely returns False when unsupported or uncertain. My Hyundai USA Tucson already passed the live SVM get/capture test on this branch, so I’m happy to re-run the standalone script or test a capability-detection-specific script if you want confirmation from a real vehicle/account. |
There was a problem hiding this comment.
Since all calls are placed via vehicleManager how is this used?
There was a problem hiding this comment.
Good catch — you're right. get_svm_details and request_svm_capture should go through VehicleManager like every other API call, not be reached into via manager.api. supports_svm already follows that pattern; the two action methods should too.
I'll add VehicleManager.get_svm_details(vehicle_id) and VehicleManager.request_svm_capture(vehicle_id, acknowledged_warning) wrappers, and switch the HA coordinator to call them so it no longer touches manager.api directly.
| username=token.username, password=token.password, pin=token.pin | ||
| ) | ||
|
|
||
| def get_svm_details(self, token: Token, vehicle: Vehicle) -> SVMDetails: |
There was a problem hiding this comment.
Looks like we never store SVMDetails to vehicle. So this would mean we store outside the library and don't follow same model?
There was a problem hiding this comment.
From a user/tester perspective, I think SVM is a little different from normal vehicle state.
For normal state like lock, windows, charge, etc., it makes sense to store/update that on the Vehicle object.
But SVM includes a large JPG image plus sensitive capture metadata, so I can see why it may be safer to return it separately instead of storing the raw image bytes directly on vehicle.
My understanding is that Home Assistant would still need to keep the latest SVM image somewhere so it can be displayed, but that does not necessarily mean it should be stored directly on the Vehicle object.
Maybe the cleaner split is:
Vehicle:
- lightweight support/capability info, such as supports_svm
Library method:
- get_svm_details / request_svm_capture returns SVMDetails with the JPG bytes and metadata
Home Assistant:
- stores the returned image bytes in the camera/image entity state/cache so it can be viewed
So the image can still be shown in HA, but the main Vehicle object does not need to permanently carry a large/private JPG payload.
There was a problem hiding this comment.
Deliberate — @ijojog nailed the reasoning above. SVM is different from normal vehicle state in three ways:
- Size: the payload is a ~170 KB JPG composite, not a scalar/enum. Storing it on
Vehiclewould bloat the object that gets dumped, snapshotted, and logged on every refresh. - Privacy:
SVMDetails.raw_metadatacarries GPS-bearing capture data. We keep that out ofVehicleso it can't leak into state dumps or logs. - Lifecycle: SVM is on-demand, not part of the periodic poll. Attaching it to
Vehicleduringrefreshwould add an HTTP call to every poll cycle.
So the split is: capability (supports_svm: bool | None) lives on Vehicle so HA can gate entities without a probe per render; the image payload is returned by the method and cached by the consumer. In HA that cache lives on the ImageEntity — the right place for image bytes.
Does that split work for you, or would you rather the library hold the latest SVMDetails (on Vehicle behind a field, or on VehicleManager)?
There was a problem hiding this comment.
@blka @cdnninja That split makes sense to me from the user/tester side.
Keeping only supports_svm on Vehicle, routing the calls through VehicleManager, and letting Home Assistant’s ImageEntity cache/display the returned JPG sounds like the cleanest approach.
That avoids putting large/private SVM image data or GPS-bearing metadata into the normal Vehicle state/logging path, while still letting HA display the image when requested.
4d14d57 to
f4b2e70
Compare
… drift Replay onto upstream/master (44 commits ahead of original base): - supports_svm: bool | None = None (SVM PR Hyundai-Kia-Connect#1203) - battery_auxiliary_fail_warning_is_on, drive_mode, oil_level_warning_is_on - tire_pressure_{fl,fr,rl,rr} + _unit, tire_pressure_unit (upstream Hyundai-Kia-Connect#1218/Hyundai-Kia-Connect#1234/#1796) Purely additive (122 insertions, 0 deletions); all new fields default None.
f4b2e70 to
2902948
Compare
|
@cdnninja Rebased this onto the current |
- Make image-privacy test assert against the logged base64 payload and the presence of '<redacted>'. - Surface non-HT_533 HTTP 502 from findMyCarSVM as a plain APIError with the server message instead of falling through to AuthenticationError. - Strip svmImage from SVMDetails.raw_metadata and replace it with '<redacted>'. - Add POST body contract assertion in request_svm_capture polling test. - Add test covering non-HT_533 502 handling.
…ppers Mirrors the existing supports_svm wrapper so consumers do not reach into manager.api directly. Matches the lock/climate action-method pattern (thin delegation, no exception swallowing — action errors propagate).
… drift Replay onto upstream/master (44 commits ahead of original base): - supports_svm: bool | None = None (SVM PR Hyundai-Kia-Connect#1203) - battery_auxiliary_fail_warning_is_on, drive_mode, oil_level_warning_is_on - tire_pressure_{fl,fr,rl,rr} + _unit, tire_pressure_unit (upstream Hyundai-Kia-Connect#1218/Hyundai-Kia-Connect#1234/#1796) Purely additive (122 insertions, 0 deletions); all new fields default None.
.ruff.toml (target-version = "py310") was accidentally introduced in b09079e (feat(svm): add SVMDetails dataclass). It does not exist on upstream/master and silently overrode the project's pyproject.toml [tool.ruff] target-version = "py312" (set in Hyundai-Kia-Connect#1216 when the Python floor was bumped to 3.12). ruff precedence: .ruff.toml > pyproject.toml. Removing it restores the correct py312 target. ruff check + format still clean under py312; no code changes needed.
…napshot Complementary refresh — fixture added in Hyundai-Kia-Connect#1254 (after the SVM branch's snapshot regen) needs the supports_svm field introduced by this PR.
23c2150 to
33e24c4
Compare
| "request_svm_capture is not implemented for this region" | ||
| ) | ||
|
|
||
| def supports_svm(self, token: Token, vehicle: Vehicle) -> bool: |
There was a problem hiding this comment.
Don't most supports items sit within the car?
hyundai_kia_connect_api/hyundai_kia_connect_api/Vehicle.py
Lines 147 to 148 in d4fa3f5
Any reason this one is exposed here?
There was a problem hiding this comment.
Good point — this was a deviation. supports_svm was a probe method (GET getSVMDetails, check image_bytes) to get per-vehicle precision, but it does not match the library capability convention. supports_window_control and supports_valet_mode are per-region class attributes on the region API, stamped onto Vehicle by VehicleManager.initialize_vehicles with no API call (Vehicle.py L147-L148).
I have aligned supports_svm to the same pattern:
ApiImpl.supports_svm = False(class attribute)HyundaiBlueLinkApiUSA.supports_svm = True(class attribute)VehicleManager.initialize_vehiclesstampsvehicle.supports_svm = self.api.supports_svm
The probe method, the base stub, and the VehicleManager.supports_svm(vehicle_id) wrapper are all removed. get_svm_details / request_svm_capture stay — those are the actual image fetch, not capability.
Tradeoff: capability is now per-region, not per-vehicle. Models without SVM hardware will show the entity and the capture will fail at runtime — same behavior as supports_window_control today. @ijojog the capability flag now lives on vehicle.supports_svm as you suggested.
| else: | ||
| _LOGGER.debug(f"{DOMAIN} - Vehicle Disabled, skipping.") | ||
|
|
||
| def supports_svm(self, vehicle_id: str) -> bool: |
There was a problem hiding this comment.
I think this is a new approach over the client using "vehicle.is_XXX_supported" Why is that?
There was a problem hiding this comment.
@cdnninja @blka From my user/tester perspective, I’m fine with whichever pattern matches the library best.
The important part for me is just that Home Assistant can reliably know whether my vehicle supports SVM before showing the image/capture entities.
If the existing library pattern is vehicle.is_XXX_supported / a support field on Vehicle, then it makes sense to me to follow that for SVM as well, especially since supports_svm is lightweight capability info and not the actual image payload.
I still agree the actual SVMDetails / JPG bytes should stay out of the normal Vehicle state, but the capability flag itself living on Vehicle sounds reasonable if that matches the existing design.
There was a problem hiding this comment.
You are right — the VehicleManager.supports_svm(vehicle_id) wrapper is gone. Same change as in the ApiImpl thread above: supports_svm is now a per-region class attribute (ApiImpl=False, HyundaiBlueLinkApiUSA=True), stamped onto vehicle.supports_svm by VehicleManager.initialize_vehicles — exactly like supports_window_control / supports_valet_mode. Consumers read vehicle.supports_svm directly; no probe, no extra API call, no wrapper. @ijojog the capability flag now lives on vehicle.supports_svm as you suggested; the image payload (SVMDetails) still stays off Vehicle.
Per cdnninja review (2026-08-06): supports_svm was a probe method on VehicleManager/ApiImpl (GET getSVMDetails, check image_bytes), deviating from the library's capability convention. supports_window_control and supports_valet_mode are per-region class attributes on the region API, stamped onto Vehicle by VehicleManager.initialize_vehicles — no API call. Align supports_svm to the same pattern: - ApiImpl: class attr supports_svm = False (was a probe stub method) - HyundaiBlueLinkApiUSA: class attr supports_svm = True (was a probe method) - VehicleManager: stamp vehicle.supports_svm = api.supports_svm in initialize_vehicles; drop the supports_svm(vehicle_id) wrapper - Vehicle.supports_svm field unchanged (canonical source, now stamped) Tradeoff: capability is per-region, not per-vehicle. Models without SVM hardware will show the entity; capture fails at runtime — same as supports_window_control today. ijojog (tester) accepted flag-on-Vehicle. Also fix latent ruff violations exposed by removing .ruff.toml (prev commit, which unmasked pyproject.toml [tool.ruff] py312 rule set): - I001 import sort (ApiImpl, VehicleManager, us_svm_test) - PYI041 svm._parse_float: int | float -> float - PIE790 SafetyAcknowledgmentError unnecessary pass - UP017 dt.timezone.utc -> dt.UTC (tests) - SIM117 collapse nested with (tests) - TRY004 noqa on _FakeResponse.json ValueError (JSONDecodeError semantic) Tests: drop 10 probe tests, add 3 class-attr/stamp tests. 450 pass, ruff clean.
62d008f to
87e7fd8
Compare
| return result | ||
|
|
||
|
|
||
| def parse_svm_response(response: dict, timezone: dt.timezone) -> SVMDetails: |
There was a problem hiding this comment.
This looks specific to the USA API? If so should it be in the API? Same question goes for a few of these other methods.
Summary
Adds 360 Surround View Monitor (SVM) / Find My Car support for Hyundai BlueLink USA, including automatic capability detection.
Closes #1201
Related to discussion #1194
New API surface
What changed
hyundai_kia_connect_api/svm.py:SVMDetailsdataclass, response parser, and log-redaction helpers.hyundai_kia_connect_api/exceptions.py:SafetyAcknowledgmentError.hyundai_kia_connect_api/ApiImpl.py: base stubs raisingNotImplementedErrorfor other regions, plus basesupports_svmreturning a cached value orFalse.hyundai_kia_connect_api/HyundaiBlueLinkApiUSA.py: concrete implementation ofget_svm_details,request_svm_capture, andsupports_svm.request_svm_captureperforms a baseline GET, POSTs the trigger, mapsHT_533toDuplicateRequestError, and polls for up to 120 s until the capture timestamp changes.supports_svmprobesget_svm_detailsonce, caches the result onVehicle, and maps all failures toFalse.hyundai_kia_connect_api/Vehicle.py: newsupports_svmfield for cached capability detection.hyundai_kia_connect_api/VehicleManager.py: newsupports_svm(vehicle_id)consumer-facing wrapper.hyundai_kia_connect_api/__init__.py: exportsSVMDetails.tests/us_svm_test.py: unit tests for SVM response parsing, redaction, polling, errors, and capability detection..ruff.toml: setstarget-version = "py310"to prevent Python 3.10 syntax regressions (bare multi-exceptionexceptwas accepted by the default target).Privacy
svmImageand GPS coordinates.SVMDetails.raw_metadatastores the full response dict withsvmImagereplaced by<redacted>.request_svm_capturelogs only thetidfrom the trigger response.Verification
Live verification
Verified against a real Hyundai USA 2025 Tucson Hybrid account by @ijojog:
get_svm_detailsreturned a valid 177 kB JPG composite with full metadata.request_svm_capture(..., acknowledged_warning=True)triggered, polled, and returned a newer 167 kB image.supports_svmcorrectly detected the feature as available for this vehicle.DuplicateRequestErroror authentication issues occurred.Scope