Skip to content

Alt Polling Mode & Per-inverter API call separation#249

Merged
Poshy163 merged 11 commits into
mainfrom
alt-mode
Jun 12, 2026
Merged

Alt Polling Mode & Per-inverter API call separation#249
Poshy163 merged 11 commits into
mainfrom
alt-mode

Conversation

@Poshy163

@Poshy163 Poshy163 commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

Alt Polling Mode

Adds an optional alt polling mode that splits API polling into two cadences:

  • Full poll — runs at the existing configurable scan_interval (default 60s). Fetches all endpoints per inverter (energy, power, config, EV charger, summary, etc.).
  • Fast poll — runs at a new configurable fast_scan_interval (default 15s, range 5–300s). Fetches only real-time data: getLastPowerData, getOneDateEnergyBySn, and getEvChargerStatusBySn.

When alt polling mode is disabled, the integration behaves as before (single interval, all endpoints).

Per-inverter API call separation

Both normal and alt modes now make API calls per inverter serial instead of relying on a single bulk getdata() call. This improves reliability for multi-inverter accounts and enables per-inverter error handling.

Inverter staggering (round-robin)

In alt mode fast polls, only one inverter is polled per tick in a round-robin pattern. With 2 inverters at a 15s fast interval, each inverter gets fresh power data every 30s — halving API calls per tick.

Per-inverter error backoff

If an inverter fails 3 consecutive polls, it is temporarily skipped. A retry is attempted every 5th tick to check recovery, and the error counter resets on success.

Poll diagnostic sensors

Four new diagnostic entities per inverter:

Sensor Description
Poll Mode normal or alt
Last Poll Type full, fast, or normal
Last Full Poll UTC timestamp of last full poll
Poll Tick Count Monotonically increasing tick counter

Reliability & correctness fixes

This PR also includes a hardening pass across the integration:

  • Failed updates now surface properly. When the cloud API and all local fallbacks fail, the coordinator raises UpdateFailed so entities go unavailable instead of silently serving stale data forever.
  • Auth failures trigger reauth. A 401 from the API raises ConfigEntryAuthFailed, and a new reauth flow lets users re-enter their AppSecret from the UI (strings.json / en.json updated).
  • Resilient startup. async_setup_entry wraps the initial cloud call in ConfigEntryNotReady, so HA automatically retries setup if the cloud is down at boot instead of hard-failing.
  • entry.runtime_data migration. The coordinator now lives on entry.runtime_data (modern HA pattern). This also fixes a latent hass.data[alphaess] namespace collision where per-serial number-entity values and coordinators (keyed by entry_id) shared the same dict; number values (batUseCap/batHighCap) are now stored on the coordinator.
  • Services survive reloads. setbatterycharge/setbatterydischarge resolve the owning API client by serial at call time instead of capturing the client of whichever entry loaded first, and are unregistered when the last entry unloads.
  • Local-IP polling race fixed. Temporary mutation of the shared API client's ipaddress (per-inverter local polling and cloud-outage fallback) is now guarded by an asyncio.Lock, preventing requests from going to the wrong inverter under concurrency.
  • Timezone-correct date handling. All datetime.now() / time.strftime() / deprecated datetime.utcnow() usage replaced with HA's dt_util helpers, so "today's energy" queries use HA's configured timezone rather than the host OS timezone.
  • Future-proofed coordinator. DataUpdateCoordinator now receives config_entry explicitly (the implicit ContextVar lookup is deprecated and breaks in HA 2026.8).
  • Fresh data each cycle. The coordinator returns a per-serial copy of its data every update, so listeners comparing old/new state never see the same mutated reference.

Code quality

  • De-async'd the parser/helper layer (DataProcessor, TimeHelper, all InverterDataParser methods) — these were async without awaiting anything, creating hundreds of pointless coroutines per poll cycle.
  • Replaced magic display-string keys ("EV Charger S/N", "Local IP", …) with AlphaESSNames enum references throughout.
  • Lazy %s-style logging per HA guidelines, standardized logger names, moved function-level imports to module top, removed dead code (get_charge/get_time, unexplained startup sleep).

Diagnostics support

