"""Enphase Envoy class"""

import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import replace
from functools import cached_property, partial
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, TypeVar, overload

import aiohttp
import orjson
from awesomeversion import AwesomeVersion
from envoy_utils.envoy_utils import EnvoyUtils
from tenacity import (
    AsyncRetrying,
    before_sleep_log,
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    stop_after_delay,
    wait_random_exponential,
)

from pyenphase.models.dry_contacts import DryContactStatus
from pyenphase.models.home import EnvoyInterfaceInformation

from .auth import (
    EnvoyAuth,
    EnvoyLegacyAuth,
    EnvoyTokenAuth,
)
from .const import (
    AUTH_TOKEN_MIN_VERSION,
    DEFAULT_MAX_REQUEST_ATTEMPTS,
    DEFAULT_MAX_REQUEST_DELAY,
    ENDPOINT_URL_HOME,
    GENERATOR_EXERCISE_DAYS,
    GENERATOR_MODES,
    GENERATOR_SCHEDULE_SETTINGS,
    LOCAL_TIMEOUT,
    MAX_PROBE_REQUEST_ATTEMPTS,
    MAX_PROBE_REQUEST_DELAY,
    URL_ACB_CONFIG,
    URL_DRY_CONTACT_SETTINGS,
    URL_DRY_CONTACT_STATUS,
    URL_GEN_CONFIG,
    URL_GEN_MODE,
    URL_GEN_SCHEDULE,
    URL_GRID_RELAY,
    URL_TARIFF,
    SupportedFeatures,
)
from .exceptions import (
    EnvoyAuthenticationRequired,
    EnvoyCommunicationError,
    EnvoyError,
    EnvoyFeatureNotAvailable,
    EnvoyHTTPStatusError,
    EnvoyPoorDataQuality,
    EnvoyProbeFailed,
)
from .firmware import EnvoyFirmware
from .json import json_loads
from .models.common import CommonProperties
from .models.envoy import EnvoyData
from .models.generator import EnvoyGeneratorConfig, EnvoyGeneratorSchedule
from .models.meters import CtType, EnvoyPhaseMode
from .models.tariff import EnvoyStorageMode
from .ssl import NO_VERIFY_SSL_CONTEXT
from .updaters.api_v1_production import EnvoyApiV1ProductionUpdater
from .updaters.api_v1_production_inverters import EnvoyApiV1ProductionInvertersUpdater
from .updaters.base import EnvoyUpdater
from .updaters.device_data_inverters import EnvoyDeviceDataInvertersUpdater
from .updaters.ensemble import EnvoyEnembleUpdater
from .updaters.generator import EnvoyGeneratorUpdater
from .updaters.inventory import EnvoyInventoryUpdater
from .updaters.meters import EnvoyMetersUpdater
from .updaters.production import (
    EnvoyProductionJsonFallbackUpdater,
    EnvoyProductionJsonUpdater,
    EnvoyProductionUpdater,
)
from .updaters.tariff import EnvoyTariffUpdater

_LOGGER = logging.getLogger(__name__)

_ModelT = TypeVar("_ModelT")


DEFAULT_HEADERS = {
    "Accept": "application/json",
}

UPDATERS: list[type["EnvoyUpdater"]] = [
    EnvoyMetersUpdater,
    EnvoyProductionJsonUpdater,
    EnvoyProductionUpdater,
    EnvoyApiV1ProductionUpdater,
    EnvoyProductionJsonFallbackUpdater,
    EnvoyDeviceDataInvertersUpdater,
    EnvoyApiV1ProductionInvertersUpdater,
    EnvoyEnembleUpdater,
    EnvoyInventoryUpdater,
    EnvoyTariffUpdater,
    EnvoyGeneratorUpdater,
]  #: Ordered list of standard updaters for Envoy data collection


def register_updater(updater: type[EnvoyUpdater]) -> Callable[[], None]:
    """
    Register an updater in the updaters list.

    Registers a callable updater at the end of the updaters list. Probe
    method of the registered updater will be called during Envoy.probe().

    Use the returned callable to remove the registered updater again.

    After registering or removing an updater, use Envoy.probe() to make
    the change effective.

    :param updater: callable of (sub-) class EnvoyUpdater
    :return: callable to remove the updater from the registration list
    """
    UPDATERS.append(updater)

    def _remove_updater() -> None:
        """Callable to remove a prior registered updater."""
        UPDATERS.remove(updater)

    return _remove_updater


def get_updaters() -> list[type[EnvoyUpdater]]:
    """
    Return list of registered updaters.

    Returns current list of registered updaters. Changes
    to the list will not be in effect until
    Envoy.probe() is used.

    :return: list of callable updaters
    """
    return UPDATERS


