Repository: briancmpbll/home_assistant_custom_envoy Branch: main Commit: 81b2f135ae63 Files: 35 Total size: 159.4 KB Directory structure: gitextract_t8pci746/ ├── .gitignore ├── README.md ├── custom_components/ │ └── enphase_envoy_custom/ │ ├── __init__.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── diagnostics.py │ ├── envoy_reader.py │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ └── translations/ │ ├── ca.json │ ├── cs.json │ ├── de.json │ ├── el.json │ ├── en.json │ ├── es-419.json │ ├── es.json │ ├── et.json │ ├── fr.json │ ├── he.json │ ├── hu.json │ ├── id.json │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── nl.json │ ├── no.json │ ├── pl.json │ ├── ru.json │ ├── sv.json │ ├── tr.json │ ├── zh-Hans.json │ └── zh-Hant.json └── hacs.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class #vscode .vscode/ ================================================ FILE: README.md ================================================ [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration#readme) This is a HACS custom integration for [enphase envoys/IQ Gateways](https://enphase.com/en-us/products-and-services/envoy-and-combiner) with firmware version 7.X. This integration is based off work done by @jesserizzo, @gtdiehl and @DanBeard, with some changes to report individual battery status. It still supports Envoys with firmware versions before 7.x including R3 versions for legacy models. Works with older models that only have (some) production metrics (ie. Envoy-C R or LCD), newer models with only production metrics (IQ Gateway / ENVOY-S Standard) and models that offer both production and consumption metrics (ie. Envoy-S metered). # Installation 1. Install [HACS](https://hacs.xyz/) if you haven't already 2. Add this repository as a [custom integration repository](https://hacs.xyz/docs/faq/custom_repositories) in HACS 4. Restart home assistant 5. Add the integration through the home assistant configuration flow, [specify settings as needed](#initial-configuration-details) 7. The integration has some run-time configuration options, [set these as desired](#runtime-configuration) after startup. 8. The integration has some hidden entities, [enable these](#disabled-entities) to use these. # Usage ## Initial Configuration details The initial configuration window requires you to enter the details how to access the Envoy. Following fields have to be filled. ### Host Enter the IP address of the Envoy. If the Envoy is auto-discovered it will be pre-filled. The IP address may be an ipv4 or ipv6 address. ### Username and Password Specify the username and password to access the envoy data. What username and password to use depends on the ENVOY type and/or it's firmware version: - If your IQ Gateway / Envoy-S is on Firmware 7.x or later, use your Enphase Enlighten username and password. Make sure to enable the 'Use Enlighten' option at the bottom of the form. - For older models and ENVOY-S with firmware before 7.x use `envoy` without a password, `installer` without a password or a valid username and password for the type. - For older models that require username `installer` with a password, this can be obtained with this: [tool](https://thecomputerperson.wordpress.com/2016/08/28/reverse-engineering-the-enphase-installer-toolkit/). - In some cases, you need to use the username `envoy` with the last 6 digits of the unit's serial number as password. See [the Enphase documentation](https://support.enphase.com/s/article/What-is-the-Username-and-Password-for-the-Administration-page-of-the-Envoy-local-interface) for more details on various units. **Note** This integration does not provide the additional data accessible by Enphase Installer or DIY accounts, only data accessible by Home owner accounts is provided. Using an Installer or DIY account may or may not work work, but currently just the Home owner data is retrieved. ## Serial number Specify the Envoy serial number. If the Envoy is auto-discovered it will be pre-filled. ### Use Enlighten Enable this option with IQ Gateway/ENVOY-S Firmware 7.x or later that require Enphase tokens for authentication. It will use the username and password to retrieve the authentication token from the Enphase website, cache it and use it to access the Envoy. ## Runtime configuration Once the Envoy has been running and is operational, the following configuration items are available: ### Scan Interval - Time between Entity updates By default the Envoy data is collected every 60 seconds. One can change the setting to what is desired with a minimum of every 5 seconds. Upon changing the value, reload the integration (or restart Home Assistant). What the optimal scan frequency is depends on the Envoy model. Models without meters typically update the inverter data every 5 minutes. Models using meters update measurements way more frequent, probably every second or so. Hence the default of 60 seconds as starting point. Some models may have capacity issues running at high refresh rates, so no single recipe is available. **Note** Envoy Metered has a data streaming option to bring in data as it comes available which is not currently supported by this integration. ### Timeout for single Envoy Page Specifies the timeout for each single data get to the Envoy. Defaults to 30 seconds. Shortening this time does not make the Envoy response faster, lengthening it will allow a slow Envoy more time to respond before time-out occurs. ### How many retries in getting an Envoy response Specifies how many times a retry should be attempted after the first one failed. Must be at least 1 to allow for automatic token updates. Typically do not change this setting. Increasing may help in poor network conditions. ### Time between 2 retries Optionally a wait time can be inserted between 2 retries. Only change this in special circumstances. ### Overall Timeout When getting data from the Envoy, an overall timer is started. If not all data is returned when the timer expires, the data collection is considered timed-out and all data is set to unavailable. Intent is catch all if data collection is never returning. Do not change this setting, unless Timeout for single envoy page or number of retries needs to be increased. In that case increase this overall timer value as well to prevent it to timeout the data collection. To get a feel for needed time, enable the debug mode on the envoy and inspect timing of a full collection cycle. ### Do not use production json This switch, intended for use with the Envoy-s Metered only, will tell the integration not to use production endpoint on the Envoy. The production endpoint is a relatively slow endpoint on the Envoy and reportedly crashes or restarts at times resulting in timeouts. The Envoy-s Metered (only) has other, faster endpoints that provide a subset of what production endpoint offers. This subset is lacking the daily total and last 7 day total values which are only provided by the production endpoint. If you are more interested in faster updates from the CT clamps and have less interest in the Daily total or last 7 day total then this may be an option to consider. The values for today total and last 7 day total will show as unavailable. The values for production and consumption CT clamps will update with every collection cycle. The values for the inverters will continue to update every 5 minutes as before. ## Disabled entities The integration comes with some entities disabled by default. These only apply when using metered Envoy with CT clamps. If desired enable these by opening the HA entities window in the settings menu. Remove the filter for not shown entities by pushing the `clear` button. Then enter disabled or enphase in the search filter to find the disabled entities. Use the selector box to select the ones to enable and use the `enable selected` button to enable them. # Firmware and its impact Enphase model offering differs in various countries as does firmware versions and releases. Not all firmware is released in all countries and as a result firmware versions may differ largely in time. Enphase does push new firmware to the IQ Gateway / Envoy, 'not necessarily letting the home owner know'. In the past this has broken this integration as API details and change information is limited available. See the [Enphase documentation website](https://enphase.com/installers/resources/documentation/communication) for information available. # Different models have different features This integration supports various models but as models have different features they will not all provide the same data. Brief list of reported data below. ## ENVOY C / R / LCD - Current power production, today's, last 7 days and lifetime energy production. And Active inverter count, which is disabled by default. ## IQ Gateway / ENVOY S standard (non metered) - Current power production, today's, last 7 days and lifetime energy production. - Current power production for each connected inverter. ## IQ Gateway / ENVOY S standard metered What data is available depends on how many current transformer clamps (CT) are installed and what currents they measure. Both production and consumption clamps can be installed, each for up to 3 phases or multiple circuits on their own breaker in single phase setup. The consumption clamps can be installed in 2 modes, 'Load with Solar'or 'Load only'. To measure net-consumption (energy import/export to the grid) it should be installedin Load with Solar mode. If in 'Load only' mode only total-consumption (to the house) can be reported. ### with connected current transformer clamps - Current power production and consumption, today's, last 7 days and lifetime energy production and consumption over all phases. - Current power production and consumption, today's, last 7 days and lifetime energy production and consumption for each individual phase named L1, L2 and L3. - Current net power consumption and lifetime net energy production (export) and consumption (import) over all phases. - Current net power consumption and lifetime net energy production (export) and consumption (import) for each individual phase named L1, L2 and L3. - Next entities are disabled by default and need to be enabled in the entities configuration screen - Power production for each connected inverter. - Power factor over all phases. - Power factor for each individual phase named L1, L2 and L3. - Voltage over all phases. (Be aware this is the summed Voltage of all measured phases!) - Voltage for each individual phase named L1, L2 and L3. - Frequency over all phases. - Frequency for each individual phase named L1, L2 and L3. - Production and consumption Current (amps) over all phases. - Production and consumption Current (amps) for each individual phase named L1, L2 and L3. **Note** If you have CT clamps on a single phase / breaker circuit only, the L1 production and consumption phase sensors will show same data as the over all phases sensors. ### without connected current transformer clamps The current firmware (D7.6.175 and probably some other right before and after it) without CT clamps connected and configured does obviously not report these measurements. But for some reason it is only reporting: - Current power production and lifetime energy production. Today's and last 7 day energy production reportedly are both solid 0. - Lifetime Energy production reportedly resets to zero roughly every 1.19 MWh. - Current power production for each connected inverter. **Note** - When adding (or removing) CT clamps after use witouth CT clamps this will cause (huge) step changes/spikes in life time values when CT readings are now from the CT clamps (or longer available) and the wrapping value is no longer/now used. # Device and Entities The naming scheme used is based on the Envoy and inverter Serial numbers. ## Device A device `Envoy ` is created with sensor entities for accessible data. ## Envoy Sensors |Sensor name|Sensor ID|Units|remarks| |-----|-----|----|----| |Envoy \ Current Power Production|sensor.Envoy_\_current_power_production|W|| |Envoy \ Today's Energy production|sensor.Envoy_\_todays_energy_production|Wh|1| |Envoy \ Last Seven Days Energy Production|sensor.Envoy_\_last_seven_days_energy_production|Wh|1| |Envoy \ Lifetime Energy Production|sensor.Envoy_\_lifetime_energy_production|Wh|2| |Envoy \ Lifetime Net Energy Production|sensor.Envoy_\_lifetime_net_energy_production|Wh|4| |Envoy \ Current Power Consumption|sensor.Envoy_\_current_power_consumption|W|| |Envoy \ Current Net Power Consumption|sensor.Envoy_\_current_net_power_consumption|W|4| |Envoy \ Today's Energy Consumption|sensor.Envoy_\_todays_energy_consumption|Wh|4,5| |Envoy \ Last Seven Days Energy Consumption|sensor.Envoy_\_last_seven_days_energy_consumption|Wh|4| |Envoy \ Lifetime Energy Consumption|sensor.Envoy_\_lifetime_energy_consumption|Wh|4| |Envoy \ Lifetime Net Energy Consumption|sensor.Envoy_\_lifetime_net_energy_consumption|Wh|4,7,8| |Envoy \ Power Factor|sensor.Envoy_\_pf||4,9| |Envoy \ Voltage|sensor.Envoy_\_voltage|V|4,9| |Envoy \ Frequency|sensor.Envoy_\_frequency|Wh|4,9| |Envoy \ Consumption Current|sensor.Envoy_\_consumption_Current|A|4,9| |Envoy \ Production Current|sensor.Envoy_\_production_Current|A|4,9| |Envoy \ Active Inverter Count|sensor.Envoy_\_active_inverter_count||9,10| |||| |Grid Status |binary_sensor.grid_status|On/Off|3| |||| |Envoy \ Current Power Production L\|sensor.Envoy_\_current_power_production_l\|W|4,5| |Envoy \ Today's Energy production L\|sensor.Envoy_\_todays_energy_production_l\|Wh|4,5| |Envoy \ Last Seven Days Energy Production L\|sensor.Envoy_\_last_seven_days_energy_production_l\|Wh|4,5| |Envoy \ Lifetime Energy Production L\|sensor.Envoy_\_lifetime_energy_consumption_l\|Wh|4,5| |Envoy \ Lifetime Net Energy Production L\|sensor.Envoy_\_lifetime_net_energy_production_l\|Wh|4,5,7,8| |Envoy \ Current Power Consumption L\|sensor.Envoy_\_current_power_consumption_l\|W|4,5| |Envoy \ Current Net Power Consumption L\|sensor.Envoy_\_current_net_power_consumption_l\|W|4,5| |Envoy \ Today's Energy Consumption L\|sensor.Envoy_\_todays_energy_consumption_l\|Wh|4,5,6| |Envoy \ Last Seven Days Energy Consumption L\|sensor.Envoy_\_last_seven_days_energy_consumption L\|Wh|4,5,6| |Envoy \ Lifetime Energy Consumption L\|sensor.Envoy_\_lifetime_energy_consumption_l\|Wh|4,5,6| |Envoy \ Lifetime Net Energy Consumption L\|sensor.Envoy_\_lifetime_net_energy_consumption_l\|Wh|4,5,6,7,8| |Envoy \ Power Factor L\|sensor.Envoy_\_pf||4,5,9| |Envoy \ Voltage L\|sensor.Envoy_\_voltage|V|4,5,9| |Envoy \ Frequency L\|sensor.Envoy_\_frequency|Wh|4,5,9| |Envoy \ Consumption Current L\|sensor.Envoy_\_consumption_Current|A|4,5,9| |Envoy \ Production Current L\|sensor.Envoy_\_production_Current|A|4,5,9| |Envoy \ |sensor.Envoy_\_|Wh|4,5| 1 Always zero for Envoy Metered without meters. 2 Reportedly resets to zero when reaching ~1.92MWh for Envoy Metered without meters. 3 Not available on Legacy models and ENVOY Standard with recent firmware. 4 Only on Envoy metered with configured and connected meters. 5 L\ L1,L2,L3, availability depends on which and how many phases are connected and configured. 6 Reportedly always zero on Envoy metered with Firmware D8. 7 In V0.0.18 renamed to Lifetime Net Energy Consumption /Production from Export Index/Import Import in v0.0.17. Old Entities will show as unavailable. 8 Only when consumption CT is installed in 'Load with Solar' mode. In 'Load only' mode values have no meaning. 9 Disabled by default and must be enabled in the entities configuration screen. These are values from the consumption CT. 10 Only available on legacy Envoy. ## Inverter Sensors For each inverter a sensor for current power production is created. |Sensor name|Sensor ID|UNits|remarks| |-----|-----|----|----| |Envoy \ Inverter \|sensor.Envoy_\\_Inverter_\|W|1| 1: Not available on Legacy models **Note** the entity 'Last Updated' for each inverter is currently not provided. **Note** As can be noted the Envoy serial number is part of the inverter sensor id and name. Internally the unique_id for it is the inverter serial number. When changing your setup by moving inverters to a new/different Envoy it will require some preparation/research how this will work out. (Statistics (history) is stored by sensor id) ## Battery Sensors For each battery a sensor for percent full is created as well as sensors for overall battery percentage, overall battery capacity, overall energy charged and discharged are created. |Sensor name|Sensor ID|Units|remarks| |-----|-----|----|----| |Envoy \ Battery \|sensor.Envoy_\\_Battery_\|%|1| |Envoy \ Total Battery Percentage|sensor.Envoy_\\_total_battery_percentage|%|1| |Envoy \ Current Battery Capacity|sensor.Envoy_\\_current_battery_capacity|Wh|1| |Envoy \ Battery Energy Charged|sensor.Envoy_\\_battery_energy_charged|Wh|1| |Envoy \ Battery Energy Discharged|sensor.Envoy_\\_battery_energy_charged|Wh|1| 1: Not available on Legacy models and ENVOYS-S Standard # How to switch to Enphase token authorization Once the envoy received the new firmware that requires token authorization, data collection will fail. To switch to the token usage execute next steps: - [Install](#installation) the Custom integration using HACS - In Home Assistant go to the Enphase Envoy integration and delete it. - Restart Home Assistant - The envoy will be auto-discovered again. If not add an Envoy Integration manually. - In the configuration screen now use your Enphase Enlighten username and password and enable the 'Use Enlighten' option. - Once it's configured it will continue reporting data in the same entities. - Optionally change the default time interval from 60 to what is preferred. # Troubleshooting When issues occur with this integration some items to check are: - Use the `Download Diagnostics` button in the Envoy Device page or the Enphase Integration page menu. It will download settings and recent data of the Envoy and provide some key information. - What model are you using. This will drive what can be expected. - What firmware is your model using, Was a firmware update recently pushed to the device? - Enable debug logging and let it run for a couple of minutes, disable it again and the log file will download. Check for obvious errors and be prepared to share it as needed for troubleshooting. Any tokens, usernames or passwords for the Envoy integration are not visible, but there may be sensitive information of other integrations that are being used. - All data collected is logged in lines like `Fetched from https://192.168.01.10/some_url: :`. Inspecting these provides insight in what and how successful data is collected. - The Envoy model it thinks its dealing with is reported in a line containing: `Using Model: P (HTTPs, Metering enabled: False, Get Inverters: True)`. (Model PC is envoy metered, P is Standard and R/LCD with FW >= R3.9 and P0 is Legacy/C/R/LCD with FW < R3.9>) - When configuring the Envoy for token use it will reach out to the Enphase Enlighten website to obtain a token. Reportedly the Enphase website is not equally responsive every moment of the day, week, moth, year and the setup will fail. At this moment the only answer to that is your perseverance or just try at another moment. - The token lifetime for Home Owner accounts is currently 1 year. The token is cached, eliminating the need to connect to Enphase each reload or restart. When the token is expired or some other authorization hiccup occurs a new token will be obtained. If that is needed at a moment it can't connect to Enphase it will try until success but in the mean time no data is collected from the Envoy. When using an Installer or DIY account this may work as well but the lifetime is 12 hours and refresh is way more frequent. - The Envoy integration supports zeroconf for auto detection and changes of IP addresses for the Envoy. It will not switch to an IPV6 address if the default network interface is running ipv4 or the other way around.It supports both IPV4 and IPV6. To change between these when default interface change IP type, remove and re-add the Envoy. - When an error is reported during the initial configuration, inspect the home-assistant.log file in the /config folder. It will reveal what happened: - Validate input, getdata returned RuntimeError: Could not Authenticate with Enlighten, status: 401, : Check if Enphase username and/or password are correct - Validate input, getdata returned RuntimeError: Could not get enlighten token, status: 403, : Make sure envoy serialnumber is correct and connected to your Enphase account - Validate input, getdata returned HTTPError: All connection attempts failed : failure to connect to envoy. : Validate if correct IP address of the Envoy is used - Fetched (1 of 2) in 0.0 sec from http://x.x.x.x/production.json?details=1: : : Was 'use Enlighten' checked when using tokens or validate username/pw used for legacy devices. - Lifetime Net Energy Consumption / Production shows 0 or incorrect values. This is the case when the Consumption CT is not available or installed in Load only mode. ================================================ FILE: custom_components/enphase_envoy_custom/__init__.py ================================================ """The Enphase Envoy integration.""" from __future__ import annotations from datetime import timedelta import logging import async_timeout from .envoy_reader import EnvoyReader import httpx from numpy import isin from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.storage import Store from .const import COORDINATOR, DOMAIN, NAME, PLATFORMS, SENSORS, CONF_USE_ENLIGHTEN, CONF_SERIAL, PHASE_SENSORS, DEFAULT_SCAN_INTERVAL SCAN_INTERVAL = timedelta(seconds=60) STORAGE_KEY = "envoy" STORAGE_VERSION = 1 FETCH_RETRIES = 1 FETCH_TIMEOUT_SECONDS = 30 FETCH_HOLDOFF_SECONDS = 0 COLLECTION_TIMEOUT_SECONDS = 55 _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Enphase Envoy from a config entry.""" config = entry.data options = entry.options name = config[CONF_NAME] # Setup persistent storage, to save tokens between home assistant restarts store = Store(hass, STORAGE_VERSION, ".".join([STORAGE_KEY, entry.entry_id])) envoy_reader = EnvoyReader( config[CONF_HOST], username=config[CONF_USERNAME], password=config[CONF_PASSWORD], enlighten_user=config[CONF_USERNAME], enlighten_pass=config[CONF_PASSWORD], inverters=True, # async_client=get_async_client(hass), use_enlighten_owner_token=config.get(CONF_USE_ENLIGHTEN, False), enlighten_serial_num=config[CONF_SERIAL], https_flag='s' if config.get(CONF_USE_ENLIGHTEN, False) else '', store=store, fetch_retries=options.get("data_fetch_retry_count", FETCH_RETRIES), fetch_timeout_seconds=options.get("data_fetch_timeout_seconds", FETCH_TIMEOUT_SECONDS), fetch_holdoff_seconds=options.get("data_fetch_holdoff_seconds", FETCH_HOLDOFF_SECONDS), do_not_use_production_json=options.get("do_not_use_production_json",False), ) await envoy_reader._sync_store() async def async_update_data(): """Fetch data from API endpoint.""" data = {} async with async_timeout.timeout(options.get("data_collection_timeout_seconds", COLLECTION_TIMEOUT_SECONDS)): try: await envoy_reader.getData() except httpx.HTTPStatusError as err: raise ConfigEntryAuthFailed from err except httpx.HTTPError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err for description in SENSORS: if description.key == "inverters": data[ "inverters_production" ] = await envoy_reader.inverters_production() elif description.key == "batteries": battery_data = await envoy_reader.battery_storage() if isinstance(battery_data, list) and len(battery_data) > 0: battery_dict = {} for item in battery_data: battery_dict[item["serial_num"]] = item data[description.key] = battery_dict elif (description.key not in ["current_battery_capacity", "total_battery_percentage"]): data[description.key] = await getattr( envoy_reader, description.key )() for description in PHASE_SENSORS: if description.key[:-2] in [ "none_known_at_this_time_" ]: # call phase function for these data[description.key] = await getattr(envoy_reader, description.key[:-3]+"_phase")( description.key[-2:].lower()) else: #catchall for non-specified phase sensors #get attributes for phase sensors based on key name #Removes _L1, _L2 or _L3 from key to call base non-phased function #Pass l1, l2 or l3 as parameter to _phase function data[description.key] = await getattr(envoy_reader, description.key[:-3])( description.key[-2:].lower()) data["grid_status"] = await envoy_reader.grid_status() data["envoy_info"] = await envoy_reader.envoy_info() _LOGGER.debug("Retrieved data from API: %s", data) await envoy_reader._sync_store() return data coordinator = DataUpdateCoordinator( hass, _LOGGER, name=f"envoy {name}", update_method=async_update_data, update_interval=timedelta( seconds=options.get("data_interval", DEFAULT_SCAN_INTERVAL) ) ) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryAuthFailed: envoy_reader.get_inverters = False await coordinator.async_config_entry_first_refresh() if not entry.unique_id: try: serial = await envoy_reader.get_full_serial_number() except httpx.HTTPError: pass else: hass.config_entries.async_update_entry(entry, unique_id=serial) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { COORDINATOR: coordinator, NAME: name, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok ================================================ FILE: custom_components/enphase_envoy_custom/binary_sensor.py ================================================ from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.entity import DeviceInfo from .const import COORDINATOR, DOMAIN, NAME, BINARY_SENSORS, ICON async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: data = hass.data[DOMAIN][config_entry.entry_id] coordinator = data[COORDINATOR] name = data[NAME] entities = [] for sensor_description in BINARY_SENSORS: if sensor_description.key == "grid_status": if coordinator.data.get("grid_status") is not None: entities.append( EnvoyGridStatusEntity( sensor_description, sensor_description.name, name, config_entry.unique_id, None, coordinator, ) ) async_add_entities(entities) class EnvoyGridStatusEntity(CoordinatorEntity, BinarySensorEntity): def __init__( self, description, name, device_name, device_serial_number, serial_number, coordinator, ): self.entity_description = description self._name = name self._serial_number = serial_number self._device_name = device_name self._device_serial_number = device_serial_number CoordinatorEntity.__init__(self, coordinator) @property def icon(self): """Icon to use in the frontend, if any.""" return ICON @property def name(self): """Return the name of the sensor.""" return self._name @property def unique_id(self): """Return the unique id of the sensor.""" if self._serial_number: return self._serial_number if self._device_serial_number: return f"{self._device_serial_number}_{self.entity_description.key}" @property def device_info(self) -> DeviceInfo or None: """Return the device_info of the device.""" if not self._device_serial_number: return None return DeviceInfo( identifiers={(DOMAIN, str(self._device_serial_number))}, manufacturer="Enphase", model="Envoy", name=self._device_name, ) @property def is_on(self) -> bool: """Return the status of the requested attribute.""" return self.coordinator.data.get("grid_status") == "closed" ================================================ FILE: custom_components/enphase_envoy_custom/config_flow.py ================================================ """Config flow for Enphase Envoy integration.""" from __future__ import annotations import contextlib import logging from typing import Any from .envoy_reader import EnvoyReader import httpx import voluptuous as vol from homeassistant import config_entries from homeassistant.components import network from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.util.network import is_ipv4_address from .const import DOMAIN, CONF_SERIAL, CONF_USE_ENLIGHTEN, DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) ENVOY = "Envoy" async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> EnvoyReader: """Validate the user input allows us to connect.""" envoy_reader = EnvoyReader( data[CONF_HOST], username=data[CONF_USERNAME], password=data[CONF_PASSWORD], enlighten_user=data[CONF_USERNAME], enlighten_pass=data[CONF_PASSWORD], inverters=False, # async_client=get_async_client(hass), use_enlighten_owner_token=data.get(CONF_USE_ENLIGHTEN, False), enlighten_serial_num=data[CONF_SERIAL], https_flag='s' if data.get(CONF_USE_ENLIGHTEN,False) else '', fetch_timeout_seconds=60 ) try: await envoy_reader.getData() except httpx.HTTPStatusError as err: _LOGGER.warning("Validate input, getdata returned HTTPStatusError: %s",err) raise InvalidAuth from err except (httpx.HTTPError) as err: _LOGGER.warning("Validate input, getdata returned HTTPError: %s",err) raise CannotConnect from err except (RuntimeError) as err: _LOGGER.warning("Validate input, getdata returned RuntimeError: %s",err) raise return envoy_reader async def ipv4asdefault(hass: HomeAssistant): adapters = await network.async_get_adapters(hass) for adapter in adapters: if adapter["default"]: return adapter["ipv4"] is not None return False class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Enphase Envoy.""" VERSION = 1 def __init__(self): """Initialize an envoy flow.""" self.ip_address = None self.username = None self._reauth_entry = None @callback def _async_generate_schema(self): """Generate schema.""" schema = {} if self.ip_address: schema[vol.Required(CONF_HOST, default=self.ip_address)] = vol.In( [self.ip_address] ) else: schema[vol.Required(CONF_HOST)] = str schema[vol.Optional(CONF_USERNAME, default=self.username)] = str schema[vol.Optional(CONF_PASSWORD, default="")] = str schema[vol.Optional(CONF_SERIAL, default=self.unique_id)] = str schema[vol.Optional(CONF_USE_ENLIGHTEN)] = bool return vol.Schema(schema) @callback def _async_current_hosts(self): """Return a set of hosts.""" return { entry.data[CONF_HOST] for entry in self._async_current_entries(include_ignore=False) if CONF_HOST in entry.data } async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" serial = discovery_info.properties["serialnum"] await self.async_set_unique_id(serial) ipv4_default = await ipv4asdefault(self.hass) if ipv4_default and not is_ipv4_address(discovery_info.host): return self.async_abort(reason="not_ipv4_address") # autodiscovery is updating the ip address of an existing envoy with matching serial to new detected ip adress self.ip_address = discovery_info.host self._abort_if_unique_id_configured({CONF_HOST: self.ip_address}) for entry in self._async_current_entries(include_ignore=False): if ( entry.unique_id is None and CONF_HOST in entry.data and entry.data[CONF_HOST] == self.ip_address ): title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY self.hass.config_entries.async_update_entry( entry, title=title, unique_id=serial ) self.hass.async_create_task( self.hass.config_entries.async_reload(entry.entry_id) ) return self.async_abort(reason="already_configured") return await self.async_step_user() async def async_step_reauth(self, user_input): """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) return await self.async_step_user() def _async_envoy_name(self) -> str: """Return the name of the envoy.""" if self.unique_id: return f"{ENVOY} {self.unique_id}" return ENVOY async def _async_set_unique_id_from_envoy(self, envoy_reader: EnvoyReader) -> bool: """Set the unique id by fetching it from the envoy.""" serial = None with contextlib.suppress(httpx.HTTPError): serial = await envoy_reader.get_full_serial_number() if serial: await self.async_set_unique_id(serial) return True return False async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: if ( not self._reauth_entry and user_input[CONF_HOST] in self._async_current_hosts() ): return self.async_abort(reason="already_configured") try: envoy_reader = await validate_input(self.hass, user_input) except RuntimeError as rerr: errors["base"] = "invalid_auth" except CannotConnect as cerr: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" except Exception as exc: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception in validate input %s",exc) errors["base"] = "unknown" else: data = user_input.copy() data[CONF_NAME] = self._async_envoy_name() if self._reauth_entry: self.hass.config_entries.async_update_entry( self._reauth_entry, data=data, ) return self.async_abort(reason="reauth_successful") if not self.unique_id and await self._async_set_unique_id_from_envoy( envoy_reader ): data[CONF_NAME] = self._async_envoy_name() if self.unique_id: self._abort_if_unique_id_configured({CONF_HOST: data[CONF_HOST]}) return self.async_create_entry(title=data[CONF_NAME], data=data) if self.unique_id: self.context["title_placeholders"] = { CONF_SERIAL: self.unique_id, CONF_HOST: self.ip_address, } return self.async_show_form( step_id="user", data_schema=self._async_generate_schema(), errors=errors, ) @staticmethod @callback def async_get_options_flow(config_entry): return EnvoyOptionsFlowHandler(config_entry) class EnvoyOptionsFlowHandler(config_entries.OptionsFlow): """Envoy config flow options handler.""" def __init__(self, config_entry): """Initialize Envoy options flow.""" self.config_entry = config_entry async def async_step_init(self, _user_input=None): """Manage the options.""" return await self.async_step_user() async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) schema = { vol.Optional( "data_interval", default=self.config_entry.options.get( "data_interval", DEFAULT_SCAN_INTERVAL ), ): vol.All(vol.Coerce(int), vol.Range(min=5)), vol.Optional( "data_fetch_timeout_seconds", default=self.config_entry.options.get( "data_fetch_timeout_seconds", 30 ), ): vol.All(vol.Coerce(int), vol.Range(min=5)), vol.Optional( "data_fetch_retry_count", default=self.config_entry.options.get( "data_fetch_retry_count", 1 ), ): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional( "data_fetch_holdoff_seconds", default=self.config_entry.options.get( "data_fetch_holdoff_seconds", 0 ), ): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional( "data_collection_timeout_seconds", default=self.config_entry.options.get( "data_collection_timeout_seconds", 55 ), ): vol.All(vol.Coerce(int), vol.Range(min=30)), vol.Optional( "do_not_use_production_json", default=self.config_entry.options.get( "do_not_use_production_json", False ), ): bool, } return self.async_show_form(step_id="user", data_schema=vol.Schema(schema)) class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" ================================================ FILE: custom_components/enphase_envoy_custom/const.py ================================================ """The enphase_envoy component.""" from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntityDescription ) from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntityDescription, SensorStateClass ) from homeassistant.const import ( UnitOfEnergy, UnitOfPower, UnitOfElectricPotential, UnitOfFrequency, Platform, PERCENTAGE ) DOMAIN = "enphase_envoy" PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] ICON = "mdi:flash" COORDINATOR = "coordinator" NAME = "name" DEFAULT_SCAN_INTERVAL = 60 # default in seconds CONF_SERIAL = "serial" CONF_USE_ENLIGHTEN = "use_enlighten" SENSORS = ( SensorEntityDescription( key="production", name="Current Power Production", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_production", name="Today's Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="seven_days_production", name="Last Seven Days Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_production", name="Lifetime Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_production", name="Lifetime Net Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="consumption", name="Current Power Consumption", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="net_consumption", name="Current Net Power Consumption", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_consumption", name="Today's Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="seven_days_consumption", name="Last Seven Days Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_consumption", name="Lifetime Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_consumption", name="Lifetime Net Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="inverters", name="Inverter", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="batteries", name="Battery", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY ), SensorEntityDescription( key="total_battery_percentage", name="Total Battery Percentage", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT ), SensorEntityDescription( key="current_battery_capacity", name="Current Battery Capacity", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY ), SensorEntityDescription( key="pf", name="Power Factor", device_class=SensorDeviceClass.POWER_FACTOR, entity_registry_enabled_default=False, ), SensorEntityDescription( key="voltage", name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, ), SensorEntityDescription( key="frequency", name="Frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_registry_enabled_default=False, ), SensorEntityDescription( key="consumption_Current", name="Consumption Current", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="production_Current", name="Production Current", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="active_inverter_count", name="Active Inverter Count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), ) BINARY_SENSORS = ( BinarySensorEntityDescription( key="grid_status", name="Grid Status", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), ) PHASE_SENSORS = ( SensorEntityDescription( key="production_l1", name="Current Power Production L1", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_production_l1", name="Today's Energy Production L1", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_production_l1", name="Lifetime Energy Production L1", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_production_l1", name="Lifetime Net Energy Production L1", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="production_l2", name="Current Power Production L2", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_production_l2", name="Today's Energy Production L2", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_production_l2", name="Lifetime Energy Production L2", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_production_l2", name="Lifetime Net Energy Production L2", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="production_l3", name="Current Power Production L3", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_production_l3", name="Today's Energy Production L3", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_production_l3", name="Lifetime Energy Production L3", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_production_l3", name="Lifetime Net Energy Production L3", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="consumption_l1", name="Current Power Consumption L1", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="net_consumption_l1", name="Current Net Power Consumption L1", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_consumption_l1", name="Today's Energy Consumption L1", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_consumption_l1", name="Lifetime Energy Consumption L1", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_consumption_l1", name="Lifetime Net Energy Consumption L1", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="consumption_l2", name="Current Power Consumption L2", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="net_consumption_l2", name="Current Net Power Consumption L2", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_consumption_l2", name="Today's Energy Consumption L2", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_consumption_l2", name="Lifetime Energy Consumption L2", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_consumption_l2", name="Lifetime Net Energy Consumption L2", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="consumption_l3", name="Current Power Consumption L3", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="net_consumption_l3", name="Current Net Power Consumption L3", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), SensorEntityDescription( key="daily_consumption_l3", name="Today's Energy Consumption L3", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_consumption_l3", name="Lifetime Energy Consumption L3", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="lifetime_net_consumption_l3", name="Lifetime Net Energy Consumption L3", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="pf_l1", name="Power Factor L1", device_class=SensorDeviceClass.POWER_FACTOR, entity_registry_enabled_default=False, ), SensorEntityDescription( key="pf_l2", name="Power Factor L2", device_class=SensorDeviceClass.POWER_FACTOR, entity_registry_enabled_default=False, ), SensorEntityDescription( key="pf_l3", name="Power Factor L3", device_class=SensorDeviceClass.POWER_FACTOR, entity_registry_enabled_default=False, ), SensorEntityDescription( key="voltage_l1", name="Voltage L1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, ), SensorEntityDescription( key="voltage_l2", name="Voltage L2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, ), SensorEntityDescription( key="voltage_l3", name="Voltage L3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, ), SensorEntityDescription( key="frequency_l1", name="Frequency L1", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_registry_enabled_default=False, ), SensorEntityDescription( key="frequency_l2", name="Frequency L2", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_registry_enabled_default=False, ), SensorEntityDescription( key="frequency_l3", name="Frequency L3", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, entity_registry_enabled_default=False, ), SensorEntityDescription( key="consumption_Current_l1", name="Consumption Current L1", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="consumption_Current_l2", name="Consumption Current L2", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="consumption_Current_l3", name="Consumption Current L3", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="production_Current_l1", name="Production Current L1", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="production_Current_l2", name="Production Current L2", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="production_Current_l3", name="Production Current L3", native_unit_of_measurement="A", device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), ) BATTERY_ENERGY_DISCHARGED_SENSOR = SensorEntityDescription( key="battery_energy_discharged", name="Battery Energy Discharged", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY ) BATTERY_ENERGY_CHARGED_SENSOR = SensorEntityDescription( key="battery_energy_charged", name="Battery Energy Charged", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY ) ================================================ FILE: custom_components/enphase_envoy_custom/diagnostics.py ================================================ """Diagnostics support for Enphase Envoy.""" from __future__ import annotations from typing import Any from attr import asdict from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import COORDINATOR, DOMAIN CONF_TITLE = "title" TO_REDACT = { # CONF_NAME, CONF_PASSWORD, # Config entry title and unique ID may contain sensitive data: # CONF_TITLE, # CONF_UNIQUE_ID, CONF_USERNAME, } async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) devices = [] registry_devices = dr.async_entries_for_config_entry( device_registry, entry.entry_id ) for device in registry_devices: entities = [] registry_entities = er.async_entries_for_device( entity_registry, device_id=device.id, include_disabled_entities=True, ) for entity in registry_entities: state_dict = None if state := hass.states.get(entity.entity_id): state_dict = dict(state.as_dict()) state_dict.pop("context", None) entities.append({"entry": asdict(entity), "state": state_dict}) devices.append({"device": asdict(device), "entities": entities}) return async_redact_data( { "entry": entry.as_dict(), "data": coordinator.data, "Note": "Entities that show as null are not available for your Envoy", "devices": devices, }, TO_REDACT, ) ================================================ FILE: custom_components/enphase_envoy_custom/envoy_reader.py ================================================ """Module to read production and consumption values from an Enphase Envoy on the local network.""" import argparse import datetime import logging import time from json.decoder import JSONDecodeError import json from ipaddress import IPv4Address, IPv6Address import sys import getpass #Modules not in standard Python Library - add to manifest requirements import re import jwt import asyncio import httpx import xmltodict from envoy_utils.envoy_utils import EnvoyUtils # # Legacy parser is only used on ancient firmwares # PRODUCTION_REGEX = r"Currentl.*\s+\s*(\d+|\d+\.\d+)\s*(W|kW|MW)" DAY_PRODUCTION_REGEX = r"Today\s+\s*(\d+|\d+\.\d+)\s*(Wh|kWh|MWh)" WEEK_PRODUCTION_REGEX = ( r"Past Week\s+\s*(\d+|\d+\.\d+)\s*(Wh|kWh|MWh)" ) LIFE_PRODUCTION_REGEX = ( r"Since Installation\s+\s*(\d+|\d+\.\d+)\s*(Wh|kWh|MWh)" ) SERIAL_REGEX = re.compile(r"Envoy\s*Serial\s*Number:\s*([0-9]+)") ACTIVE_INVERTER_COUNT_REGEX = r"Number of Microinverters Online\s*\s*(\d*)\s*" ENDPOINT_URL_PRODUCTION_JSON = "http{}://{}/production.json?details=1" ENDPOINT_URL_PRODUCTION_V1 = "http{}://{}/api/v1/production" ENDPOINT_URL_PRODUCTION_INVERTERS = "http{}://{}/api/v1/production/inverters" ENDPOINT_URL_PRODUCTION = "http{}://{}/production" ENDPOINT_URL_CHECK_JWT = "https://{}/auth/check_jwt" ENDPOINT_URL_ENSEMBLE_INVENTORY = "http{}://{}/ivp/ensemble/inventory" ENDPOINT_URL_HOME_JSON = "http{}://{}/home.json" ENDPOINT_URL_HOME = "http{}://{}/home" ENDPOINT_URL_INFO_XML = "http{}://{}/info" ENDPOINT_URL_METERS = "http{}://{}/ivp/meters" ENDPOINT_URL_METERS_REPORTS = "http{}://{}/ivp/meters/reports" ENDPOINT_URL_METERS_READINGS = "http{}://{}/ivp/meters/readings" # pylint: disable=pointless-string-statement ENVOY_MODEL_S = "PC" ENVOY_MODEL_C = "P" ENVOY_MODEL_LEGACY = "P0" LOGIN_URL = "https://entrez.enphaseenergy.com/login_main_page" TOKEN_URL = "https://entrez.enphaseenergy.com/entrez_tokens" # paths for the enlighten 1 year owner token ENLIGHTEN_AUTH_URL = "https://enlighten.enphaseenergy.com/login/login.json" ENLIGHTEN_TOKEN_URL = "https://entrez.enphaseenergy.com/tokens" _LOGGER = logging.getLogger(__name__) def has_production_and_consumption(json): """Check if json has keys for both production and consumption.""" return "production" in json and "consumption" in json def has_metering_setup(json): """Check if Active Count of Production CTs (eim) installed is greater than one.""" return json["production"][1]["activeCount"] > 0 def has_production_metering_setup(json): """Check if Production CTs (eim) are installed.""" return json[0]["state"] == "enabled" def has_consumption_metering_setup(json): """Check if Consumption CTs (eim) are installed.""" return json[1]["state"] == "enabled" def has_net_consumption_meters_type(json): """Check if Consumption measurement type is net-consumption.""" return json[1]["measurementType"] == "net-consumption" def get_production_meters_phase_count(json): """Get Count of Production CTs (eim) installed.""" return json[0]["phaseCount"] def get_consumption_meters_phase_count(json): """Get Count of Consumption CTs (eim) installed.""" return json[1]["phaseCount"] def is_ipv6_address(address: str) -> bool: """Check if a given string is an IPv6 address.""" try: IPv6Address(address) except ValueError: return False return True class SwitchToHTTPS(Exception): pass class EnvoyReader: # pylint: disable=too-many-instance-attributes """Instance of EnvoyReader""" # P0 for older Envoy model C, s/w < R3.9 no json pages # P for production data only (ie. Envoy model C, s/w >= R3.9) # PC for production and consumption data (ie. Envoy model S) message_battery_not_available = ( "Battery storage data not available for your Envoy device." ) message_production_not_available = ( "CTs production data not available for your Envoy device." ) message_consumption_not_available = ( "CTs consumption data not available for your Envoy device." ) message_grid_status_not_available = ( "Grid status not available for your Envoy device." ) message_frequency_not_available = ( "Frequency data not available for your Envoy device." ) message_voltage_not_available = ( "Voltage data not available for your Envoy device." ) message_pf_not_available = ( "Power Factor data not available for your Envoy device." ) message_current_consumption_not_available = ( "Amps consumption data not available for your Envoy device." ) message_current_production_not_available = ( "Amps production data not available for your Envoy device." ) message_active_inverters_not_available = ( "Active Inverter count not available for your Envoy device." ) def __init__( # pylint: disable=too-many-arguments self, host, username="envoy", password="", inverters=False, async_client=None, enlighten_user=None, enlighten_pass=None, commissioned=False, enlighten_site_id=None, enlighten_serial_num=None, https_flag="", use_enlighten_owner_token=False, token_refresh_buffer_seconds=0, store=None, info_refresh_buffer_seconds=3600, fetch_timeout_seconds=30, fetch_holdoff_seconds=0, fetch_retries=1, do_not_use_production_json=False, ): """Init the EnvoyReader.""" self.host = host.lower().replace('[','').replace(']','') # IPv6 addresses need to be enclosed in brackets if is_ipv6_address(self.host): self.host = f"[{self.host}]" self.username = username self.password = password self.get_inverters = inverters self.endpoint_type = None self.has_grid_status = True self.serial_number_last_six = None self.endpoint_meters_reports_json_results = None self.endpoint_meters_readings_json_results = None self.endpoint_production_json_results = None self.endpoint_production_v1_results = None self.endpoint_production_inverters = None self.endpoint_production_results = None self.endpoint_ensemble_json_results = None self.endpoint_home_json_results = None self.endpoint_home_results = None self.isProductionMeteringEnabled = False # pylint: disable=invalid-name self.isConsumptionMeteringEnabled = False # pylint: disable=invalid-name self.net_consumption_meters_type = False self.production_meters_phase_count = 0 self.consumption_meters_phase_count = 0 self._async_client = async_client self._authorization_header = None self._cookies = None self.enlighten_user = enlighten_user self.enlighten_pass = enlighten_pass self.commissioned = commissioned self.enlighten_site_id = enlighten_site_id self.enlighten_serial_num = enlighten_serial_num self.https_flag = https_flag self.use_enlighten_owner_token = use_enlighten_owner_token self.token_refresh_buffer_seconds = token_refresh_buffer_seconds self.endpoint_info_results = None self.endpoint_meters_json_results = None self.info_refresh_buffer_seconds = info_refresh_buffer_seconds self.info_next_refresh_time = datetime.datetime.now() self.meters_next_refresh_time = datetime.datetime.now() self._store = store self._store_data = {} self._store_update_pending = False self._fetch_timeout_seconds = fetch_timeout_seconds self._fetch_holdoff_seconds = fetch_holdoff_seconds self._fetch_retries = max(fetch_retries,1) self._do_not_use_production_json=do_not_use_production_json @property def _token(self): return self._store_data.get("token", "") @_token.setter def _token(self, token_value): self._store_data["token"] = token_value self._store_update_pending = True async def _sync_store(self): if self._store and not self._store_data: self._store_data = await self._store.async_load() or {} if self._store and self._store_update_pending: self._store_update_pending = False await self._store.async_save(self._store_data) @property def async_client(self): """Return the httpx client.""" return self._async_client or httpx.AsyncClient(verify=False, headers=self._authorization_header, cookies=self._cookies) @property def non_local_async_client(self): """Return the httpx client for non-local usage.""" return self._async_client or httpx.AsyncClient(verify=True, headers=self._authorization_header, cookies=self._cookies) async def _update(self): """Update the data.""" _LOGGER.debug("_update running") if self.endpoint_type == ENVOY_MODEL_S: await self._update_meters_endpoint() await self._update_from_pc_endpoint() if self.endpoint_type == ENVOY_MODEL_C or ( self.endpoint_type == ENVOY_MODEL_S and not self.isProductionMeteringEnabled ): await self._update_from_p_endpoint() if self.endpoint_type == ENVOY_MODEL_LEGACY: await self._update_from_p0_endpoint() await self._update_info_endpoint() async def _update_from_meters_reports_endpoint(self): """Update from ivp/meters endpoint.""" if self.endpoint_type == ENVOY_MODEL_S: #only touch meters reports if confirmed envoy model S, other type choke up on this request await self._update_endpoint( "endpoint_meters_reports_json_results", ENDPOINT_URL_METERS_REPORTS ) async def _update_from_meters_readings_endpoint(self): """Update from ivp/meters/readings endpoint.""" if self.endpoint_type == ENVOY_MODEL_S: #only touch meters reports if confirmed envoy model S, other type choke up on this request await self._update_endpoint( "endpoint_meters_readings_json_results", ENDPOINT_URL_METERS_READINGS ) async def _update_from_pc_endpoint(self,detectmode=False): """Update from PC endpoint.""" if not self._do_not_use_production_json or detectmode: await self._update_endpoint( "endpoint_production_json_results", ENDPOINT_URL_PRODUCTION_JSON ) await self._update_endpoint( "endpoint_ensemble_json_results", ENDPOINT_URL_ENSEMBLE_INVENTORY ) if self.has_grid_status: await self._update_endpoint( "endpoint_home_json_results", ENDPOINT_URL_HOME_JSON ) async def _update_from_p_endpoint(self): """Update from P endpoint.""" await self._update_endpoint( "endpoint_production_v1_results", ENDPOINT_URL_PRODUCTION_V1 ) async def _update_from_p0_endpoint(self): """Update from P0 endpoint.""" await self._update_endpoint( "endpoint_production_results", ENDPOINT_URL_PRODUCTION ) await self._update_endpoint( "endpoint_home_results", ENDPOINT_URL_HOME ) async def _update_info_endpoint(self): """Update from info endpoint if next time expired.""" if self.info_next_refresh_time <= datetime.datetime.now(): await self._update_endpoint("endpoint_info_results", ENDPOINT_URL_INFO_XML) self.info_next_refresh_time = datetime.datetime.now() + datetime.timedelta( seconds=self.info_refresh_buffer_seconds ) _LOGGER.debug( "Info endpoint updated, set next update time: %s using interval: %s", self.info_next_refresh_time, self.info_refresh_buffer_seconds, ) else: _LOGGER.debug( "Info endpoint next update time is: %s using interval: %s", self.info_next_refresh_time, self.info_refresh_buffer_seconds, ) async def _update_meters_endpoint(self): """Update from meters endpoint if next time expried.""" if self.meters_next_refresh_time <= datetime.datetime.now(): await self._update_endpoint("endpoint_meters_json_results", ENDPOINT_URL_METERS) #some devices return [] for ivp/meters if self.endpoint_meters_json_results and self.endpoint_meters_json_results.text != "[]": self.isProductionMeteringEnabled = has_production_metering_setup( self.endpoint_meters_json_results.json() ) self.isConsumptionMeteringEnabled = has_consumption_metering_setup( self.endpoint_meters_json_results.json() ) self.net_consumption_meters_type = has_net_consumption_meters_type( self.endpoint_meters_json_results.json() ) self.production_meters_phase_count = get_production_meters_phase_count( self.endpoint_meters_json_results.json() ) self.consumption_meters_phase_count = get_consumption_meters_phase_count( self.endpoint_meters_json_results.json() ) self.meters_next_refresh_time = datetime.datetime.now() + datetime.timedelta( seconds=self.info_refresh_buffer_seconds ) _LOGGER.debug( "Meters endpoint updated, set next update time: %s using interval: %s", self.meters_next_refresh_time, self.info_refresh_buffer_seconds, ) else: _LOGGER.debug( "Meters endpoint next update time is: %s using interval: %s", self.meters_next_refresh_time, self.info_refresh_buffer_seconds, ) await self._update_from_meters_reports_endpoint() await self._update_from_meters_readings_endpoint() async def _update_endpoint(self, attr, url): """Update a property from an endpoint.""" formatted_url = url.format(self.https_flag, self.host) response = await self._async_fetch_with_retry( formatted_url, follow_redirects=False ) setattr(self, attr, response) async def _async_fetch_with_retry(self, url, **kwargs): """Retry 3 times to fetch the url if there is a transport error.""" for attempt in range(self._fetch_retries + 1): header = " " if self._authorization_header: header = "