New diagnostics.py: config-entry diagnostics download with credentials, device serials, register keys, Wi-Fi credentials/SSID and IPs redacted — should make rate-limit/data issues much easier to triage from issue reports.

Tests & CI — 100% coverage, enforced

  • New test suite: 295 tests covering 100% of integration lines (coordinator polling/backoff/fallback paths, all six entity platforms, config/options/reauth/subentry flows, setup/migration/unload, services, device info, diagnostics redaction).
  • Tests use explicit mocks (no HA test-plugin runtime fixtures), so they run identically on Windows/macOS/Linux.
  • New CI workflow (.github/workflows/ci.yml): ruff lint + pytest --cov with a fail_under = 100 coverage gate — CI fails if coverage drops below 100%.
  • The test harness already caught one real bug: the coordinator's reliance on the deprecated ContextVar config-entry lookup (above).

Fixes #235

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an optional “alt polling mode” that splits polling into fast/slow cadences and refactors cloud polling to perform per-inverter API calls (enabling per-inverter backoff and round-robin fast polling), along with new diagnostic entities.

Changes:

  • Refactors coordinator polling to fetch cloud data per inverter, adds alt fast/slow polling mode with inverter staggering and error backoff.
  • Adds new config options (alt_polling_mode, fast_scan_interval_seconds) and UI strings to configure fast polling cadence.
  • Introduces per-inverter polling diagnostic sensors (poll mode/type, last full poll, tick count).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
custom_components/alphaess/coordinator.py Implements alt polling mode, per-inverter API calls, round-robin fast polling, and backoff + diagnostics injection.
custom_components/alphaess/__init__.py Reads new options and passes alt/fast intervals into the coordinator.
custom_components/alphaess/config_flow.py Adds options UI fields for alt polling mode and fast interval with validation.
custom_components/alphaess/const.py Adds constants for alt polling mode and fast scan interval bounds/defaults.
custom_components/alphaess/sensorlist.py Adds poll diagnostic sensor descriptions to the common sensor set.
custom_components/alphaess/enums.py Adds enum keys for new poll diagnostic sensors.
custom_components/alphaess/translations/en.json Updates options labels/descriptions for full/fast polling configuration.
custom_components/alphaess/strings.json Mirrors translation changes for the options flow strings.
custom_components/alphaess/manifest.json Bumps integration version to 0.8.5.
test_rate_limit.py Adds a standalone OpenAPI rate-limit stress test script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread custom_components/alphaess/coordinator.py Outdated
Comment thread custom_components/alphaess/coordinator.py Outdated
Comment thread custom_components/alphaess/coordinator.py Outdated
Comment thread custom_components/alphaess/coordinator.py
Comment thread custom_components/alphaess/coordinator.py Outdated
Comment thread custom_components/alphaess/sensorlist.py
Comment thread test_rate_limit.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test_rate_limit.py Outdated
Comment thread scripts/rate_limit_stress.py
Comment thread custom_components/alphaess/coordinator.py Outdated
Comment thread custom_components/alphaess/coordinator.py Outdated
Comment thread custom_components/alphaess/coordinator.py Outdated
Comment thread custom_components/alphaess/sensorlist.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 15 to 16
CONF_ALT_POLLING_MODE,
CONF_IP_ADDRESS,

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

CONF_ALT_POLLING_MODE and CONF_IP_ADDRESS are imported from const but never referenced in this module. Removing unused imports helps avoid lint failures and keeps the import list accurate.

Suggested change
CONF_ALT_POLLING_MODE,
CONF_IP_ADDRESS,

Copilot uses AI. Check for mistakes.
Comment on lines +796 to +810
# Include local IP data if available and this is the first inverter
if include_local_ip and self.api.ipaddress:
try:
ip_data = await self.api.getIPData()
if ip_data:
unit["LocalIPData"] = {
"type": "local_ip_data",
"ip": self.api.ipaddress,
**ip_data,
}
except asyncio.CancelledError:
raise
except Exception:
_LOGGER.debug("Failed to fetch local IP data", exc_info=True)

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