class Envoy:
    """Class for communicating with an envoy."""

    def __init__(
        self,
        host: str,
        client: aiohttp.ClientSession | None = None,
        timeout: float | aiohttp.ClientTimeout | None = None,
        v2_acb_mode: bool = True,
    ) -> None:
        """
        Class for communicating with an envoy.

        Collects solar production data for all Envoy models as of
        firmware 3.9. Depending on model and installed components
        can collect power/energy consumption, battery charge,
        discharge and settings. Supports communication with both
        pre- and post V7 Envoy firmware.

        .. code-block:: python

            envoy = Envoy(host_ip_or_name)
            await envoy.setup()
            await envoy.authenticate(
                username=username,
                password=password,
                token=token
            )
            await envoy.update()
            # ...
            await envoy.close()

        :param host: Envoy DNS name or IP address
        :param client: aiohttp ClientSession not verifying SSL
            certificates, if not specified one will be created. In
            that case call :py:meth:`Envoy.close` before application
            exit.
        :param timeout: aiohttp ClientTimeout to use, if not specified
            10 sec connection and 45 sec read timeouts will be used
            (:any:`LOCAL_TIMEOUT`).
        :param v2_acb_mode: in v3 acb data is not included in inverter data as it
            is (incorrectly) in v2. This may break applications. Use this mode to
            include acb battery data in inverter data. In v3 this defaults to True.
            In a future version this default will be set to False.
        """
        # We use our own aiohttp client session so we can disable SSL verification (Envoys use self-signed SSL certs)
        self._timeout = timeout or LOCAL_TIMEOUT
        connector = aiohttp.TCPConnector(ssl=NO_VERIFY_SSL_CONTEXT)
        self._client = client or aiohttp.ClientSession(connector=connector)  # nosec
        self._user_client = client is not None
        self.auth: EnvoyAuth | None = None
        self._host = host
        self._firmware = EnvoyFirmware(self._client, self._host)
        self._supported_features: SupportedFeatures | None = None
        self._updaters: list[EnvoyUpdater] = []
        self._endpoint_cache: dict[str, aiohttp.ClientResponse] = {}
        self.data: EnvoyData | None = None
        self._common_properties: CommonProperties = CommonProperties()
        self._interface_settings: EnvoyInterfaceInformation | None = None
        self._request_max_attempts: int = DEFAULT_MAX_REQUEST_ATTEMPTS
        self._request_max_delay: int = DEFAULT_MAX_REQUEST_DELAY
        self._request_wait_multiplier: float = 2
        self._request_last_attempts: int = 0
        self._request_last_elapsed: float = 0.0
        self._request_last_endpoint: str = ""
        self._v2_acb_mode: bool = v2_acb_mode

    async def setup(self) -> None:
        """
        Initiate Envoy communication by obtaining firmware version.

        Read /info on Envoy, accessible without authentication.
        Instantiates EnvoyFirmware class object. Required to
        decide what authentication to use for sub-sequent Envoy
        communication. Use this method as first step after class instantiation

        Will retry up to :any:`MAX_PROBE_REQUEST_ATTEMPTS` times or
        :any:`MAX_PROBE_REQUEST_DELAY` elapsed at next try, which
        ever comes first.

        :raises EnvoyFirmwareFatalCheckError: if connection or timeout
            failure occurs
        :raises EnvoyFirmwareCheckError: on http errors or any HTTP
            status other then 200
        """
        await self._firmware.setup()
        # force refetch of interface data next time requested
        self._interface_settings = None

    async def close(self) -> None:
        """
        Close or clean anything opened or created on behalf of the caller.

        Should be called when ending application, if:

        - no aiohttp ClientSession was specified for the Envoy:

          - the pyenphase-created ClientSession will be closed.

        - an aiohttp ClientSession was provided by the caller:

          - Envoy will not close the provided session; the caller remains responsible.

        :return: None
        """
        if not self._user_client and not self._client.closed:
            await self._client.close()

    async def authenticate(
        self,
        username: str | None = None,
        password: str | None = None,
        token: str | None = None,
    ) -> None:
        """
        Authenticate to the Envoy based on firmware version.

        If firmware version retrieved in Envoy.setup is < 7 then create DigestAuth using
        passed username and password. Use 'envoy' or 'installer' username and blank password.

        If Firmware is >= 7 create JWT Token based authorization. If token is passed, use
        it for authorization. If no token is passed, username and password should be
        Enlighten Cloud credentials to obtain a token. Validate the token with the local Envoy.

        :param username: Enligthen Cloud username or local Envoy username, defaults to None
        :param password: Enligthen Cloud password or local Envoy password, defaults to None
        :param token: Token to use with authentication, defaults to None
        :raises EnvoyAuthenticationRequired: Authentication failed with the local Envoy,
            provided token is expired or no token could be obtained from Enlighten cloud
            due to error or missing parameters.
        """
        if self._firmware.version < AUTH_TOKEN_MIN_VERSION:
            # Envoy firmware using old envoy/installer authentication
            _LOGGER.debug(
                "FW: %s, Authenticating to Envoy using envoy/installer authentication",
                self._firmware.version,
            )
            full_serial = self._firmware.serial
            if not username or username == "installer":
                username = "installer"
                password = EnvoyUtils.get_password(full_serial, username)
            elif username == "envoy" and not password:
                # The default password for the envoy user is the last 6 digits of the serial number
                assert full_serial is not None, "Serial must be set"  # nosec
                password = full_serial[-6:]

            if username and password:
                self.auth = EnvoyLegacyAuth(self.host, username, password)

        else:
            # Envoy firmware using new token authentication
            _LOGGER.debug(
                "FW: %s, Authenticating to Envoy using token authentication",
                self._firmware.version,
            )
            if token or (username and password):
                # Always pass all the data to the token auth class, even if some of it is None
                # so that we can refresh the token if needed
                self.auth = EnvoyTokenAuth(
                    self.host,
                    cloud_username=username,
                    cloud_password=password,
                    envoy_serial=self._firmware.serial,
                    token=token,
                )

        if not self.auth:
            _LOGGER.error(
                "You must include username/password or token to authenticate to the Envoy."
            )
            raise EnvoyAuthenticationRequired("Could not setup authentication object.")

        await self.auth.setup(self._client)

    @retry(
        retry=retry_if_exception_type(
            (
                aiohttp.ClientError,
                asyncio.TimeoutError,
            )
        ),
        wait=wait_random_exponential(multiplier=2, max=5),
        stop=stop_after_delay(MAX_PROBE_REQUEST_DELAY)
        | stop_after_attempt(MAX_PROBE_REQUEST_ATTEMPTS),
        reraise=True,
    )
    async def probe_request(self, endpoint: str) -> aiohttp.ClientResponse:
        """
        Make a probe request to the Envoy.

        Probe requests are intended for use during initial
        search of available features in the Envoy. For regular
        data retrieval, use the :py:meth:`pyenphase.Envoy.request`
        or :py:meth:`pyenphase.Envoy.update` methods. Sends GET
        request to endpoint on Envoy and returns the response.

        Probe retries on client connection issues or timeouts.
        Will retry up to :any:`MAX_PROBE_REQUEST_ATTEMPTS` times
        or :any:`MAX_PROBE_REQUEST_DELAY` sec elapsed at next try,
        which ever comes first.

        :param endpoint: Envoy Endpoint to access, start with leading /.
        :raises EnvoyAuthenticationRequired: if no prior authentication
            was completed or HTTP status 401 or 404 is returned.
        :return: request response.
        """
        return await self._request(endpoint)

    async def request(
        self,
        endpoint: str,
        data: dict[str, Any] | None = None,
        method: str | None = None,
    ) -> aiohttp.ClientResponse:
        """
        Make a request to the Envoy.

        Send GET or POST request to Envoy. Defaults to GET, specify
        data dictionary to perform a POST. Only specify the endpoint
        path in the Envoy, HTTP type and Envoy address is prepended
        to form full URL based on authentication method.

        Request retries on client connection issues or timeouts.
        By default will retry up to :any:`DEFAULT_MAX_REQUEST_ATTEMPTS` times or
        :any:`DEFAULT_MAX_REQUEST_DELAY` sec elapsed at next try, which
        ever comes first. Adjust these settings using
        :py:meth:`pyenphase.Envoy.set_retry_policy`

        :param endpoint: Envoy Endpoint to access, start with leading /
        :param data: optional data dictionary to send to the Envoy.
            Defaults to none, if none a GET request is issued.
        :param method: HTTP method to use when sending data dictionary,
            if none and data is specified, POST is default.
        :raises EnvoyAuthenticationRequired: if no prior authentication
            was completed or HTTP status 401 or 404 is returned.
        :raises: Any communication errors when retries are exceeded
        :return: request response.
        """
        self._request_last_attempts = 0
        self._request_last_elapsed = 0
        self._request_last_endpoint = endpoint

        async for attempt in AsyncRetrying(
            wait=wait_random_exponential(
                multiplier=self._request_wait_multiplier, max=5
            ),
            stop=stop_after_delay(self._request_max_delay)
            | stop_after_attempt(self._request_max_attempts),
            retry=retry_if_exception_type(
                (
                    aiohttp.ClientError,
                    asyncio.TimeoutError,
                )
            ),
            reraise=True,
            before_sleep=before_sleep_log(_LOGGER, logging.DEBUG),
        ):
            with attempt:
                result = await self._request(endpoint, data, method)
            self._request_last_attempts = attempt.retry_state.attempt_number
            self._request_last_elapsed = attempt.retry_state.seconds_since_start
        return result

    def set_retry_policy(
        self,
        *,
        max_delay: int | None = None,
        max_attempts: int | None = None,
        wait_multiplier: float | None = None,
    ) -> None:
        """
        Set request retry parameters for request retries. Retry
        attempts will continue until either max_attempts have been
        tried or maximum elapsed time in seconds since first try has
        passed at next attempt. Applies to :py:meth:`pyenphase.Envoy.update`
        and :py:meth:`pyenphase.Envoy.request` methods.

        :param max_delay: maximum time elapsed allowed in seconds since first try.
            Optional, if not specified, setting is not changed. Default setting
            for each instantiated Envoy is :any:`DEFAULT_MAX_REQUEST_DELAY`.
        :param max_attempts: maximum retry attempts. Optional, if not specified,
            setting is not changed. Default setting for each instantiated Envoy
            is :any:`DEFAULT_MAX_REQUEST_ATTEMPTS`.
        :param wait_multiplier: multiplier to increase wait time between
            retry attempts. Optional, if not specified, setting is not changed.
            Default setting for each instantiated Envoy
            is 2. Set to zero to disable waits between retries
        """
        if max_attempts is not None:
            self._request_max_attempts = max_attempts
        if max_delay is not None:
            self._request_max_delay = max_delay
        if wait_multiplier is not None:
            self._request_wait_multiplier = wait_multiplier
        _LOGGER.debug(
            "Request retries set to %s attempts and %s seconds maximum elapsed time, wait multiplier %s",
            self._request_max_attempts,
            self._request_max_delay,
            self._request_wait_multiplier,
        )

    @property
    def last_request_statistics(self) -> dict[str, str | int | float | None]:
        """
        Return statistics of last request call.

        Returns retry information on last executed
        :py:meth:`pyenphase.Envoy.request` method. Provides
        information on retry endpoint, attempts and elapsed time.

        :return: {"endpoint": str,"attempt_number": int,"delay_since_first_attempt": float}.
        """
        return {
            "endpoint": self._request_last_endpoint,
            "attempt_number": self._request_last_attempts,
            "delay_since_first_attempt": self._request_last_elapsed,
        }

    async def _request(
        self,
        endpoint: str,
        data: dict[str, Any] | None = None,
        method: str | None = None,
    ) -> aiohttp.ClientResponse:
        """
        Make a request to the Envoy.

        If data is specified use POST or specified method to
        send data dictionary as json string to the endpoint.
        If no data is specified use GET request. Return the response.

        :param endpoint: Envoy Endpoint to access, start with leading /
        :param data: data dictionary to send to the Envoy, defaults to None
        :param method: method to use to send data dictionary,
            POST if none, only used for data send
        :raises EnvoyAuthenticationRequired: if no prior authentication
            was completed or HTTP status 401 or 404 is returned
        :return: request response
        """
        if self.auth is None:
            raise EnvoyAuthenticationRequired(
                "You must authenticate to the Envoy before making requests."
            )

        url = self.auth.get_endpoint_url(endpoint)
        debugon = _LOGGER.isEnabledFor(logging.DEBUG)
        if debugon:
            request_start = time.monotonic()

        # Set up middleware from auth
        middlewares = (self.auth.auth,) if self.auth.auth else None

        # not using redirects to avoid following 301s to error pages on missing
        # end points and lots of extra requests
        if data:
            if debugon:
                _LOGGER.debug(
                    "Sending %s to %s with data %s",
                    method or "POST",
                    url,
                    orjson.dumps(data),
                )
            response = await self._client.request(
                method or "POST",
                url,
                headers={**DEFAULT_HEADERS, **self.auth.headers},
                timeout=self._timeout,
                data=orjson.dumps(data),
                middlewares=middlewares,
                allow_redirects=False,
            )
        else:
            _LOGGER.debug("Requesting %s with timeout %s", url, self._timeout)
            response = await self._client.get(
                url,
                headers={**DEFAULT_HEADERS, **self.auth.headers},
                timeout=self._timeout,
                middlewares=middlewares,
                allow_redirects=False,
            )

        status_code = response.status
        if status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
            content = await response.read()
            _LOGGER.debug(
                "Authentication failed for %s with status %s: %s",
                url,
                status_code,
                content[:500] if content else "No content",
            )
            raise EnvoyAuthenticationRequired(
                f"Authentication failed for {url} with status {status_code}, "
                "please check your username/password or token."
            )

        # show all responses centrally when in debug
        if debugon:
            request_end = time.monotonic()
            content_type = response.headers.get("content-type")
            _LOGGER.debug(
                "Request reply in %s sec from %s status %s: %s %s",
                round(request_end - request_start, 1),
                url,
                status_code,
                content_type,
                await response.read(),  # Use the actual content bytes
            )

        return response

    async def interface_settings(self) -> EnvoyInterfaceInformation | None:
        """
        Returns Envoy active interface information.

        Returned data includes interface mac, interface type, software
        build date, configured timezone and DHCP settings

        This data is sourced from the /home endpoint which is a slower
        responding endpoint with some potential overhead on the Envoy.
        For this reason, as well as the fact that the data is static,
        it will only be fetched one time when called first time and
        cached internally. Subsequent calls will be fulfilled from the cache.
        A call to envoy.setup() will invalidate the cached data and
        result in a one-time read from Envoy upon next call.

        :return: Interface details or None if error

        """
        if not self._interface_settings:
            try:
                home_json = await self._json_request(
                    end_point=ENDPOINT_URL_HOME, data=None
                )
                self._interface_settings = EnvoyInterfaceInformation.from_api(home_json)
            except EnvoyError as exc:
                _LOGGER.debug("Failure getting interface information %s", exc)
        return self._interface_settings

    @property
    def host(self) -> str:
        """Return the Envoy host specified at initialization."""
        return self._host

    @property
    def firmware(self) -> AwesomeVersion:
        """Return the Envoy firmware version as read from the Envoy."""
        return self._firmware.version

    @property
    def part_number(self) -> str | None:
        """Return the Envoy part number as read from the Envoy."""
        return self._firmware.part_number

    @property
    def serial_number(self) -> str | None:
        """Return the Envoy serial number as read from the Envoy."""
        return self._firmware.serial

    @property
    def is_metered(self) -> bool:
        """Return the Envoy imetered info as read from the Envoy."""
        return self._firmware.is_metered

    @property
    def supported_features(self) -> SupportedFeatures:
        """Return the mask of Envoy supported features as established during Probe."""
        assert self._supported_features is not None, "Call setup() first"  # nosec
        return self._supported_features

    @property
    def phase_count(self) -> int:
        """Return the number of configured phases for CT meters as read from the Envoy."""
        assert self._common_properties is not None, "Call setup() first"  # nosec
        return self._common_properties.phase_count

    @property
    def active_phase_count(self) -> int:
        """Return the number of actual reported phases in Envoy production/consumption report."""
        assert self._common_properties is not None, "Call setup() first"  # nosec
        return self._common_properties.active_phase_count

    @property
    def ct_meter_count(self) -> int:
        """Return the number of configured current transformers (CT) as read from the Envoy"""
        assert self._common_properties is not None, "Call setup() first"  # nosec
        return self._common_properties.ct_meter_count

    @overload
    def _meter_type(self, *types: CtType) -> CtType | None: ...

    @overload
    def _meter_type(self, *types: str) -> str | None: ...

    def _meter_type(self, *types: CtType | str) -> CtType | str | None:
        """Return if ct meter type is installed."""
        assert self._common_properties is not None, "Call setup() first"  # nosec
        if not self._common_properties.meter_types:
            return None
        for type in types:
            if type in self._common_properties.meter_types:
                return type
        return None

    @property
    def consumption_meter_type(self) -> CtType | None:
        """
        Return the type of consumption ct meter installed (total
        or net-consumption or None) as read from the Envoy.
        """
        return self._meter_type(CtType.NET_CONSUMPTION, CtType.TOTAL_CONSUMPTION)

    @property
    def production_meter_type(self) -> CtType | None:
        """
        Return the type of production ct meter installed
        (Production or None) as read from the Envoy.
        """
        return self._meter_type(CtType.PRODUCTION)

    @property
    def storage_meter_type(self) -> CtType | None:
        """Return the type of storage ct meter installed (Storage or None) as read from the Envoy."""
        return self._meter_type(CtType.STORAGE)

    @overload
    def meter_type(self, meter_type: CtType) -> CtType | None: ...

    @overload
    def meter_type(self, meter_type: str) -> str | None: ...

    def meter_type(self, meter_type: CtType | str) -> CtType | str | None:
        """Return the type of ct meter installed as read from the Envoy."""
        return self._meter_type(meter_type)

    @property
    def ct_meter_list(self) -> list[CtType | str]:
        """Return the list of configured current transformers (CT) as read from the Envoy"""
        assert self._common_properties is not None, "Call setup() first"  # nosec
        return self._common_properties.meter_types

    @property
    def phase_mode(self) -> EnvoyPhaseMode | None:
        """Return the phase mode configured for the CT meters (single, split or three) as read from the Envoy."""
        assert self._common_properties is not None, "Call setup() first"  # nosec
        return self._common_properties.phase_mode

    @property
    def acb_count(self) -> int:
        """Return the number of reported ACB batteries in Production storage report section."""
        assert self._common_properties is not None, "Call setup() first"  # nosec
        return self._common_properties.acb_batteries_reported

    @cached_property
    def envoy_model(self) -> str:
        """
        Return Envoy model description.

        Describes the Envoy model based on properties found.

        - if 2 or more phases found or at least 1 ct is found:
        - -  phase count
        - -  phase mode
        - if CT found, type of CTs

        Example: "Envoy, phases: 2, phase mode: split, net-consumption CT, production CT"

        :return: String describing the Envoy model and features.
        """
        model = "Envoy"

        # if CT and more then 1 phase add phase count to model
        ct_count = self.ct_meter_count
        phase_count = self.phase_count
        if phase_count > 1 or ct_count > 0:
            model = f"{model}, phases: {phase_count}"

            # Add phase mode to model
            phase_mode = self.phase_mode
            model = f"{model}, phase mode: {phase_mode}"

        for ct_type in self.ct_meter_list:
            model = f"{model}, {ct_type} CT"

        return model

    async def _make_cached_request(
        self,
        request_func: Callable[[str], Awaitable[aiohttp.ClientResponse]],
        endpoint: str,
    ) -> aiohttp.ClientResponse:
        """Make a cached request."""
        if cached_response := self._endpoint_cache.get(endpoint):
            return cached_response
        response = await request_func(endpoint)
        if response.status == 200:
            self._endpoint_cache[endpoint] = response
        return response

    async def probe(self) -> None:
        """
        Probe for Envoy model and supported features.

        For each updater in the list of updaters returned by get_updaters,
        execute the probe() method. Build and store a list of updaters to use,
        containing updaters for which the probe() method does return at least 1
        supported feature. Store the map of all returned supported features.

        An updaters probe method should determine if the data for the specific
        updater scope is available or not. If so, the updaters update() method will
        be used during data collection.

        Probe should be used only once, after setup and authorization at the start of
        the communication. The update() method will call probe if not done prior.

        :raises EnvoyProbeFailed: if no solar production data can be found on the Envoy.
            Solar production data is available in all Envoy models.
        """
        supported_features = SupportedFeatures(0)
        updaters: list[EnvoyUpdater] = []
        version = self._firmware.version
        metered = self.is_metered
        v2_acb_mode = self._v2_acb_mode
        self._endpoint_cache.clear()
        cached_probe = partial(self._make_cached_request, self.probe_request)
        cached_request = partial(self._make_cached_request, self.request)
        self._common_properties.reset_probe_properties(
            is_metered=metered, v2_acb_mode=v2_acb_mode
        )

        for updater in get_updaters():
            klass = updater(
                version, cached_probe, cached_request, self._common_properties
            )
            if updater_features := await klass.probe(supported_features):
                supported_features |= updater_features
                updaters.append(klass)

        if not supported_features & SupportedFeatures.PRODUCTION:
            raise EnvoyProbeFailed("Unable to determine production endpoint")

        self._updaters = updaters
        self._supported_features = supported_features

    def _validate_update(self, data: EnvoyData) -> None:
        """
        Perform some overall validation checks and raise for issues.

        Envoy data returned can be impacted by the state of the Envoy. This
        validates searches for known cases and rules the data as invalid.
        INtention is to avoid false data reported and have it rather signalled
        as poor quality.

        - Envoy firmware v3 starts communicating before all data is established
            and sends zero values for solar production during first minutes
            after startup. Signal poor data quality as long as all values are 0.

        :param data: Envoy data
        :raises EnvoyPoorDataQuality: data was deemed invalid based on specific quality tests
        """
        if self._firmware.version.major == "3" and data.system_production:
            # FW R3.x will return status 200 with all zeros right after startup
            # where never versions return status 503 to signal not ready yet
            # raise error to avoid inserting zero's in historical series.
            production = data.system_production
            if (
                production.watts_now
                + production.watt_hours_today
                + production.watt_hours_last_7_days
                + production.watt_hours_lifetime
            ) == 0:
                raise EnvoyPoorDataQuality(
                    "Data rejected on rule: "
                    f"FW 3.x production all zero at startup ({self._firmware.version})."
                )

    async def update(self) -> EnvoyData:
        """
        Read data from Envoy.

        For each updater in the list of established updaters during probe(),
        execute the update() method to collect current data from the Envoy.
        If probe was never executed, use probe method first.

        An updaters update() method should obtain the data for the specific
        updater scope and save to the Envoy data set.

        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: Collected Envoy data
        """
        # Some of the updaters user the same endpoint
        # so we cache the 200 responses for each update
        # cycle to not make duplicate requests
        self._endpoint_cache.clear()
        if not self._supported_features:
            await self.probe()

        data = EnvoyData()
        for updater in self._updaters:
            try:
                await updater.update(data)
            except aiohttp.ClientError as err:  # noqa: PERF203
                raise EnvoyCommunicationError(f"aiohttp ClientError {err!s}") from err
            except asyncio.TimeoutError as err:
                raise EnvoyCommunicationError(f"Timeout {err!s}") from err

        self._validate_update(data)
        self.data = data
        return data

    async def _json_request(
        self, end_point: str, data: dict[str, Any] | None, method: str | None = None
    ) -> Any:
        """
        Make a request to the Envoy and return the JSON response.

        Uses _request() to obtain response and returns response content
        as formatted JSON.

        :param endpoint: Envoy Endpoint to access, start with leading /
        :param data: data dictionary to send to the Envoy, defaults to None
        :param method: method to use to send data dictionary,
            POST if none, only used for data send
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: response content as JSON
        """
        try:
            response = await self._request(end_point, data, method)
        except aiohttp.ClientError as err:
            _LOGGER.debug("Request to %s failed with ClientError: %s", end_point, err)
            raise EnvoyCommunicationError(f"aiohttp ClientError {err!s}") from err
        except asyncio.TimeoutError as err:
            _LOGGER.debug("Request to %s timed out: %s", end_point, err)
            raise EnvoyCommunicationError(f"Timeout {err!s}") from err
        if not (200 <= response.status < 300):
            content = await response.read()
            _LOGGER.debug(
                "Request to %s failed with status %s: %s",
                end_point,
                response.status,
                content[:500] if content else "No content",
            )
            raise EnvoyHTTPStatusError(response.status, str(response.url))

        try:
            return json_loads(end_point, await response.read())
        except orjson.JSONDecodeError as err:
            raise EnvoyCommunicationError(
                f"Invalid JSON response from {end_point}: {err}"
            ) from err

    async def go_on_grid(self) -> dict[str, Any]:
        """
        Make a request to the Envoy to go on grid.

        POST {"mains_admin_state": "closed"} to /ivp/ensemble/relay directing
        to connect to the grid. Requires ENPOWER installed.

        :raises EnvoyFeatureNotAvailable: If ENPOWER feature is not available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: JSON returned by Envoy
        """
        if not self.supported_features & SupportedFeatures.ENPOWER:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        return await self._json_request(URL_GRID_RELAY, {"mains_admin_state": "closed"})

    async def go_off_grid(self) -> dict[str, Any]:
        """
        Make a request to the Envoy to go off grid.

        POST {"mains_admin_state": "open"} to /ivp/ensemble/relay directing
        to disconnect from the grid. Requires ENPOWER installed.

        :raises EnvoyFeatureNotAvailable: If ENPOWER feature is not available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: JSON returned by Envoy
        """
        if not self.supported_features & SupportedFeatures.ENPOWER:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        return await self._json_request(URL_GRID_RELAY, {"mains_admin_state": "open"})

    async def update_dry_contact(self, new_data: dict[str, Any]) -> dict[str, Any]:
        """
        Update settings for an Enpower dry contact relay.

        POST updated dry contact settings to /ivp/ss/dry_contact_settings
        in the Envoy. New_data dict can contain one or more of below items
        to set. The key/value for "id" is required to identify the relay.
        Only include key/values to change.

        .. code-block:: json

            {
                "id": "<relay-id>",
                "grid_action": "value",
                "micro_grid_action": "value",
                "gen_action": "value",
                "override": "value",
                "load_name": "value",
                "mode": "value",
                "soc_low": "value",
                "soc_high": "value",
            },

        Settings specified in the data dict are updated in the
        internally stored dry_contact_settings and send as a whole
        to update the Envoy.

        :param new_data: dict of settings to change, "id" key/value required
        :raises EnvoyFeatureNotAvailable: If ENPOWER feature is not available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :raises ValueError: If update was attempted before first data was requested from Envoy
        :raises ValueError: If no "id" key is present in data dict to send.
        :return: dry_contact_settings JSON returned by Envoy
        """
        # All settings for the relay must be sent in the POST or it may crash the Envoy

        if not self.supported_features & SupportedFeatures.ENPOWER:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )

        if not (id_ := new_data.get("id")):
            raise ValueError("You must specify the dry contact ID in the data object.")
        # Get the current settings for the relay from EnvoyData and merge with the new settings
        if not (current_data := self.data):
            raise ValueError(
                "Tried to set dry contact settings before the Envoy was queried."
            )
        current_model = current_data.dry_contact_settings[id_]
        new_model = replace(current_model, **new_data)

        return await self._json_request(
            URL_DRY_CONTACT_SETTINGS, {"dry_contacts": new_model.to_api()}
        )

    async def open_dry_contact(self, id: str) -> dict[str, Any]:
        """
        Open a dry contact relay.

        POST {"dry_contacts": {"id": id, "status": "open"}} to Envoy to
        open dry contact with specified id. Upon successful POST,
        update dry contact status in internal data as Envoy needs some time
        to implement the change and have status updated.

        :param id: relay id of dry contact relay to open
        :raises EnvoyFeatureNotAvailable: If ENPOWER feature is not available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: JSON response of Envoy
        """
        if not self.supported_features & SupportedFeatures.ENPOWER:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )

        result = await self._json_request(
            URL_DRY_CONTACT_STATUS, {"dry_contacts": {"id": id, "status": "open"}}
        )
        # The Envoy takes a few seconds before it will reflect the new state of the relay
        # so we preemptively update it
        if TYPE_CHECKING:
            assert self.data is not None  # nosec
        self.data.dry_contact_status[id].status = DryContactStatus.OPEN
        return result

    async def close_dry_contact(self, id: str) -> dict[str, Any]:
        """
        Close a dry contact relay.

        POST {"dry_contacts": {"id": id, "status": "closed"}} to Envoy to
        close dry contact with specified id. Upon successful POST,
        update dry contact status in internal data as Envoy needs some time
        to implement the change and have status updated.

        :param id: relay id of dry contact relay to open
        :raises EnvoyFeatureNotAvailable: If ENPOWER feature is not available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: JSON response of Envoy
        """
        if not self.supported_features & SupportedFeatures.ENPOWER:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )

        result = await self._json_request(
            URL_DRY_CONTACT_STATUS, {"dry_contacts": {"id": id, "status": "closed"}}
        )
        # The Envoy takes a few seconds before it will reflect the new state of the relay
        # so we preemptively update it
        if TYPE_CHECKING:
            assert self.data is not None  # nosec
        self.data.dry_contact_status[id].status = DryContactStatus.CLOSED
        return result

    async def set_generator_mode(self, mode: str) -> dict[str, Any]:
        """
        Set the standby generator operation mode.

        POST {"gen_cmd": mode} to /ivp/ss/gen_mode to set the generator
        mode. Accepted modes are "off", "on" and "auto"; input is
        normalized to lowercase before sending, matching the casing the
        endpoint reports on live firmware (D8.3.5169 reports e.g.
        ``{"gen_cmd": "on"}``). Requires a system with an Enpower and
        standby generator installed. Upon successful POST, update the
        generator mode in internal data as the Envoy needs some time to
        implement the change and have status updated.

        :param mode: generator mode to set, one of "off", "on" or "auto"
        :raises EnvoyFeatureNotAvailable: If GENERATOR feature is not available in Envoy
        :raises ValueError: If mode is not one of "off", "on" or "auto"
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: JSON returned by Envoy
        """
        if not self.supported_features & SupportedFeatures.GENERATOR:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        gen_cmd = mode.lower()
        if gen_cmd not in GENERATOR_MODES:
            raise ValueError(
                f"Invalid generator mode: {mode}. "
                f"Valid modes: {', '.join(sorted(GENERATOR_MODES))}"
            )
        result = await self._json_request(URL_GEN_MODE, {"gen_cmd": gen_cmd})
        # The Envoy takes a few seconds before it will reflect the new mode
        # so we preemptively update it
        if self.data and self.data.generator_mode:
            self.data.generator_mode.gen_cmd = gen_cmd
        return result

    async def update_generator_schedule(
        self, new_data: dict[str, Any], refresh: bool = False
    ) -> dict[str, Any]:
        """
        Update the standby generator schedule settings.

        POST updated generator schedule settings to /ivp/ss/gen_schedule
        in the Envoy. New_data dict can contain one or more of below
        items to set. Only include key/values to change.

        .. code-block:: json

            {
                "exercise_freq_in_weeks": 1,
                "exercise_day": "Mon",
                "exercise_start": 840,
                "exercise_duration": 20,
                "default_start_soc": 30,
                "default_stop_soc": 70,
            },

        Settings specified in the data dict are updated in the
        internally stored generator_schedule and send as a whole to
        update the Envoy, as partial updates are not supported by the
        endpoint. The exercise_day value is case insensitive and
        normalized to the capitalized short day name the firmware
        reports, e.g. "Thu".

        Value domains match the official Enphase app: exercise_day
        "Mon" through "Sun", exercise_start 0-1439 minutes after
        midnight, exercise_duration 10-60 minutes in steps of 10,
        exercise_freq_in_weeks 1-4 and the SOC values 0-100. Firmware
        accepts and persists out-of-domain durations, but the Enlighten
        UI can not display them, so the vendor domain is enforced.

        The default_start_soc and default_stop_soc are applied by the
        firmware as the active generator start/stop SOC, as reported in
        /ivp/ensemble/generator. They are part of the schedule document
        and are always sent, so an update also (re)applies the SOC
        values held in :any:`EnvoyData.generator_schedule` at the time
        of the call. On systems with Enphase batteries this matters
        when the Enphase app or cloud changed them since the last data
        collection, as the update would set them back to the values in
        the stored data. Handle this in one of three ways: specify
        refresh to re-read the schedule from the Envoy right before the
        settings are merged into it, use :any:`Envoy.update` before the
        call, or include the wanted SOC values in the settings to
        change.

        The Envoy returns the resulting generator schedule, which is
        used to update :any:`EnvoyData.generator_schedule` and the raw
        data. Callers can use the returned data or the updated model to
        verify the applied settings. If the Envoy does not return a
        complete schedule document, the stored data is left at the last
        known state rather than at an optimistic one and
        EnvoyCommunicationError is raised. The update was sent in that
        case, use :any:`Envoy.update` to establish the actual state.

        :param new_data: dict of settings to change
        :param refresh: re-read the schedule from the Envoy before
            merging the settings to change into it
        :raises EnvoyFeatureNotAvailable: If GENERATOR feature is not available in Envoy
        :raises EnvoyFeatureNotAvailable: If this firmware does not expose the gen_schedule endpoint
        :raises ValueError: If update was attempted before first data was requested from Envoy
        :raises ValueError: If an unknown setting is specified or a value is out of range
        :raises ValueError: If the resulting default_start_soc is not lower than default_stop_soc
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyCommunicationError: If the Envoy does not return a complete schedule document
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: generator schedule JSON returned by Envoy
        """
        if not self.supported_features & SupportedFeatures.GENERATOR:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        if not (data := self.data):
            raise ValueError(
                "Tried to set generator schedule before the Envoy was queried."
            )
        if data.generator_schedule is None:
            raise EnvoyFeatureNotAvailable(
                "The generator schedule endpoint is not available on this Envoy."
            )
        if refresh:
            # re-read before merging so settings changed by the Enphase
            # cloud or app since the last data collection are not echoed
            # back with stale values
            current = await self._json_request(URL_GEN_SCHEDULE, None)
            data.generator_schedule = self._model_from_document(
                URL_GEN_SCHEDULE,
                current,
                EnvoyGeneratorSchedule.from_api,
                "The Envoy returned an incomplete generator schedule, "
                "no data was changed and no update was sent",
            )
            data.raw[URL_GEN_SCHEDULE] = current
        new_data = self._validated_generator_schedule(new_data, data.generator_schedule)
        # merge with the current settings and send the whole document
        new_model = replace(data.generator_schedule, **new_data)
        result = await self._json_request(URL_GEN_SCHEDULE, new_model.to_api())
        # The Envoy returns the resulting schedule, use it to keep the
        # raw and the typed data consistent until the next data update.
        # Build the model first: if the reply is not a complete document
        # the stored data is left at the last known state rather than at
        # an optimistic one the Envoy never confirmed.
        new_state = self._model_from_document(
            URL_GEN_SCHEDULE,
            result,
            EnvoyGeneratorSchedule.from_api,
            "The Envoy returned an incomplete generator schedule for the update, "
            "the update was sent but the stored data was left unchanged",
        )
        data.raw[URL_GEN_SCHEDULE] = result
        data.generator_schedule = new_state
        return result

    def _model_from_document(
        self,
        end_point: str,
        document: Any,
        from_api: Callable[[Any], _ModelT],
        message: str,
    ) -> _ModelT:
        """
        Build a data model from a document returned by the Envoy.

        Used to verify a document returned for a write action is complete
        before any stored data is replaced with it.

        :param end_point: Envoy endpoint the document came from
        :param document: JSON document returned by the Envoy
        :param from_api: model method to build the model from the document
        :param message: message to report if the document is incomplete
        :raises EnvoyCommunicationError: If the document is not a complete document
        :return: data model built from the document
        """
        try:
            return from_api(document)
        except (KeyError, TypeError, IndexError) as err:
            _LOGGER.debug("Incomplete document returned by %s: %s", end_point, document)
            raise EnvoyCommunicationError(f"{message}: {end_point} {err!s}") from err

    def _validated_generator_schedule(
        self, new_data: dict[str, Any], current: EnvoyGeneratorSchedule
    ) -> dict[str, Any]:
        """
        Validate generator schedule settings to change.

        Only settings present in the data dict are validated, values
        are returned normalized where applicable.

        :param new_data: dict of settings to change
        :param current: current generator schedule to validate the
            settings to change against
        :raises ValueError: If an unknown setting is specified or a value is out of range
        :raises ValueError: If the resulting default_start_soc is not lower than default_stop_soc
        :return: validated and normalized settings to change
        """
        if unknown := set(new_data) - GENERATOR_SCHEDULE_SETTINGS:
            raise ValueError(
                f"Unknown generator schedule setting(s): {', '.join(sorted(unknown))}. "
                f"Valid settings: {', '.join(sorted(GENERATOR_SCHEDULE_SETTINGS))}"
            )
        validated = dict(new_data)
        if (freq := validated.get("exercise_freq_in_weeks")) is not None and (
            not 1 <= freq <= 4
        ):
            raise ValueError("exercise_freq_in_weeks must be between 1 and 4 weeks")
        if (day := validated.get("exercise_day")) is not None:
            exercise_day = str(day).capitalize()
            if exercise_day not in GENERATOR_EXERCISE_DAYS:
                raise ValueError(
                    f"Invalid exercise day: {day}. "
                    f"Valid days: {', '.join(GENERATOR_EXERCISE_DAYS)}"
                )
            validated["exercise_day"] = exercise_day
        if (start := validated.get("exercise_start")) is not None and (
            not 0 <= start <= 1439
        ):
            raise ValueError(
                "exercise_start must be between 0 and 1439 minutes after midnight"
            )
        if (duration := validated.get("exercise_duration")) is not None and (
            not 10 <= duration <= 60 or duration % 10 != 0
        ):
            raise ValueError(
                "exercise_duration must be between 10 and 60 minutes in steps of 10"
            )
        for soc_key in ("default_start_soc", "default_stop_soc"):
            if (soc := validated.get(soc_key)) is not None and not 0 <= soc <= 100:
                raise ValueError(f"{soc_key} must be between 0 and 100")
        # the generator starts at the low SOC and stops at the high one,
        # verify the resulting pair against the settings not being changed
        start_soc = validated.get("default_start_soc", current.default_start_soc)
        stop_soc = validated.get("default_stop_soc", current.default_stop_soc)
        if start_soc >= stop_soc:
            raise ValueError(
                f"default_start_soc ({start_soc}) must be lower than "
                f"default_stop_soc ({stop_soc})"
            )
        return validated

    async def set_generator_charge_from_generator(
        self, charge_from_generator: bool, refresh: bool = False
    ) -> dict[str, Any]:
        """
        Enable or disable battery charging from the standby generator.

        POST the generator configuration with only charge_from_generator
        changed to /ivp/ss/gen_config. The configuration is sent as a
        whole, built from :any:`EnvoyData.generator_config`, as partial
        updates are not supported by the endpoint. Other gen_config
        fields (name plate rating, max continuous amps, generator
        type/model/manufacturer, start method) are installer-grade
        settings and are intentionally not settable through this
        library.

        The Envoy returns the resulting generator configuration, which
        is used to update :any:`EnvoyData.generator_config` and the raw
        data. Callers can use the returned data or the updated model to
        verify the applied setting. This matters here: on systems
        without Enphase batteries the Envoy accepts the request with
        HTTP 200 but normalizes charge_from_generator back to true,
        verified live on firmware D8.3.5169. If the Envoy does not
        return a complete configuration document, the stored data is
        left at the last known state rather than at an optimistic one
        and EnvoyCommunicationError is raised. The update was sent in
        that case, use :any:`Envoy.update` to establish the actual
        state.

        The configuration is sent as a whole, so the values held in
        :any:`EnvoyData.generator_config` at the time of the call are
        sent along with the changed setting. If the Enphase app or
        cloud changed the configuration since the last data collection,
        the update would set it back to the values in the stored data.
        Handle this by specifying refresh to re-read the configuration
        from the Envoy right before the setting is changed in it, or by
        using :any:`Envoy.update` before the call.

        :param charge_from_generator: True to allow charging batteries
            from the generator, False to disallow
        :param refresh: re-read the configuration from the Envoy before
            changing the setting in it
        :raises EnvoyFeatureNotAvailable: If GENERATOR feature is not available in Envoy
        :raises TypeError: If charge_from_generator is not of type bool
        :raises ValueError: If update was attempted before first data was requested from Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyCommunicationError: If the Envoy does not return a complete configuration document
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: generator configuration JSON returned by Envoy
        """
        if not self.supported_features & SupportedFeatures.GENERATOR:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        if type(charge_from_generator) is not bool:
            raise TypeError("charge_from_generator must be of type bool")
        if not (data := self.data):
            raise ValueError(
                "Tried to set charge from generator before the Envoy was queried."
            )
        if refresh:
            # re-read before merging so configuration changed by the
            # Enphase cloud or app since the last data collection is not
            # echoed back with stale values
            current = await self._json_request(URL_GEN_CONFIG, None)
            data.generator_config = self._model_from_document(
                URL_GEN_CONFIG,
                current,
                EnvoyGeneratorConfig.from_api,
                "The Envoy returned an incomplete generator configuration, "
                "no data was changed and no update was sent",
            )
            data.raw[URL_GEN_CONFIG] = current
        # gen_config is the GENERATOR detection gate, so it is always
        # collected during update when the feature is available
        if TYPE_CHECKING:
            assert data.generator_config is not None  # nosec
        new_model = replace(
            data.generator_config, charge_from_generator=charge_from_generator
        )
        result = await self._json_request(URL_GEN_CONFIG, new_model.to_api())
        # The Envoy returns the resulting configuration, use it to keep
        # the raw and the typed data consistent until the next update.
        # Build the model first: if the reply is not a complete document
        # the stored data is left at the last known state rather than at
        # an optimistic one the Envoy never confirmed.
        new_state = self._model_from_document(
            URL_GEN_CONFIG,
            result,
            EnvoyGeneratorConfig.from_api,
            "The Envoy returned an incomplete generator configuration for the update, "
            "the update was sent but the stored data was left unchanged",
        )
        data.raw[URL_GEN_CONFIG] = result
        data.generator_config = new_state
        return result

    async def set_acb_sleep(self, configs: list[dict[str, Any]]) -> dict[str, Any]:
        """
        Configure sleep mode for one or more ACB (AC Battery) devices.

        PUT {"acb_sleep": [{"serial_num": "...", "sleep_min_soc": x, "sleep_max_soc": y}, ...]}
        to Envoy to set sleep thresholds per device.

        :param configs: List of dicts, each with keys serial_num (str),
            sleep_min_soc (int 0-100), sleep_max_soc (int 0-100).
        :raises EnvoyFeatureNotAvailable: If ACB feature is not available in Envoy
        :raises ValueError: If configs is empty or any entry has invalid/missing fields.
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: JSON response of Envoy
        """
        if not self.supported_features & SupportedFeatures.ACB:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        if not configs:
            raise ValueError("configs must not be empty.")
        acb_sleep = []
        serial_nums: list[str] = []
        for i, cfg in enumerate(configs):
            if "serial_num" not in cfg:
                raise ValueError(f"configs[{i}] missing required key 'serial_num'.")
            if "sleep_min_soc" not in cfg:
                raise ValueError(f"configs[{i}] missing required key 'sleep_min_soc'.")
            if "sleep_max_soc" not in cfg:
                raise ValueError(f"configs[{i}] missing required key 'sleep_max_soc'.")
            serial_num = str(cfg["serial_num"]).strip()
            if not serial_num:
                raise ValueError(f"configs[{i}] serial_num must not be empty.")
            min_soc = cfg["sleep_min_soc"]
            max_soc = cfg["sleep_max_soc"]
            # Coerce to integers and validate
            try:
                min_soc = int(min_soc)
                max_soc = int(max_soc)
            except (TypeError, ValueError) as err:
                raise ValueError(
                    f"configs[{i}] sleep_min_soc and sleep_max_soc must be numeric integers."
                ) from err
            if not (0 <= min_soc <= 100):
                raise ValueError(
                    f"configs[{i}] sleep_min_soc {min_soc} is out of range 0-100."
                )
            if not (0 <= max_soc <= 100):
                raise ValueError(
                    f"configs[{i}] sleep_max_soc {max_soc} is out of range 0-100."
                )
            # Validate that min_soc <= max_soc
            if min_soc > max_soc:
                raise ValueError(
                    f"configs[{i}] sleep_min_soc {min_soc} must be <= sleep_max_soc {max_soc}"
                )
            serial_nums.append(serial_num)
            acb_sleep.append(
                {
                    "serial_num": serial_num,
                    "sleep_min_soc": min_soc,
                    "sleep_max_soc": max_soc,
                }
            )
        await self._validate_acb_serials_or_raise(serial_nums)
        try:
            return await self._json_request(
                URL_ACB_CONFIG, {"acb_sleep": acb_sleep}, method="PUT"
            )
        except EnvoyHTTPStatusError as err:
            if err.status_code == HTTPStatus.BAD_REQUEST:
                raise ValueError(
                    "Envoy rejected ACB sleep request (HTTP 400). Check serial numbers and SOC values."
                ) from err
            raise

    async def clear_acb_sleep(self, serial_nums: list[str]) -> dict[str, Any]:
        """
        Clear sleep mode for one or more ACB (AC Battery) devices.

        DELETE {"acb_sleep": [{"serial_num": "..."}, ...]}
        to Envoy to cancel sleep or wake specified devices.

        :param serial_nums: List of ACB device serial numbers.
        :raises EnvoyFeatureNotAvailable: If ACB feature is not available in Envoy
        :raises ValueError: If serial_nums is empty or contains empty serials.
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :return: JSON response of Envoy
        """
        if not self.supported_features & SupportedFeatures.ACB:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        if not serial_nums:
            raise ValueError("serial_nums must not be empty.")

        acb_sleep = []
        normalized_serials: list[str] = []
        for i, serial_num in enumerate(serial_nums):
            serial_num_str = str(serial_num).strip()
            if not serial_num_str:
                raise ValueError(f"serial_nums[{i}] must not be empty.")
            normalized_serials.append(serial_num_str)
            acb_sleep.append({"serial_num": serial_num_str})

        await self._validate_acb_serials_or_raise(normalized_serials)

        try:
            return await self._json_request(
                URL_ACB_CONFIG, {"acb_sleep": acb_sleep}, method="DELETE"
            )
        except EnvoyHTTPStatusError as err:
            if err.status_code == HTTPStatus.BAD_REQUEST:
                raise ValueError(
                    "Envoy rejected ACB sleep clear request (HTTP 400). Check serial numbers."
                ) from err
            raise

    async def _known_acb_serials(self) -> set[str]:
        """Return known ACB serials from loaded data."""
        if self.data is None:
            raise EnvoyError("Envoy data not initialized. Call update() first.")
        if not self.data.acb_inventory:
            return set()
        return set(self.data.acb_inventory)

    async def _validate_acb_serials_or_raise(self, serial_nums: list[str]) -> None:
        """Validate requested serials against known ACB devices when available."""
        known_serials = await self._known_acb_serials()
        if not known_serials:
            return

        invalid_serials = sorted(
            {serial for serial in serial_nums if serial not in known_serials}
        )
        if invalid_serials:
            known_sorted = ", ".join(sorted(known_serials))
            invalid_sorted = ", ".join(invalid_serials)
            raise ValueError(
                f"Unknown ACB serial number(s): {invalid_sorted}. Known ACB serials: {known_sorted}."
            )

    async def enable_charge_from_grid(self) -> dict[str, Any]:
        """
        Enable charge from grid for Encharge batteries.

        Set charge_from_grid true in internal stored
        tariff data and send updated tariff data to Envoy using PUT.
        This will update the charge from grid setting to true
        in the Envoy.

        :raises EnvoyFeatureNotAvailable: If no Encharge or IQ batteries are available
        :raises EnvoyFeatureNotAvailable: If no TARIFF data is available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :raises ValueError: If update was attempted before first data was requested from Envoy
        :return: JSON response of Envoy
        """
        self._verify_tariff_storage_or_raise()
        if TYPE_CHECKING:
            assert self.data is not None  # nosec
            assert self.data.tariff is not None  # nosec
            assert self.data.tariff.storage_settings is not None  # nosec
        self.data.tariff.storage_settings.charge_from_grid = True
        return await self._json_request(
            URL_TARIFF, {"tariff": self.data.tariff.to_api()}, method="PUT"
        )

    async def disable_charge_from_grid(self) -> dict[str, Any]:
        """
        Disable charge from grid for Encharge batteries.

        Set charge_from_grid false in internal stored
        tariff data and send updated tariff data to Envoy using PUT.
        This will update the charge from grid setting to false
        in the Envoy.

        :raises EnvoyFeatureNotAvailable: If no Encharge or IQ batteries are available
        :raises EnvoyFeatureNotAvailable: If no TARIFF data is available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :raises ValueError: If update was attempted before first data was requested from Envoy
        :return: JSON response of Envoy
        """
        self._verify_tariff_storage_or_raise()
        if TYPE_CHECKING:
            assert self.data is not None  # nosec
            assert self.data.tariff is not None  # nosec
            assert self.data.tariff.storage_settings is not None  # nosec
        self.data.tariff.storage_settings.charge_from_grid = False
        return await self._json_request(
            URL_TARIFF, {"tariff": self.data.tariff.to_api()}, method="PUT"
        )

    async def set_storage_mode(
        self,
        mode: EnvoyStorageMode,
        disable_optimized_schedules: bool = False,
    ) -> dict[str, Any]:
        """
        Set the Encharge storage mode.

        Set storage_mode in internal stored tariff data to specified
        mode and send updated tariff data to /admin/lib/tariff in Envoy
        using PUT. This will update the storage mode setting
        in the Envoy.

        On firmware where optimized schedules are supported, set
        ``disable_optimized_schedules=True`` to also set ``opt_schedules``
        to ``False`` alongside the mode change. When ``opt_schedules`` is
        ``True``, writes to ``storage_settings.mode`` are accepted by the
        gateway and return HTTP 200, but are silently ignored by the battery
        controller. See :ref:`battery-mode-overwritten` for details on cloud reconciliation
        behaviour when the gateway has internet access.

        :param mode: storage mode to set
        :param disable_optimized_schedules: set ``opt_schedules`` to ``False``
            alongside the mode change
        :raises EnvoyFeatureNotAvailable: If no Encharge or IQ batteries are available
        :raises EnvoyFeatureNotAvailable: If no TARIFF data is available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :raises ValueError: If update was attempted before first data was requested from Envoy
        :return: JSON response of Envoy
        """
        self._verify_tariff_storage_or_raise()
        if TYPE_CHECKING:
            assert self.data is not None  # nosec
            assert self.data.tariff is not None  # nosec
            assert self.data.tariff.storage_settings is not None  # nosec
        if type(mode) is not EnvoyStorageMode:
            raise TypeError("Mode must be of type EnvoyStorageMode")
        self.data.tariff.storage_settings.mode = mode
        if (
            disable_optimized_schedules
            and self.data.tariff.storage_settings.opt_schedules is not None
        ):
            self.data.tariff.storage_settings.opt_schedules = False
        return await self._json_request(
            URL_TARIFF, {"tariff": self.data.tariff.to_api()}, method="PUT"
        )

    async def set_reserve_soc(self, value: int) -> dict[str, Any]:
        """
        Set the Encharge reserve state of charge.

        Set reserved_soc in internal stored tariff data to specified
        value and send updated tariff data to /admin/lib/tariff in Envoy
        using PUT. This will update the reserve soc setting
        in the Envoy.

        :param value: reserve soc to set
        :raises EnvoyFeatureNotAvailable: If no Encharge or IQ batteries are available
        :raises EnvoyFeatureNotAvailable: If no TARIFF data is available in Envoy
        :raises EnvoyCommunicationError: when aiohttp network or communication error occurs.
        :raises EnvoyHTTPStatusError: when HTTP status is not 2xx.
        :raises ValueError: If update was attempted before first data was requested from Envoy
        :return: JSON response of Envoy
        """
        self._verify_tariff_storage_or_raise()
        if TYPE_CHECKING:
            assert self.data is not None  # nosec
            assert self.data.tariff is not None  # nosec
            assert self.data.tariff.storage_settings is not None  # nosec
        self.data.tariff.storage_settings.reserved_soc = round(float(value), 1)
        return await self._json_request(
            URL_TARIFF, {"tariff": self.data.tariff.to_api()}, method="PUT"
        )

    def _verify_tariff_storage_or_raise(self) -> None:
        """
        Verify Encharge or IQ batteries and tariff data are available in Envoy

        :raises EnvoyFeatureNotAvailable: If no Encharge or IQ batteries are available
        :raises EnvoyFeatureNotAvailable: If no TARIFF data is available in Envoy
        :raises ValueError: If update was attempted before first data was requested from Envoy
        """
        if not self.supported_features & SupportedFeatures.ENCHARGE:
            raise EnvoyFeatureNotAvailable(
                "This feature requires Enphase Encharge or IQ Batteries."
            )
        if not self.supported_features & SupportedFeatures.TARIFF:
            raise EnvoyFeatureNotAvailable(
                "This feature is not available on this Envoy."
            )
        if not self.data:
            raise ValueError("Tried access envoy data before Envoy was queried.")
        if TYPE_CHECKING:
            assert self.data is not None  # nosec
        if not self.data.tariff:
            raise ValueError(
                "Tried to configure charge from grid before the Envoy was queried."
            )
        if TYPE_CHECKING:
            assert self.data.tariff is not None  # nosec
        if not self.data.tariff.storage_settings:
            raise EnvoyFeatureNotAvailable(
                "This feature requires Enphase Encharge or IQ Batteries."
            )
