Conversation
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| CONF_ALT_POLLING_MODE, | ||
| CONF_IP_ADDRESS, |
There was a problem hiding this comment.
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.
| CONF_ALT_POLLING_MODE, | |
| CONF_IP_ADDRESS, |
| # 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) | ||
|
|
There was a problem hiding this comment.
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.
| # 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) |
| 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 | ||
|
|
There was a problem hiding this comment.
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.
| 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 |
| for idx, unit in enumerate(units): | ||
| serial = unit.get("sysSn") | ||
| if not serial: | ||
| continue | ||
| try: |
There was a problem hiding this comment.
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).
|
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 |
Alt Polling Mode
Adds an optional alt polling mode that splits API polling into two cadences:
scan_interval(default 60s). Fetches all endpoints per inverter (energy, power, config, EV charger, summary, etc.).fast_scan_interval(default 15s, range 5–300s). Fetches only real-time data:getLastPowerData,getOneDateEnergyBySn, andgetEvChargerStatusBySn.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:
Poll ModenormaloraltLast Poll Typefull,fast, ornormalLast Full PollPoll Tick CountFixes #235