include_local_ip is passed into _fetch_inverter_data, but the guarded branch if include_local_ip and self.api.ipaddress: appears unreachable in the current flow: the client ipaddress is only set temporarily inside _fetch_per_inverter_local_data/_fallback_to_local_data and then reset to None. Either set self.api.ipaddress before calling _fetch_inverter_data when you intend to include LocalIPData, or remove include_local_ip and this dead branch.

Suggested change
# Include local IP data if available and this is the first inverter
if include_local_ip and self.api.ipaddress:
try:
ip_data = await self.api.getIPData()
if ip_data:
unit["LocalIPData"] = {
"type": "local_ip_data",
"ip": self.api.ipaddress,
**ip_data,
}
except asyncio.CancelledError:
raise
except Exception:
_LOGGER.debug("Failed to fetch local IP data", exc_info=True)

Copilot uses AI. Check for mistakes.
Comment on lines +662 to +713
serial = serials[self._fast_poll_index % len(serials)]
self._fast_poll_index += 1

# Per-inverter error backoff
err_count = self._inverter_error_count.get(serial, 0)
if err_count >= self._ERROR_BACKOFF_THRESHOLD:
# Retry every N cycles to see if it recovers
if self._poll_tick_count % self._ERROR_BACKOFF_CYCLES != 0:
_LOGGER.debug(
f"Alt mode: skipping {serial} (backed off, {err_count} consecutive errors)"
)
self._update_diagnostics()
return self.data

_LOGGER.debug(f"Alt mode: fast poll for {serial}")
try:
import time
# getLastPowerData — real-time watts/SOC (skip for unsupported models)
model = self.data[serial].get("Model")
if model not in LOWER_INVERTER_API_CALL_LIST:
power_data = await self.api.getLastPowerData(serial)
if power_data:
parsed = await self.parser.parse_power_data(power_data, None)
self.data[serial].update(parsed)
await asyncio.sleep(throttle_delay)

# getOneDateEnergyBySn — daily energy counters
energy_data = await self.api.getOneDateEnergyBySn(
serial, time.strftime("%Y-%m-%d")
)
if energy_data:
parsed = await self.parser.parse_energy_data(energy_data)
self.data[serial].update(parsed)
await asyncio.sleep(throttle_delay)

# EV charger status if one is known
ev_sn = self.data[serial].get(AlphaESSNames.evchargersn)
if ev_sn:
ev_status = await self.api.getEvChargerStatusBySn(serial, ev_sn)
if ev_status:
self.data[serial][AlphaESSNames.evchargerstatus] = ev_status.get("evchargerStatus")
self.data[serial][AlphaESSNames.evchargerstatusraw] = ev_status.get("evchargerStatus")
await asyncio.sleep(throttle_delay)

# Clear error count on success
self._inverter_error_count[serial] = 0
except asyncio.CancelledError:
raise
except Exception as err:
_LOGGER.debug(f"Alt mode fast poll failed for {serial}: {err}")
self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

In alt mode fast polls, when an inverter is in backoff the code returns early for the whole tick (_update_diagnostics(); return self.data). This prevents polling any other inverter on that tick. Consider advancing the round-robin index and selecting the next eligible inverter (or looping until one is polled / all are skipped) instead of returning immediately.

