[1.3.0] - 2026-06-09
Stable release. Promotes the 1.3.0 line to general availability after the
public beta window (beta.1 2026-05-19 → beta.3 2026-06-01) closed with no
regressions reported. No code changes relative to 1.3.0-beta.3.
This is the cumulative 1.2.x → 1.3.0 change set, developed and validated
across the three pre-releases listed below. The integration gains full V2C
Cloud endpoint coverage, automatic multi-charger discovery, a LAN-vs-cloud
control router, and substantially better cloud-only (4G) behaviour.
Breaking (auto-migrated): the config entry schema is upgraded from v1 to
v2 on first load — no user action required. Rolling back to1.2.xis
not supported; see Upgrade / downgrade notes below.
Added
- Full V2C Cloud endpoint coverage – 10 new client methods cover every previously missing public endpoint:
start_charge,pause_charge,intensity,locked,dynamic,chargefvmode,max_car_int,min_car_int,denka/max_power, andGET /device/connected. Each is exercised by dedicated tests intests/test_cloud_endpoints_1_3.py. - 10 new Home Assistant services:
start_charge,pause_charge,set_charge_intensity,set_locked,set_dynamic,set_fv_mode,set_max_car_intensity,set_min_car_intensity,set_denka_max_power,get_connected_status. The first five use the LAN-vs-cloud router; the photovoltaic and Denka calls are cloud-only. - Automatic multi-charger discovery – an account with N chargers is fully supported. Every charger's LAN IP is sourced from the cloud
/pairings/meresponse at runtime and a normalised snapshot is persisted on every successful refresh (entry.data["cached_pairings"]). During a cloud outage every previously-seen charger stays addressable via its last-known IP; when the cloud returns, the cache is reconciled (added devices appear, removed devices disappear). Replaces the single user-typed fallback IP. - Smart LAN-vs-cloud router (
local_api.async_route_local_or_cloud) – control commands shared between LAN (/write/) and cloud (/device/*) prefer the LAN path and transparently fall back to the cloud endpoint when LAN is unreachable or the device is cloud-only. Covers start/pause charge, intensity, locked, dynamic. Controls with no cloud endpoint (LightLED,ContractedPower,Timer,PauseDynamic,ChargeMode,DynamicPowerMode) raise a clear, user-facingHomeAssistantErrorin cloud-only mode instead of silently dropping the write. - Editable connection type – an options-flow
Local (Wi-Fi)/Cloud only (4G)toggle switches modes post-setup and triggers an automatic integration reload. ChargeModeselect (monophasic / threephasic / mixed) andLightLEDnumber (0-100 %) entities.- User-configurable local refresh interval (5-300 s, default 30 s) via the Reconfigure dialog (
CONF_LOCAL_UPDATE_INTERVAL). Cloud-only (4G) devices keep their fixed cadence and ignore the option. Applied live via an entry update listener — no reload required. - Expanded cloud-only entity coverage –
_build_realtime_from_reportedsynthesises a LAN-shaped payload from the cloud/reporteddocument, including seven additional numeric keys plus device metadata (ID, firmware, MAC, SSID, IP via thewifi_infoblob). The set of entities showing real data in cloud-only mode grows from ~12 to ~20+. Structurally LAN-only entities (ReadyState,SignalStatus,Timer,ChargeMode,DynamicPowerMode,PauseDynamic) now correctly advertise as Unavailable in cloud-only mode instead of the misleading "Unknown". - Discovered
/device/logo_ledcloud endpoint (undocumented, live on firmware 2.4.6) – the LogoLED switch is now controllable from cloud-only mode viaasync_cloud_set_logo_led. - Spanish UI translation – the previously incomplete Spanish support is now a full
translations/es.json(235 keys), at parity withen.jsonandit.json. - Live smoke-test script (
scripts/live_smoke_test.py) – exercises every read endpoint and issues safe no-op writes against a real Trydan plus the V2C Cloud, then verifies snapshot/restore. Requires the explicit--confirm-restoreflag and is never run in CI. - CI / supply-chain hardening – Python 3.12/3.13/3.14 matrix, ruff lint + format gates, Codecov coverage reporting (
.coveragerc),concurrency:blocks on every workflow, pip caching,.github/dependabot.yml(weekly grouped Actions + pip updates), SBOM (SPDX-JSON + CycloneDX-JSON viaanchore/sbom-action) attached to every release, and a reusablesecurity.yaml(workflow_call) so the release pipeline gates on the exact same SAST / dependency-audit / secret-scan jobs as PRs.
Changed
- Config entry schema v1 → v2 (auto-migrated).
async_migrate_entryis version-aware via a_MIGRATIONSregistry;SCHEMA_VERSION = 2inconst.pyis the single source of truth for bothconfig_flow.VERSIONand the migration target. Legacy entries are translated: the cloud-only sentinel (fallback_ip == ""/"0.0.0.0") becomescloud_only: True; a non-emptyfallback_ippaired withfallback_device_idbecomes a one-recordcached_pairings; aninitial_pairingssnapshot wins over the single-device pair. Legacy keys are dropped fromentry.data. - Cloud-only mode is encoded as
entry.data["cloud_only"]: boolinstead of the empty-stringfallback_ipsentinel. The first-setup fallback-IP step is gone — initial setup now requires the cloud to be reachable to capture the pairings list, consistent with the integration's name and removing a single point of failure. - SSRF guard deduplicated into
custom_components/v2c_cloud/_net.py::validate_private_ip(private + not loopback + not link-local + not unspecified), replacing four scattered copies with a single tested helper. async_write_keywordvalidates the keyword against a documentedWRITEABLE_KEYWORDSwhitelist to reduce the LAN write/SSRF surface and prevent accidental misuse from automations.- Local API constants consolidated in
const.py:LOCAL_HTTP_TIMEOUT,LOCAL_MAX_RETRIES,LOCAL_RETRY_BACKOFF,LOCAL_WRITE_RETRY_DELAY,CLOUD_ONLY_UPDATE_INTERVAL, and the newDEFAULT/MIN/MAX_LOCAL_INTERVALbounds. - HA minimum version stays in
hacs.json("homeassistant": "2025.4.0") — hassfest rejectsmin_ha_versioninmanifest.jsonas an unknown field.
Fixed
ChargeModeSelect showed "Unknown" in LAN mode – it is write-enabled but absent from/RealTimeData; the integration's read-only-keyword augmentation coveredLogoLED+LightLEDbut missedChargeMode. The local coordinator now also fetches/read/ChargeModein parallel and the Select displays the live value.Local refresh intervaloption was not persisted – the options flow returnedasync_create_entry(title="", data={}), and HA uses thatdataargument to overwriteentry.options, so the field always snapped back to 30 s. Fix: pass the populated options dict toasync_create_entry(data=new_options).- Connection-type radio labels were not translated – the schema used a hard-coded
vol.In({label: ...})dict. Migrated toSelectSelector(translation_key="connection_type")with a top-levelselectorblock instrings.jsonand all translation files. Italian "Intensità Light LED" renamed to "Intensità LED". - Cloud-only data accuracy (validated end-to-end against a live firmware-2.4.6
/device/reported+/device/currentstatechargecapture):VoltageInstallationreported a spurious ~77 V – the cloudvoltagefield is a small internal signal; the real mains/installation voltage is carried bycp_level(e.g.248on a 230 V EU install). Remappedcp_level → VoltageInstallationand dropped the misleadingvoltagemapping.ContractedPowerwas off by 100× – the cloud encodescontract_poweras W/100 ("7"= 700 W = 0.7 kW), but the Number entity divides by 1000 to render kW. Added a_CLOUD_TO_LAN_MULTIPLIERStable that multipliesContractedPowerby 100 during synthesis.LightLEDshowed1for a LED set to 100 % – the cloud serialiseslight_ledas a 0.0-1.0 fraction; the LAN keyword and entity use 0-100 % integers. Added a × 100 multiplier.- Number / Switch / Select writes were silently dropped in cloud-only (4G) – every setter called
async_write_keyworddirectly, so the LAN write raisedV2CLocalApiErrorand no cloud fallback fired. Every setter now routes throughasync_route_local_or_cloud. device_identifier/firmware_version/wifi_ssid/wifi_ipsensors were "Unknown" – the synthesis loop coerced every value viafloat(str(raw))and silently dropped non-numeric ones. Added a string-passthrough path plus inline parsing of the cloud'swifi_infoJSON blob.
- UI strings referencing the removed
fallback_ipstep / field were cleaned up acrossstrings.jsonand the en/it/es translations; theconnection_typeandinitdescriptions now mention auto-discovered per-device IPs and automatic LAN→cloud routing. requirements.txt:pyyamlis now version-pinned (>=6.0,<7).requirements_test.txt: all packages have upper bounds for reproducible CI builds..ruff.toml:target-versionaligned with CI (py312); test/script directories get a targetedper-file-ignoresso the strictselect = ALLrule set no longer drowns the lint output. The whole tree isruff format-clean and bothruff checkandruff format --checkgate every PR and release.- CI hardening:
persist-credentials: falseon every read-only checkout;pip-audit --strict; bandit artifact retention pinned to 30 days;hacs.yaml+hassfest.yamlpush triggers scoped tobranches: [main](daily cron retained).
Hardening (post-beta review pass)
_normalise_pairingsdeduplicated into a single_pairings.pymodule imported by bothconfig_flow.pyand__init__.py, eliminating drift between the two persistence paths. Persisted lists are capped at 64 records during normalisation, andasync_setup_entrynow passes the raw entry data through_normalise_pairingsso a malformed snapshot ([{}],[{"deviceId": None}], a non-list value) no longer prevents the integration from loading.- LAN-vs-cloud router takes a
cloud_callfactory (Callable[[], Awaitable]) instead of a pre-constructed coroutine. On the LAN-success happy path the cloud awaitable is no longer created, eliminating theRuntimeWarning: coroutine was never awaitedpollution. - Parallel local-coordinator first-refresh in the
sensorplatform – setup time on multi-charger accounts is bounded by the slowest device's LAN response rather than the sum of all devices'. config_flow.pyno longer surfaces exception args in the unknown-error branch (_LOGGER.exception(...)→_LOGGER.error("…: %s", type(err).__name__)) so a future exception-class change cannot leak the API key into a traceback.- Legacy
fallback_ipwithout afallback_device_idis logged during migration so a dropped IP that could not be turned into a(deviceId, ip)record is diagnosable.
Security
- Tighter local-write surface – writes are rejected unless the keyword is in the documented Trydan write-list and the resolved IP parses cleanly and satisfies the private / non-loopback / non-link-local / non-unspecified policy.
- Audit of credential masking, SSRF guards and
eval/exec/pickle/yaml.unsafe_loaduse confirmed clean — no new findings.
Upgrade / downgrade notes
- Rolling back from
1.3.xto1.2.xvia HACS is not supported. The v2 schema dropsfallback_device_id/initial_pairingsand persists multi-device IPs only incached_pairings(a key1.2.xdoes not understand). v2 entries leave a vestigialfallback_ip: ""sentinel so the older code path loads instead of crashing onKeyError, but a rollback degrades a multi-device account to cloud-only mode and silently loses LAN data. Re-setup the integration after a downgrade.