Skip to content

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

Draft
Poshy163 wants to merge 4 commits intomainfrom
alt-mode
Draft

Alt Polling Mode & Per-inverter API call separation#249
Poshy163 wants to merge 4 commits intomainfrom
alt-mode

Conversation

@Poshy163
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

Fixes #235

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
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 Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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,
Copy link

Copilot AI Apr 11, 2026

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)

Copy link

Copilot AI Apr 11, 2026

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

Copy link

Copilot AI Apr 11, 2026

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:
Copy link

Copilot AI Apr 11, 2026

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

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

2 participants