github samuelebistoletti/HomeAssistant-V2C-Cloud v1.3.0

latest release: v1.3.1
12 hours ago

[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.x1.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 to 1.2.x is
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, and GET /device/connected. Each is exercised by dedicated tests in tests/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/me response 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-facing HomeAssistantError in 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.
  • ChargeMode select (monophasic / threephasic / mixed) and LightLED number (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_reported synthesises a LAN-shaped payload from the cloud /reported document, including seven additional numeric keys plus device metadata (ID, firmware, MAC, SSID, IP via the wifi_info blob). 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_led cloud endpoint (undocumented, live on firmware 2.4.6) – the LogoLED switch is now controllable from cloud-only mode via async_cloud_set_logo_led.
  • Spanish UI translation – the previously incomplete Spanish support is now a full translations/es.json (235 keys), at parity with en.json and it.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-restore flag 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 via anchore/sbom-action) attached to every release, and a reusable security.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_entry is version-aware via a _MIGRATIONS registry; SCHEMA_VERSION = 2 in const.py is the single source of truth for both config_flow.VERSION and the migration target. Legacy entries are translated: the cloud-only sentinel (fallback_ip == "" / "0.0.0.0") becomes cloud_only: True; a non-empty fallback_ip paired with fallback_device_id becomes a one-record cached_pairings; an initial_pairings snapshot wins over the single-device pair. Legacy keys are dropped from entry.data.
  • Cloud-only mode is encoded as entry.data["cloud_only"]: bool instead of the empty-string fallback_ip sentinel. 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_keyword validates the keyword against a documented WRITEABLE_KEYWORDS whitelist 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 new DEFAULT/MIN/MAX_LOCAL_INTERVAL bounds.
  • HA minimum version stays in hacs.json ("homeassistant": "2025.4.0") — hassfest rejects min_ha_version in manifest.json as an unknown field.

Fixed

  • ChargeMode Select showed "Unknown" in LAN mode – it is write-enabled but absent from /RealTimeData; the integration's read-only-keyword augmentation covered LogoLED + LightLED but missed ChargeMode. The local coordinator now also fetches /read/ChargeMode in parallel and the Select displays the live value.
  • Local refresh interval option was not persisted – the options flow returned async_create_entry(title="", data={}), and HA uses that data argument to overwrite entry.options, so the field always snapped back to 30 s. Fix: pass the populated options dict to async_create_entry(data=new_options).
  • Connection-type radio labels were not translated – the schema used a hard-coded vol.In({label: ...}) dict. Migrated to SelectSelector(translation_key="connection_type") with a top-level selector block in strings.json and 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/currentstatecharge capture):
    • VoltageInstallation reported a spurious ~77 V – the cloud voltage field is a small internal signal; the real mains/installation voltage is carried by cp_level (e.g. 248 on a 230 V EU install). Remapped cp_level → VoltageInstallation and dropped the misleading voltage mapping.
    • ContractedPower was off by 100× – the cloud encodes contract_power as W/100 ("7" = 700 W = 0.7 kW), but the Number entity divides by 1000 to render kW. Added a _CLOUD_TO_LAN_MULTIPLIERS table that multiplies ContractedPower by 100 during synthesis.
    • LightLED showed 1 for a LED set to 100 % – the cloud serialises light_led as 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_keyword directly, so the LAN write raised V2CLocalApiError and no cloud fallback fired. Every setter now routes through async_route_local_or_cloud.
    • device_identifier / firmware_version / wifi_ssid / wifi_ip sensors were "Unknown" – the synthesis loop coerced every value via float(str(raw)) and silently dropped non-numeric ones. Added a string-passthrough path plus inline parsing of the cloud's wifi_info JSON blob.
  • UI strings referencing the removed fallback_ip step / field were cleaned up across strings.json and the en/it/es translations; the connection_type and init descriptions now mention auto-discovered per-device IPs and automatic LAN→cloud routing.
  • requirements.txt: pyyaml is now version-pinned (>=6.0,<7). requirements_test.txt: all packages have upper bounds for reproducible CI builds.
  • .ruff.toml: target-version aligned with CI (py312); test/script directories get a targeted per-file-ignores so the strict select = ALL rule set no longer drowns the lint output. The whole tree is ruff format-clean and both ruff check and ruff format --check gate every PR and release.
  • CI hardening: persist-credentials: false on every read-only checkout; pip-audit --strict; bandit artifact retention pinned to 30 days; hacs.yaml + hassfest.yaml push triggers scoped to branches: [main] (daily cron retained).

Hardening (post-beta review pass)

  • _normalise_pairings deduplicated into a single _pairings.py module imported by both config_flow.py and __init__.py, eliminating drift between the two persistence paths. Persisted lists are capped at 64 records during normalisation, and async_setup_entry now passes the raw entry data through _normalise_pairings so a malformed snapshot ([{}], [{"deviceId": None}], a non-list value) no longer prevents the integration from loading.
  • LAN-vs-cloud router takes a cloud_call factory (Callable[[], Awaitable]) instead of a pre-constructed coroutine. On the LAN-success happy path the cloud awaitable is no longer created, eliminating the RuntimeWarning: coroutine was never awaited pollution.
  • Parallel local-coordinator first-refresh in the sensor platform – setup time on multi-charger accounts is bounded by the slowest device's LAN response rather than the sum of all devices'.
  • config_flow.py no 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_ip without a fallback_device_id is 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_load use confirmed clean — no new findings.

Upgrade / downgrade notes

  • Rolling back from 1.3.x to 1.2.x via HACS is not supported. The v2 schema drops fallback_device_id / initial_pairings and persists multi-device IPs only in cached_pairings (a key 1.2.x does not understand). v2 entries leave a vestigial fallback_ip: "" sentinel so the older code path loads instead of crashing on KeyError, but a rollback degrades a multi-device account to cloud-only mode and silently loses LAN data. Re-setup the integration after a downgrade.

Don't miss a new HomeAssistant-V2C-Cloud release

NewReleases is sending notifications on new releases.