Suggested change
serial = serials[self._fast_poll_index % len(serials)]
self._fast_poll_index += 1
# Per-inverter error backoff
err_count = self._inverter_error_count.get(serial, 0)
if err_count >= self._ERROR_BACKOFF_THRESHOLD:
# Retry every N cycles to see if it recovers
if self._poll_tick_count % self._ERROR_BACKOFF_CYCLES != 0:
_LOGGER.debug(
f"Alt mode: skipping {serial} (backed off, {err_count} consecutive errors)"
)
self._update_diagnostics()
return self.data
_LOGGER.debug(f"Alt mode: fast poll for {serial}")
try:
import time
# getLastPowerData — real-time watts/SOC (skip for unsupported models)
model = self.data[serial].get("Model")
if model not in LOWER_INVERTER_API_CALL_LIST:
power_data = await self.api.getLastPowerData(serial)
if power_data:
parsed = await self.parser.parse_power_data(power_data, None)
self.data[serial].update(parsed)
await asyncio.sleep(throttle_delay)
# getOneDateEnergyBySn — daily energy counters
energy_data = await self.api.getOneDateEnergyBySn(
serial, time.strftime("%Y-%m-%d")
)
if energy_data:
parsed = await self.parser.parse_energy_data(energy_data)
self.data[serial].update(parsed)
await asyncio.sleep(throttle_delay)
# EV charger status if one is known
ev_sn = self.data[serial].get(AlphaESSNames.evchargersn)
if ev_sn:
ev_status = await self.api.getEvChargerStatusBySn(serial, ev_sn)
if ev_status:
self.data[serial][AlphaESSNames.evchargerstatus] = ev_status.get("evchargerStatus")
self.data[serial][AlphaESSNames.evchargerstatusraw] = ev_status.get("evchargerStatus")
await asyncio.sleep(throttle_delay)
# Clear error count on success
self._inverter_error_count[serial] = 0
except asyncio.CancelledError:
raise
except Exception as err:
_LOGGER.debug(f"Alt mode fast poll failed for {serial}: {err}")
self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1
for _ in range(len(serials)):
serial = serials[self._fast_poll_index % len(serials)]
self._fast_poll_index += 1
# Per-inverter error backoff
err_count = self._inverter_error_count.get(serial, 0)
if err_count >= self._ERROR_BACKOFF_THRESHOLD:
# Retry every N cycles to see if it recovers
if self._poll_tick_count % self._ERROR_BACKOFF_CYCLES != 0:
_LOGGER.debug(
f"Alt mode: skipping {serial} (backed off, {err_count} consecutive errors)"
)
continue
_LOGGER.debug(f"Alt mode: fast poll for {serial}")
try:
import time
# getLastPowerData — real-time watts/SOC (skip for unsupported models)
model = self.data[serial].get("Model")
if model not in LOWER_INVERTER_API_CALL_LIST:
power_data = await self.api.getLastPowerData(serial)
if power_data:
parsed = await self.parser.parse_power_data(power_data, None)
self.data[serial].update(parsed)
await asyncio.sleep(throttle_delay)
# getOneDateEnergyBySn — daily energy counters
energy_data = await self.api.getOneDateEnergyBySn(
serial, time.strftime("%Y-%m-%d")
)
if energy_data:
parsed = await self.parser.parse_energy_data(energy_data)
self.data[serial].update(parsed)
await asyncio.sleep(throttle_delay)
# EV charger status if one is known
ev_sn = self.data[serial].get(AlphaESSNames.evchargersn)
if ev_sn:
ev_status = await self.api.getEvChargerStatusBySn(serial, ev_sn)
if ev_status:
self.data[serial][AlphaESSNames.evchargerstatus] = ev_status.get("evchargerStatus")
self.data[serial][AlphaESSNames.evchargerstatusraw] = ev_status.get("evchargerStatus")
await asyncio.sleep(throttle_delay)
# Clear error count on success
self._inverter_error_count[serial] = 0
except asyncio.CancelledError:
raise
except Exception as err:
_LOGGER.debug(f"Alt mode fast poll failed for {serial}: {err}")
self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1
break

Copilot uses AI. Check for mistakes.
Comment on lines +631 to +635
for idx, unit in enumerate(units):
serial = unit.get("sysSn")
if not serial:
continue
try:

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

Alt-mode full polls iterate all inverters without applying the same backoff/skip gating used in normal mode and alt fast polls. This differs from the PR description (“skip after 3 consecutive polls, retry every 5th tick”) and can keep hammering a failing inverter on every full poll. Consider applying the backoff check inside this loop (or update the description if full polls are intentionally exempt).

Copilot uses AI. Check for mistakes.
@Poshy163

Copy link
Copy Markdown
Collaborator Author

Ill be holding off this release till i can figure out what to do about #250, as it seems like they may be more endpoints/updated endpoints coming

@Poshy163 Poshy163 marked this pull request as ready for review June 11, 2026 06:36
@Poshy163

Copy link
Copy Markdown
Collaborator Author

@CharlesGillanders bump

@Poshy163 Poshy163 merged commit 8a16c75 into main Jun 12, 2026
3 checks passed
@Poshy163 Poshy163 deleted the alt-mode branch June 12, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scan interval

3 participants