├── .gitignore ├── requirements.txt ├── test_data └── envoy_metered │ ├── endpoint_production_power.json │ ├── endpoint_production_v1.json │ ├── endpoint_dpel.json │ ├── endpoint_pcu_comm_check.json │ ├── endpoint_ensemble_power.json │ ├── endpoint_meters.json │ ├── endpoint_pdm_energy.json │ ├── endpoint_ensemble_secctrl.json │ ├── endpoint_peb_newscan.json │ ├── endpoint_production_report.json │ ├── endpoint_home_json.json │ ├── endpoint_consumption_report.json │ ├── endpoint_info.xml │ ├── endpoint_installer_agf_index_json.json │ ├── endpoint_production_inverters.json │ ├── endpoint_admin_lib_tariff.json │ ├── endpoint_production_json.json │ ├── endpoint_ensemble_inventory.json │ ├── endpoint_meters_readings.json │ ├── endpoint_devstatus.json │ ├── endpoint_inventory.json │ └── endpoint_device_data.json ├── hacs.json ├── .github ├── workflows │ ├── black.yml │ └── hassfest.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── custom_components └── enphase_envoy │ ├── manifest.json │ ├── services.yaml │ ├── diagnostics.py │ ├── strings.json │ ├── number.py │ ├── select.py │ ├── translations │ ├── nl.json │ └── en.json │ ├── envoy_endpoints.py │ ├── envoy_test_data.py │ ├── switch.py │ ├── __init__.py │ ├── config_flow.py │ ├── binary_sensor.py │ └── sensor.py ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httpx 2 | pyjwt 3 | xmltodict 4 | jsonpath 5 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_production_power.json: -------------------------------------------------------------------------------- 1 | { 2 | "powerForcedOff": false 3 | } 4 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Enphase Envoy (Installer)", 3 | "render_readme": true, 4 | "content_in_root": false, 5 | "homeassistant": "2024.9.0" 6 | } 7 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_production_v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "wattHoursToday": 21515, 3 | "wattHoursSevenDays": 198717, 4 | "wattHoursLifetime": 1820907, 5 | "wattsNow": 3185 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: psf/black@stable 11 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_dpel.json: -------------------------------------------------------------------------------- 1 | {"dynamic_pel_settings": {"enable": true, "export_limit": true, "limit_value_W": 50.0, "slew_rate": 50.0, "enable_dynamic_limiting": false}, "filename": "site_settings", "version": "00.00.01"} 2 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_pcu_comm_check.json: -------------------------------------------------------------------------------- 1 | {"999999913010": 5, 2 | "999999913012": 5, 3 | "999999912750": 5, 4 | "999999912983": 5, 5 | "999999908520": 5, 6 | "999999909983": 5, 7 | "999999908521": 3, 8 | "999999912669": 4, 9 | "999999913748": 5, 10 | "999999909985": 5, 11 | "999999915285": 5, 12 | "999999915246": 5, 13 | "999999912590": 5, 14 | "999999910862": 5} -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_ensemble_power.json: -------------------------------------------------------------------------------- 1 | { 2 | "devices:": [ 3 | { 4 | "serial_num": "999999995065", 5 | "real_power_mw": 179000, 6 | "apparent_power_mva": 179000, 7 | "soc": 89 8 | }, 9 | { 10 | "serial_num": "999999995067", 11 | "real_power_mw": 190000, 12 | "apparent_power_mva": 190000, 13 | "soc": 89 14 | }, 15 | { 16 | "serial_num": "999999995069", 17 | "real_power_mw": 183000, 18 | "apparent_power_mva": 183000, 19 | "soc": 89 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /custom_components/enphase_envoy/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "enphase_envoy", 3 | "name": "Enphase Envoy (Installer)", 4 | "codeowners": ["@vincentwolsink", "@mnederlof"], 5 | "config_flow": true, 6 | "dependencies": ["zeroconf"], 7 | "documentation": "https://github.com/vincentwolsink/home_assistant_enphase_envoy_installer/", 8 | "integration_type": "hub", 9 | "iot_class": "local_polling", 10 | "issue_tracker": "https://github.com/vincentwolsink/home_assistant_enphase_envoy_installer/issues", 11 | "requirements": ["pyjwt", "xmltodict", "httpx", "jsonpath"], 12 | "version": "0.8.1", 13 | "zeroconf": ["_enphase-envoy._tcp.local."] 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Ask for a feature to be implemented. 4 | title: "[FEATURE] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature** 11 | A clear and concise description of what you would like. 12 | 13 | **Account type** 14 | - [ ] Installer 15 | - [ ] DIY / DHZ 16 | - [ ] Home Owner (This integration is not (fully) functional with a home owner account!) 17 | 18 | **Envoy** 19 | - [ ] Metered with CTs installed 20 | - [ ] Metered without CTs 21 | - [ ] Standard 22 | 23 | - FW version: D7.xxx 24 | - Amount of micro inverters connected: x 25 | 26 | **Home Assistant** 27 | - Version: 2023.x 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Account type** 14 | - [ ] Installer 15 | - [ ] DIY / DHZ 16 | - [ ] Home Owner (This integration is not (fully) functional with a home owner account!) 17 | 18 | **Envoy** 19 | - [ ] Metered with CTs installed 20 | - [ ] Metered without CTs 21 | - [ ] Standard 22 | 23 | - FW version: D7.xxx 24 | - Amount of micro inverters connected: x 25 | 26 | **Home Assistant** 27 | - Version: 2023.x 28 | 29 | **Additional context** 30 | ``` 31 | Relevant snippet of Home Assistant error log. 32 | ``` 33 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/services.yaml: -------------------------------------------------------------------------------- 1 | get_grid_profiles: 2 | set_grid_profile: 3 | fields: 4 | profile: 5 | required: true 6 | example: "EN 50549-1:2019 RfG E02 Netherlands:1.2.4" 7 | selector: 8 | text: 9 | upload_grid_profile: 10 | fields: 11 | file: 12 | required: true 13 | example: "/share/grid_profiles/64d31e5868ac391817b21af1" 14 | selector: 15 | text: 16 | enable_dpel: 17 | fields: 18 | watt: 19 | example: 50 20 | required: true 21 | selector: 22 | number: 23 | slew_rate: 24 | example: 50 25 | required: false 26 | selector: 27 | number: 28 | export_limit: 29 | example: true 30 | required: false 31 | selector: 32 | boolean: 33 | disable_dpel: 34 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_meters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "eid": 704643328, 4 | "state": "enabled", 5 | "measurementType": "production", 6 | "phaseMode": "split", 7 | "phaseCount": 3, 8 | "meteringStatus": "normal", 9 | "statusFlags": [] 10 | }, 11 | { 12 | "eid": 704643584, 13 | "state": "enabled", 14 | "measurementType": "net-consumption", 15 | "phaseMode": "split", 16 | "phaseCount": 3, 17 | "meteringStatus": "normal", 18 | "statusFlags": [] 19 | }, 20 | { 21 | "eid": 704643840, 22 | "state": "enabled", 23 | "measurementType": "storage", 24 | "phaseMode": "split", 25 | "phaseCount": 2, 26 | "meteringStatus": "normal", 27 | "statusFlags": [] 28 | } 29 | ] -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_pdm_energy.json: -------------------------------------------------------------------------------- 1 | { 2 | "production": { 3 | "pcu": { 4 | "wattHoursToday": 14291, 5 | "wattHoursSevenDays": 158957, 6 | "wattHoursLifetime": 3442571, 7 | "wattsNow": 13 8 | }, 9 | "rgm": { 10 | "wattHoursToday": 0, 11 | "wattHoursSevenDays": 0, 12 | "wattHoursLifetime": 0, 13 | "wattsNow": 0 14 | }, 15 | "eim": { 16 | "wattHoursToday": 14471, 17 | "wattHoursSevenDays": 160852, 18 | "wattHoursLifetime": 2043071, 19 | "wattsNow": 18 20 | } 21 | }, 22 | "consumption": { 23 | "eim": { 24 | "wattHoursToday": 0, 25 | "wattHoursSevenDays": 0, 26 | "wattHoursLifetime": 0, 27 | "wattsNow": 0 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_ensemble_secctrl.json: -------------------------------------------------------------------------------- 1 | { 2 | "shutdown": false, 3 | "freq_bias_hz": 0.19460000097751618, 4 | "voltage_bias_v": 2.365999937057495, 5 | "freq_bias_hz_q8": 313, 6 | "voltage_bias_v_q5": 75, 7 | "freq_bias_hz_phaseb": 0.0, 8 | "voltage_bias_v_phaseb": 0.0, 9 | "freq_bias_hz_q8_phaseb": 0, 10 | "voltage_bias_v_q5_phaseb": 0, 11 | "freq_bias_hz_phasec": 0.0, 12 | "voltage_bias_v_phasec": 0.0, 13 | "freq_bias_hz_q8_phasec": 0, 14 | "voltage_bias_v_q5_phasec": 0, 15 | "configured_backup_soc": 0, 16 | "adjusted_backup_soc": 0, 17 | "agg_soc": 89, 18 | "Max_energy": 10500, 19 | "ENC_agg_soc": 89, 20 | "ENC_agg_soh": 100, 21 | "ENC_agg_backup_energy": 0, 22 | "ENC_agg_avail_energy": 9345, 23 | "Enc_commissioned_capacity": 10500, 24 | "Enc_max_available_capacity": 10500, 25 | "ACB_agg_soc": 0, 26 | "ACB_agg_energy": 0 27 | } -------------------------------------------------------------------------------- /custom_components/enphase_envoy/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for Enphase Envoy.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from homeassistant.components.diagnostics import async_redact_data 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 12 | 13 | from .const import COORDINATOR, DOMAIN 14 | 15 | TO_REDACT = { 16 | CONF_HOST, 17 | CONF_PASSWORD, 18 | CONF_USERNAME, 19 | } 20 | 21 | 22 | async def async_get_config_entry_diagnostics( 23 | hass: HomeAssistant, entry: ConfigEntry 24 | ) -> dict[str, Any]: 25 | """Return diagnostics for a config entry.""" 26 | coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] 27 | 28 | return async_redact_data( 29 | { 30 | "entry": entry.as_dict(), 31 | "data": coordinator.data, 32 | }, 33 | TO_REDACT, 34 | ) 35 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_peb_newscan.json: -------------------------------------------------------------------------------- 1 | { "newDeviceScan" : { "active" : false, "is-suspended" : false, "controlled" : false, "scan-is-done" : false, "in-exclusive-mode" : false, "duration-in-minutes" : 0, "remaining-minutes" : 0, "exclusive-minutes" : 0, "rem-minutes-exclusive" : 0, "inhibit-device-scan" : false, "rqst-opportunity-modulus" : 0, "curr-opportunity-modulus" : 0, "devices-to-be-discovered" : 15, "tot-devices-discovered" : 13, "curr-devices-discovered" : 13, "avg-rate-of-new-devices-discovered-per-min" : 13, "avg-num-of-new-devices-discovered-per-poll" : 13, "pcu": {"expected": 12, "discovered": 12, "this-scan": 12, "per-min": 12, "per-poll": 12}, "acb": {"expected": 0, "discovered": 0, "this-scan": 0, "per-min": 0, "per-poll": 0}, "nsrb": {"expected": 1, "discovered": 1, "this-scan": 1, "per-min": 1, "per-poll": 1}, "esub": {"expected": 2, "discovered": 0, "this-scan": 0, "per-min": 0, "per-poll": 0}, "pld": {"expected": 15, "discovered": 13, "this-scan": 13, "per-min": 13, "per-poll": 13}, "mins-until-next-cycle" : 1, "xdom-disabled-scan" : false, "polling-period-secs" : 900, "polling-is-off" : false,"forget-all-scan" : false }} -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_production_report.json: -------------------------------------------------------------------------------- 1 | { 2 | "createdAt": 1689258566, 3 | "reportType": "production", 4 | "cumulative": { 5 | "currW": 3366.764, 6 | "actPower": 3366.764, 7 | "apprntPwr": 3406.287, 8 | "reactPwr": 283.013, 9 | "whDlvdCum": 1820876.754, 10 | "whRcvdCum": 3513.299, 11 | "varhLagCum": 374043.865, 12 | "varhLeadCum": 0.042, 13 | "vahCum": 2007586.298, 14 | "rmsVoltage": 248.761, 15 | "rmsCurrent": 13.691, 16 | "pwrFactor": 0.99, 17 | "freqHz": 50.00 18 | }, 19 | "lines": [ 20 | { 21 | "currW": 3366.764, 22 | "actPower": 3366.764, 23 | "apprntPwr": 3406.287, 24 | "reactPwr": 283.013, 25 | "whDlvdCum": 1820876.754, 26 | "whRcvdCum": 3513.299, 27 | "varhLagCum": 374043.865, 28 | "varhLeadCum": 0.042, 29 | "vahCum": 2007586.298, 30 | "rmsVoltage": 248.761, 31 | "rmsCurrent": 13.691, 32 | "pwrFactor": 0.99, 33 | "freqHz": 50.00 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_home_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "software_build_epoch": 1687430621, 3 | "timezone": "Europe/Amsterdam", 4 | "current_date": "07/13/2023", 5 | "current_time": "16:30", 6 | "network": { 7 | "web_comm": true, 8 | "ever_reported_to_enlighten": true, 9 | "last_enlighten_report_time": 1689258312, 10 | "primary_interface": "wlan0", 11 | "interfaces": [ 12 | { 13 | "type": "ethernet", 14 | "interface": "eth0", 15 | "mac": "FA:FE:FD:7C:AE:9D", 16 | "dhcp": true, 17 | "ip": "169.254.120.1", 18 | "signal_strength": 0, 19 | "signal_strength_max": 1, 20 | "carrier": false 21 | }, 22 | { 23 | "signal_strength": 3, 24 | "signal_strength_max": 5, 25 | "type": "wifi", 26 | "interface": "wlan0", 27 | "mac": "FA:FE:FD:CE:3B:31", 28 | "dhcp": true, 29 | "ip": "192.168.123.105", 30 | "carrier": true, 31 | "supported": true, 32 | "present": true, 33 | "configured": true, 34 | "status": "connected" 35 | } 36 | ] 37 | }, 38 | "tariff": "time_of_use", 39 | "comm": { 40 | "num": 16, 41 | "level": 5, 42 | "pcu": { 43 | "num": 14, 44 | "level": 5 45 | }, 46 | "acb": { 47 | "num": 0, 48 | "level": 0 49 | }, 50 | "nsrb": { 51 | "num": 2, 52 | "level": 5 53 | }, 54 | "esub": { 55 | "num": 0, 56 | "level": 0 57 | }, 58 | "encharge": [ 59 | { 60 | "num": 0, 61 | "level": 0, 62 | "level_24g": 0, 63 | "level_subg": 0 64 | } 65 | ] 66 | }, 67 | "wireless_connection": [ 68 | { 69 | "signal_strength": 0, 70 | "signal_strength_max": 0, 71 | "type": "zigbee", 72 | "connected": false 73 | }, 74 | { 75 | "signal_strength": 0, 76 | "signal_strength_max": 0, 77 | "type": "subghz", 78 | "connected": false 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_consumption_report.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "createdAt": 1689258576, 4 | "reportType": "total-consumption", 5 | "cumulative": { 6 | "currW": 3366.363, 7 | "actPower": 3366.363, 8 | "apprntPwr": 3381.996, 9 | "reactPwr": 277.693, 10 | "whDlvdCum": 1820885.806, 11 | "whRcvdCum": 0.000, 12 | "varhLagCum": -374044.619, 13 | "varhLeadCum": -0.042, 14 | "vahCum": 0.000, 15 | "rmsVoltage": 248.937, 16 | "rmsCurrent": 13.586, 17 | "pwrFactor": 1.00, 18 | "freqHz": 50.00 19 | }, 20 | "lines": [ 21 | { 22 | "currW": 3366.363, 23 | "actPower": 3366.363, 24 | "apprntPwr": 3381.996, 25 | "reactPwr": 277.693, 26 | "whDlvdCum": 1820885.806, 27 | "whRcvdCum": 0.000, 28 | "varhLagCum": -374044.619, 29 | "varhLeadCum": -0.042, 30 | "vahCum": 0.000, 31 | "rmsVoltage": 248.937, 32 | "rmsCurrent": 13.586, 33 | "pwrFactor": 1.00, 34 | "freqHz": 50.00 35 | } 36 | ] 37 | }, 38 | { 39 | "createdAt": 1689258576, 40 | "reportType": "net-consumption", 41 | "cumulative": { 42 | "currW": 0.000, 43 | "actPower": 0.000, 44 | "apprntPwr": 0.000, 45 | "reactPwr": -0.000, 46 | "whDlvdCum": 0.000, 47 | "whRcvdCum": 0.000, 48 | "varhLagCum": 0.000, 49 | "varhLeadCum": 0.000, 50 | "vahCum": 0.000, 51 | "rmsVoltage": 248.937, 52 | "rmsCurrent": 0.000, 53 | "pwrFactor": 0.00, 54 | "freqHz": 50.00 55 | }, 56 | "lines": [ 57 | { 58 | "currW": 0.000, 59 | "actPower": 0.000, 60 | "apprntPwr": 0.000, 61 | "reactPwr": -0.000, 62 | "whDlvdCum": 0.000, 63 | "whRcvdCum": 0.000, 64 | "varhLagCum": 0.000, 65 | "varhLeadCum": 0.000, 66 | "vahCum": 0.000, 67 | "rmsVoltage": 248.937, 68 | "rmsCurrent": 0.000, 69 | "pwrFactor": 0.00, 70 | "freqHz": 50.00 71 | } 72 | ] 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_info.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 999999900879 6 | 800-00654-r08 7 | D7.6.175 8 | 4c8675 9 | 0 10 | 1 11 | true 12 | 13 | true 14 | 15 | 500-00001-r01 16 | 02.00.00 17 | 1210 18 | 19 | 20 | 500-00011-r02 21 | 04.04.225 22 | 3eb4d3 23 | 24 | 25 | 590-00019-r01 26 | 02.00.01 27 | 1f421b 28 | 29 | 30 | 500-00002-r01 31 | 07.06.175 32 | f79c8d 33 | 34 | 35 | 500-00005-r01 36 | 01.02.371 37 | 373aab 38 | 39 | 40 | 500-00008-r01 41 | 02.01.24 42 | a74d96 43 | 44 | 45 | 500-00010-r01 46 | 07.00.20 47 | 176d57 48 | 49 | 50 | 500-00013-r01 51 | 03.02.08 52 | eaa252 53 | 54 | 55 | 500-00012-r01 56 | 02.02.00 57 | 40061a 58 | 59 | 60 | 500-00020-r01 61 | 21.19.82 62 | 667fd7 63 | 64 | 65 | 500-00016-r01 66 | 02.00.00 67 | 54a6dc 68 | 69 | 70 | 500-00021-r01 71 | 01.00.00 72 | 19ae14 73 | 74 | 75 | 500-00001-r01 76 | 02.00.00 77 | 1210 78 | 79 | 80 | ec2-user-envoy_uber-pkg_master:pkg-Jun-22-23-18:55:22 81 | 1687460237 82 | 02.00.4238 83 | 700-GA 84 | 85 | 86 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "{serial} ({host})", 4 | "step": { 5 | "user": { 6 | "description": "Enter the hostname/ip and serial of your Envoy. Use your Enlighten Installer account credentials.", 7 | "data": { 8 | "host": "[%key:common::config_flow::data::host%]", 9 | "username": "[%key:common::config_flow::data::username%]", 10 | "password": "[%key:common::config_flow::data::password%]", 11 | "serial": "Envoy Serial Number", 12 | "disable_installer_account_use": "I have no installer or DIY enphase account, just Home owner" 13 | } 14 | } 15 | }, 16 | "error": { 17 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 18 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 19 | "unknown": "[%key:common::config_flow::error::unknown%]" 20 | }, 21 | "abort": { 22 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", 23 | "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" 24 | } 25 | }, 26 | "options": { 27 | "step": { 28 | "user": { 29 | "title": "Envoy options", 30 | "data": { 31 | "enable_realtime_updates": "Enable realtime updates (only for metered envoys)", 32 | "realtime_update_throttle": "Minimum time between realtime entity updates [s]", 33 | "disable_negative_production": "Disable negative production values", 34 | "time_between_update": "Minimum time between entity updates [s]", 35 | "getdata_timeout": "Timeout value for fetching data from envoy [s]", 36 | "enable_additional_metrics": "[Metered only] Enable additional metrics like total amps, frequency, apparent and reactive power and power factor.", 37 | "disable_installer_account_use": "Do not collect data that requires installer or DIY enphase account", 38 | "enable_pcu_comm_check": "Enable powerline communication level sensors (slow)", 39 | "devstatus_device_data": "Use alternative endpoint 'devstatus' (installer account only) for device sensors", 40 | "lifetime_production_correction": "Correction of lifetime production value (Wh)", 41 | "disabled_endpoints": "[Advanced] Disabled Envoy endpoints" 42 | }, 43 | "data_description": { 44 | "realtime_update_throttle": "Only applies to realtime updates (to preventing any overload on the system)", 45 | "time_between_update": "This interval only applies to the polling interval (not on the live updates)" 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_installer_agf_index_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "selected_profile": "EN 50549-1:2019 RfG E02 Netherlands:1.3.2", 3 | "selected_profile_id": "65e6901caeee1200f07caeee", 4 | "last_package_profile_md5sum": "00beeb6d88ac522947b15c97d630b48c", 5 | "profile_auto_selected": false, 6 | "mm_version": "01.03.06", 7 | "bt_version": "1.3.6", 8 | "migration_in_progress": false, 9 | "profiles": [ 10 | { 11 | "uuid": "65e6901caeee1200f07caeee", 12 | "profile_id": "EN 50549-1:2019 RfG E02 Netherlands:1.3.2", 13 | "profile_name": "EN 50549-1:2019 RfG E02 Netherlands", 14 | "profile_version": "1.3.2", 15 | "profile_description": "Profile for The Netherlands based on requirements drawn from EN 50549-1:2019 + Amendment 1 and Netbeheer E-02 - Netcode elektriciteit (2022-12-18) (the Dutch Grid Code).", 16 | "policy": { 17 | "min_base_template_version": "1.3.1", 18 | "min_master_model_version": "1.3.1", 19 | "min_envoy_version": "7.6", 20 | "max_envoy_version": "", 21 | "model_mismatch": false, 22 | "logical_device_mismatch": false, 23 | "attribute_mismatch": false, 24 | "id_mismatch": false 25 | }, 26 | "envoy_type": "europe", 27 | "countries": [ 28 | "NL" 29 | ], 30 | "states": [ 31 | 32 | ], 33 | "unpacked": true, 34 | "profile_source": [ 35 | "enlighten" 36 | ], 37 | "dynamic": true, 38 | "mm_version": "1.3.7", 39 | "bt_version": "1.3.7" 40 | } 41 | ], 42 | "profile_groups": { 43 | "NL": [ 44 | { 45 | "uuid": "65e6901caeee1200f07caeee", 46 | "profile_id": "EN 50549-1:2019 RfG E02 Netherlands:1.3.2", 47 | "profile_name": "EN 50549-1:2019 RfG E02 Netherlands", 48 | "profile_version": "1.3.2", 49 | "profile_description": "Profile for The Netherlands based on requirements drawn from EN 50549-1:2019 + Amendment 1 and Netbeheer E-02 - Netcode elektriciteit (2022-12-18) (the Dutch Grid Code).", 50 | "policy": { 51 | "min_base_template_version": "1.3.1", 52 | "min_master_model_version": "1.3.1", 53 | "min_envoy_version": "7.6", 54 | "max_envoy_version": "", 55 | "model_mismatch": false, 56 | "logical_device_mismatch": false, 57 | "attribute_mismatch": false, 58 | "id_mismatch": false 59 | }, 60 | "envoy_type": "europe", 61 | "countries": [ 62 | "NL" 63 | ], 64 | "states": [ 65 | 66 | ], 67 | "unpacked": true, 68 | "profile_source": [ 69 | "enlighten" 70 | ], 71 | "dynamic": true, 72 | "mm_version": "1.3.7", 73 | "bt_version": "1.3.7" 74 | } 75 | ] 76 | } 77 | } -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_production_inverters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "serialNumber": "999999913010", 4 | "lastReportDate": 1689258553, 5 | "devType": 1, 6 | "lastReportWatts": 252, 7 | "maxReportWatts": 297 8 | }, 9 | { 10 | "serialNumber": "999999913012", 11 | "lastReportDate": 1689258403, 12 | "devType": 1, 13 | "lastReportWatts": 230, 14 | "maxReportWatts": 297 15 | }, 16 | { 17 | "serialNumber": "999999912750", 18 | "lastReportDate": 1689258555, 19 | "devType": 1, 20 | "lastReportWatts": 223, 21 | "maxReportWatts": 297 22 | }, 23 | { 24 | "serialNumber": "999999912983", 25 | "lastReportDate": 1689258584, 26 | "devType": 1, 27 | "lastReportWatts": 254, 28 | "maxReportWatts": 297 29 | }, 30 | { 31 | "serialNumber": "999999908520", 32 | "lastReportDate": 1689258525, 33 | "devType": 1, 34 | "lastReportWatts": 289, 35 | "maxReportWatts": 297 36 | }, 37 | { 38 | "serialNumber": "999999909983", 39 | "lastReportDate": 1689258644, 40 | "devType": 1, 41 | "lastReportWatts": 276, 42 | "maxReportWatts": 297 43 | }, 44 | { 45 | "serialNumber": "999999908521", 46 | "lastReportDate": 1689258526, 47 | "devType": 1, 48 | "lastReportWatts": 286, 49 | "maxReportWatts": 297 50 | }, 51 | { 52 | "serialNumber": "999999912669", 53 | "lastReportDate": 1689258618, 54 | "devType": 1, 55 | "lastReportWatts": 291, 56 | "maxReportWatts": 297 57 | }, 58 | { 59 | "serialNumber": "999999913748", 60 | "lastReportDate": 1689258586, 61 | "devType": 1, 62 | "lastReportWatts": 287, 63 | "maxReportWatts": 297 64 | }, 65 | { 66 | "serialNumber": "999999909985", 67 | "lastReportDate": 1689258619, 68 | "devType": 1, 69 | "lastReportWatts": 283, 70 | "maxReportWatts": 297 71 | }, 72 | { 73 | "serialNumber": "999999915285", 74 | "lastReportDate": 1689258345, 75 | "devType": 1, 76 | "lastReportWatts": 245, 77 | "maxReportWatts": 297 78 | }, 79 | { 80 | "serialNumber": "999999915246", 81 | "lastReportDate": 1689258405, 82 | "devType": 1, 83 | "lastReportWatts": 259, 84 | "maxReportWatts": 297 85 | }, 86 | { 87 | "serialNumber": "999999912590", 88 | "lastReportDate": 1689258344, 89 | "devType": 1, 90 | "lastReportWatts": 100, 91 | "maxReportWatts": 297 92 | }, 93 | { 94 | "serialNumber": "999999910862", 95 | "lastReportDate": 1689258620, 96 | "devType": 1, 97 | "lastReportWatts": 46, 98 | "maxReportWatts": 297 99 | } 100 | ] 101 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/number.py: -------------------------------------------------------------------------------- 1 | from homeassistant.core import HomeAssistant 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 4 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 5 | from homeassistant.components.number import NumberEntity 6 | from homeassistant.helpers.entity import DeviceInfo 7 | 8 | from .const import ( 9 | COORDINATOR, 10 | DOMAIN, 11 | NAME, 12 | READER, 13 | STORAGE_RESERVE_SOC_NUMBER, 14 | ) 15 | 16 | 17 | async def async_setup_entry( 18 | hass: HomeAssistant, 19 | config_entry: ConfigEntry, 20 | async_add_entities: AddEntitiesCallback, 21 | ) -> None: 22 | data = hass.data[DOMAIN][config_entry.entry_id] 23 | coordinator = data[COORDINATOR] 24 | name = data[NAME] 25 | reader = data[READER] 26 | 27 | entities = [] 28 | if ( 29 | coordinator.data.get("batteries") 30 | and coordinator.data.get("storage_charge_from_grid") is not None 31 | ): 32 | entity_name = f"{name} {STORAGE_RESERVE_SOC_NUMBER.name}" 33 | entities.append( 34 | EnvoyStorageReservedSocEntity( 35 | STORAGE_RESERVE_SOC_NUMBER, 36 | entity_name, 37 | name, 38 | config_entry.unique_id, 39 | None, 40 | coordinator, 41 | reader, 42 | ) 43 | ) 44 | async_add_entities(entities) 45 | 46 | 47 | class EnvoyNumberEntity(CoordinatorEntity, NumberEntity): 48 | def __init__( 49 | self, 50 | description, 51 | name, 52 | device_name, 53 | device_serial_number, 54 | serial_number, 55 | coordinator, 56 | reader, 57 | ): 58 | self.entity_description = description 59 | self._name = name 60 | self._serial_number = serial_number 61 | self._device_name = device_name 62 | self._device_serial_number = device_serial_number 63 | CoordinatorEntity.__init__(self, coordinator) 64 | self._is_on = False 65 | self.reader = reader 66 | 67 | @property 68 | def name(self): 69 | """Return the name of the sensor.""" 70 | return self._name 71 | 72 | @property 73 | def unique_id(self): 74 | """Return the unique id of the sensor.""" 75 | if self._serial_number: 76 | return self._serial_number 77 | if self._device_serial_number: 78 | return f"{self._device_serial_number}_{self.entity_description.key}" 79 | 80 | @property 81 | def device_info(self) -> DeviceInfo or None: 82 | """Return the device_info of the device.""" 83 | if not self._device_serial_number: 84 | return None 85 | 86 | model = self.coordinator.data.get("envoy_info", {}).get("model", "Standard") 87 | 88 | return DeviceInfo( 89 | identifiers={(DOMAIN, str(self._device_serial_number))}, 90 | manufacturer="Enphase", 91 | model=f"Envoy-S {model}", 92 | name=self._device_name, 93 | ) 94 | 95 | 96 | class EnvoyStorageReservedSocEntity(EnvoyNumberEntity): 97 | @property 98 | def native_value(self) -> float: 99 | """Return the status of the requested attribute.""" 100 | return int(self.coordinator.data.get("storage_reserved_soc")) 101 | 102 | async def async_set_native_value(self, value: float) -> None: 103 | """Update the current value.""" 104 | await self.reader.set_storage("reserved_soc", value) 105 | await self.coordinator.async_request_refresh() 106 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/select.py: -------------------------------------------------------------------------------- 1 | from homeassistant.core import HomeAssistant 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 4 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 5 | from homeassistant.components.select import SelectEntity 6 | from homeassistant.helpers.entity import DeviceInfo 7 | 8 | from .const import COORDINATOR, DOMAIN, NAME, READER, STORAGE_MODES, STORAGE_MODE_SELECT 9 | 10 | 11 | async def async_setup_entry( 12 | hass: HomeAssistant, 13 | config_entry: ConfigEntry, 14 | async_add_entities: AddEntitiesCallback, 15 | ) -> None: 16 | data = hass.data[DOMAIN][config_entry.entry_id] 17 | coordinator = data[COORDINATOR] 18 | name = data[NAME] 19 | reader = data[READER] 20 | 21 | entities = [] 22 | if ( 23 | coordinator.data.get("batteries") 24 | and coordinator.data.get("storage_mode") is not None 25 | ): 26 | entity_name = f"{name} {STORAGE_MODE_SELECT.name}" 27 | entities.append( 28 | EnvoyStorageModeSelectEntity( 29 | STORAGE_MODE_SELECT, 30 | entity_name, 31 | name, 32 | config_entry.unique_id, 33 | None, 34 | coordinator, 35 | reader, 36 | ) 37 | ) 38 | async_add_entities(entities) 39 | 40 | 41 | class EnvoySelectEntity(CoordinatorEntity, SelectEntity): 42 | def __init__( 43 | self, 44 | description, 45 | name, 46 | device_name, 47 | device_serial_number, 48 | serial_number, 49 | coordinator, 50 | reader, 51 | ): 52 | self.entity_description = description 53 | self._name = name 54 | self._serial_number = serial_number 55 | self._device_name = device_name 56 | self._device_serial_number = device_serial_number 57 | CoordinatorEntity.__init__(self, coordinator) 58 | self._is_on = False 59 | self.reader = reader 60 | 61 | @property 62 | def name(self): 63 | """Return the name of the sensor.""" 64 | return self._name 65 | 66 | @property 67 | def unique_id(self): 68 | """Return the unique id of the sensor.""" 69 | if self._serial_number: 70 | return self._serial_number 71 | if self._device_serial_number: 72 | return f"{self._device_serial_number}_{self.entity_description.key}" 73 | 74 | @property 75 | def device_info(self) -> DeviceInfo or None: 76 | """Return the device_info of the device.""" 77 | if not self._device_serial_number: 78 | return None 79 | 80 | model = self.coordinator.data.get("envoy_info", {}).get("model", "Standard") 81 | 82 | return DeviceInfo( 83 | identifiers={(DOMAIN, str(self._device_serial_number))}, 84 | manufacturer="Enphase", 85 | model=f"Envoy-S {model}", 86 | name=self._device_name, 87 | ) 88 | 89 | 90 | class EnvoyStorageModeSelectEntity(EnvoySelectEntity): 91 | @property 92 | def current_option(self) -> str: 93 | """Return the status of the requested attribute.""" 94 | return self.coordinator.data.get("storage_mode") 95 | 96 | @property 97 | def options(self) -> list: 98 | return STORAGE_MODES 99 | 100 | async def async_select_option(self, option: str) -> None: 101 | """Change the selected option.""" 102 | await self.reader.set_storage("mode", option) 103 | await self.coordinator.async_request_refresh() 104 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Apparaat is al geconfigureerd", 5 | "reauth_successful": "Herauthenticatie was succesvol" 6 | }, 7 | "error": { 8 | "cannot_connect": "Kan geen verbinding maken met Envoy. Controleer host en serienummer.", 9 | "invalid_auth": "Inloggen mislukt. Controleer Enlighten gebruikersnaam/wachtwoord.", 10 | "unknown": "Onverwachte fout" 11 | }, 12 | "flow_title": "{serial} ({host})", 13 | "step": { 14 | "user": { 15 | "data": { 16 | "host": "Host", 17 | "password": "Enlighten Wachtwoord", 18 | "username": "Enlighten Gebruikersnaam", 19 | "serial": "Envoy Serienummer" 20 | }, 21 | "description": "Voer de hostname/ip en serienummer van je Envoy in. Gebruik je Enlighten (installer) account gegevens." 22 | } 23 | } 24 | }, 25 | "options": { 26 | "step": { 27 | "user": { 28 | "title": "Envoy opties", 29 | "data": { 30 | "enable_realtime_updates": "[Envoy-S Metered] Gebruik real-time updates", 31 | "realtime_update_throttle": "Minimale tijd tussen real-time updates [s]", 32 | "disable_negative_production": "[Envoy-S Metered] Voorkom negatieve productie waardes", 33 | "time_between_update": "Minimum tijd tussen entity updates [s]", 34 | "getdata_timeout": "Maximum tijd voor het ophalen van data vanaf envoy [s]", 35 | "enable_additional_metrics": "[Envoy-S Metered] Extra metrics inschakelen, zoals total amps, frequency, apparent en reactive power en power factor.", 36 | "disable_installer_account_use": "Haal geen data op die een installateur of DHZ enphase account vereisen", 37 | "enable_pcu_comm_check": "Powerline communication level sensors inschakelen (langzaam)", 38 | "devstatus_device_data": "Gebruik alternatief endpoint 'devstatus' (alleen installer account) voor apparaat sensoren", 39 | "lifetime_production_correction": "Correctie van lifetime production waarde (Wh)", 40 | "disabled_endpoints": "[Geavanceerd] Uitgeschakelde Envoy endpoints" 41 | }, 42 | "data_description": { 43 | "realtime_update_throttle": "Dit interval is van toepassing op real-time updates (om eventuele overload met updates te voorkomen)", 44 | "time_between_update": "Dit interval is alleen van toepassing voor het pollen van URLs" 45 | } 46 | } 47 | } 48 | }, 49 | "services": { 50 | "set_grid_profile": { 51 | "name": "Stel netwerkprofiel in", 52 | "description": "Stel het netwerkprofiel in dat de Envoy toepast op de omvormers.", 53 | "fields": { 54 | "profile": { 55 | "name": "Profiel", 56 | "description": "Netwerkprofiel ID" 57 | } 58 | } 59 | }, 60 | "get_grid_profiles": { 61 | "name": "Haal netwerkprofielen op", 62 | "description": "Haal huidige geselecteerde en alle beschikbare netwerkprofielen op." 63 | } 64 | }, 65 | "enable_dpel": { 66 | "name": "DPEL Inschakelen ", 67 | "description": "DPEL (Dynamic Power Export Limit) Inschakelen", 68 | "fields": { 69 | "watt": { 70 | "name": "Watt", 71 | "description": "Vermogen export limiet in watt" 72 | }, 73 | "slew_rate": { 74 | "name": "Slew rate", 75 | "description": "Vermogen export limiet slew rate" 76 | }, 77 | "export_limit": { 78 | "name": "Export Limiet", 79 | "description": "Stel een Export Limiet in bij True (standaard) of een Productie Limiet bij False" 80 | } 81 | } 82 | }, 83 | "disable_dpel": { 84 | "name": "DPEL Uitschakelen", 85 | "description": "DPEL (Dynamic Power Export Limit) Uitschakelen" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "reauth_successful": "Re-authentication was successful" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect to Envoy. Please check host and serial.", 9 | "invalid_auth": "Cannot login. Please check Enlighten username/password.", 10 | "unknown": "Unexpected error" 11 | }, 12 | "flow_title": "{serial} ({host})", 13 | "step": { 14 | "user": { 15 | "data": { 16 | "host": "Host", 17 | "password": "Enlighten Password", 18 | "username": "Enlighten Username", 19 | "serial": "Envoy Serial Number" 20 | }, 21 | "description": "Enter the hostname/ip and serial of your Envoy. Use your Enlighten (installer) account credentials." 22 | } 23 | } 24 | }, 25 | "options": { 26 | "step": { 27 | "user": { 28 | "title": "Envoy options", 29 | "data": { 30 | "enable_realtime_updates": "[Envoy-S Metered] Enable realtime updates", 31 | "realtime_update_throttle": "Minimum time between realtime entity updates [s]", 32 | "disable_negative_production": "[Envoy-S Metered] Disable negative production values", 33 | "time_between_update": "Minimum time between entity updates [s]", 34 | "getdata_timeout": "Timeout value for fetching data from envoy [s]", 35 | "enable_additional_metrics": "[Envoy-S Metered] Enable additional metrics like total amps, frequency, apparent and reactive power and power factor.", 36 | "disable_installer_account_use": "Do not collect data that requires installer or DIY enphase account", 37 | "enable_pcu_comm_check": "Enable powerline communication level sensors (slow)", 38 | "devstatus_device_data": "Use alternative endpoint 'devstatus' (installer account only) for device sensors", 39 | "lifetime_production_correction": "Correction of lifetime production value (Wh)", 40 | "disabled_endpoints": "[Advanced] Disabled Envoy endpoints" 41 | }, 42 | "data_description": { 43 | "realtime_update_throttle": "Only applies to realtime updates (to preventing any overload on the system)", 44 | "time_between_update": "This interval only applies to the polling interval (not on the live updates)" 45 | } 46 | } 47 | } 48 | }, 49 | "services": { 50 | "set_grid_profile": { 51 | "name": "Set grid profile", 52 | "description": "Sets the grid profile the Envoy will upload to the inverters.", 53 | "fields": { 54 | "profile": { 55 | "name": "Profile", 56 | "description": "Grid profile ID" 57 | } 58 | } 59 | }, 60 | "get_grid_profiles": { 61 | "name": "Get grid profiles", 62 | "description": "Get currently selected and all available profiles." 63 | }, 64 | "upload_grid_profile": { 65 | "name": "Upload grid profile", 66 | "description": "Upload a grid profile package file to the Envoy so that it is available for selecting.", 67 | "fields": { 68 | "file": { 69 | "name": "File", 70 | "description": "Path to the grid profile package file" 71 | } 72 | } 73 | }, 74 | "enable_dpel": { 75 | "name": "Enable DPEL", 76 | "description": "Enable DPEL (Dynamic Power Export Limit)", 77 | "fields": { 78 | "watt": { 79 | "name": "Watt", 80 | "description": "Power export limit in watt" 81 | }, 82 | "slew_rate": { 83 | "name": "Slew rate", 84 | "description": "Power export limit slew rate" 85 | }, 86 | "export_limit": { 87 | "name": "Export Limit", 88 | "description": "Create an Export Limit by setting True (Default) or a Production Limit by setting False" 89 | } 90 | } 91 | }, 92 | "disable_dpel": { 93 | "name": "Disable DPEL", 94 | "description": "Disable DPEL (Dynamic Power Export Limit)" 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_admin_lib_tariff.json: -------------------------------------------------------------------------------- 1 | { 2 | "tariff": { 3 | "currency": { 4 | "code": "EUR" 5 | }, 6 | "logger": "mylogger", 7 | "date": "1683540858", 8 | "storage_settings": { 9 | "mode": "self-consumption", 10 | "operation_mode_sub_type": "", 11 | "reserved_soc": 0, 12 | "very_low_soc": 5, 13 | "charge_from_grid": false, 14 | "date": "1683540858" 15 | }, 16 | "single_rate": { 17 | "rate": 0.3, 18 | "sell": 0 19 | }, 20 | "seasons": [], 21 | "seasons_sell": [] 22 | }, 23 | "schedule": { 24 | "source": "Tariff", 25 | "date": "2024-05-08 14:25:16 UTC", 26 | "version": "00.00.02", 27 | "reserved_soc": 0, 28 | "operation_mode_sub_type": "", 29 | "very_low_soc": 5, 30 | "charge_from_grid": false, 31 | "battery_mode": "self-consumption", 32 | "schedule": { 33 | "Disable": [ 34 | { 35 | "Sun": [ 36 | { 37 | "start": 0, 38 | "duration": 1440, 39 | "setting": "ID" 40 | } 41 | ] 42 | }, 43 | { 44 | "Mon": [ 45 | { 46 | "start": 0, 47 | "duration": 1440, 48 | "setting": "ID" 49 | } 50 | ] 51 | }, 52 | { 53 | "Tue": [ 54 | { 55 | "start": 0, 56 | "duration": 1440, 57 | "setting": "ID" 58 | } 59 | ] 60 | }, 61 | { 62 | "Wed": [ 63 | { 64 | "start": 0, 65 | "duration": 1440, 66 | "setting": "ID" 67 | } 68 | ] 69 | }, 70 | { 71 | "Thu": [ 72 | { 73 | "start": 0, 74 | "duration": 1440, 75 | "setting": "ID" 76 | } 77 | ] 78 | }, 79 | { 80 | "Fri": [ 81 | { 82 | "start": 0, 83 | "duration": 1440, 84 | "setting": "ID" 85 | } 86 | ] 87 | }, 88 | { 89 | "Sat": [ 90 | { 91 | "start": 0, 92 | "duration": 1440, 93 | "setting": "ID" 94 | } 95 | ] 96 | } 97 | ], 98 | "tariff": [ 99 | { 100 | "start": "1/1", 101 | "end": "1/1", 102 | "Sun": [ 103 | { 104 | "start": 0, 105 | "duration": 1440, 106 | "setting": "ZN" 107 | } 108 | ], 109 | "Mon": [ 110 | { 111 | "start": 0, 112 | "duration": 1440, 113 | "setting": "ZN" 114 | } 115 | ], 116 | "Tue": [ 117 | { 118 | "start": 0, 119 | "duration": 1440, 120 | "setting": "ZN" 121 | } 122 | ], 123 | "Wed": [ 124 | { 125 | "start": 0, 126 | "duration": 1440, 127 | "setting": "ZN" 128 | } 129 | ], 130 | "Thu": [ 131 | { 132 | "start": 0, 133 | "duration": 1440, 134 | "setting": "ZN" 135 | } 136 | ], 137 | "Fri": [ 138 | { 139 | "start": 0, 140 | "duration": 1440, 141 | "setting": "ZN" 142 | } 143 | ], 144 | "Sat": [ 145 | { 146 | "start": 0, 147 | "duration": 1440, 148 | "setting": "ZN" 149 | } 150 | ] 151 | } 152 | ] 153 | }, 154 | "override": false, 155 | "override_backup_soc": 30, 156 | "override_chg_dischg_rate": 0, 157 | "override_tou_mode": "StorageTouMode_DEFAULT_TOU_MODE" 158 | } 159 | } -------------------------------------------------------------------------------- /custom_components/enphase_envoy/envoy_endpoints.py: -------------------------------------------------------------------------------- 1 | ENVOY_ENDPOINTS = { 2 | # Generic endpoints 3 | "info": { 4 | "url": "https://{}/info.xml", 5 | "cache": 3600, 6 | "installer_required": False, 7 | "optional": False, 8 | }, 9 | "peb_newscan": { 10 | "url": "https://{}/ivp/peb/newscan", 11 | "cache": 3600, 12 | "installer_required": True, 13 | "optional": True, 14 | }, 15 | "dpel": { 16 | "url": "https://{}/ivp/ss/dpel", 17 | "cache": 0, 18 | "installer_required": True, 19 | "optional": True, 20 | }, 21 | # Production/consumption endpoints 22 | "production_json": { 23 | "url": "https://{}/production.json?details=1", 24 | "cache": 0, 25 | "installer_required": False, 26 | "optional": False, 27 | }, 28 | "production_v1": { 29 | "url": "https://{}/api/v1/production", 30 | "cache": 0, 31 | "installer_required": False, 32 | "optional": False, 33 | }, 34 | "production_inverters": { 35 | "url": "https://{}/api/v1/production/inverters", 36 | "cache": 0, 37 | "installer_required": False, 38 | "optional": False, 39 | }, 40 | "production_report": { 41 | "url": "https://{}/ivp/meters/reports/production", 42 | "cache": 0, 43 | "installer_required": False, 44 | "optional": False, 45 | }, 46 | "production_power": { 47 | "url": "https://{}/ivp/mod/603980032/mode/power", 48 | "cache": 0, 49 | "installer_required": True, 50 | "optional": True, 51 | }, 52 | "pdm_energy": { 53 | "url": "https://{}/ivp/pdm/energy", 54 | "cache": 0, 55 | "installer_required": True, 56 | "optional": False, 57 | }, 58 | # Battery endpoints 59 | "ensemble_inventory": { 60 | "url": "https://{}/ivp/ensemble/inventory", 61 | "cache": 0, 62 | "installer_required": False, 63 | "optional": True, 64 | }, 65 | "ensemble_secctrl": { 66 | "url": "https://{}/ivp/ensemble/secctrl", 67 | "cache": 0, 68 | "installer_required": False, 69 | "optional": True, 70 | }, 71 | "ensemble_power": { 72 | "url": "https://{}/ivp/ensemble/power", 73 | "cache": 0, 74 | "installer_required": False, 75 | "optional": True, 76 | }, 77 | # Inverter endpoints 78 | "inventory": { 79 | "url": "https://{}/inventory.json", 80 | "cache": 0, 81 | "installer_required": False, 82 | "optional": False, 83 | }, 84 | "device_data": { 85 | "url": "https://{}/ivp/pdm/device_data", 86 | "cache": 0, 87 | "installer_required": False, 88 | "optional": True, 89 | }, 90 | "devstatus": { 91 | "url": "https://{}/ivp/peb/devstatus", 92 | "cache": 0, 93 | "installer_required": True, 94 | "optional": True, 95 | }, 96 | "pcu_comm_check": { 97 | "url": "https://{}/installer/pcu_comm_check", 98 | "cache": 3600, 99 | "installer_required": True, 100 | "optional": True, 101 | }, 102 | "meters": { 103 | "url": "https://{}/ivp/meters", 104 | "cache": 0, 105 | "installer_required": False, 106 | "optional": True, 107 | }, 108 | "meters_readings": { 109 | "url": "https://{}/ivp/meters/readings", 110 | "cache": 0, 111 | "installer_required": False, 112 | "optional": True, 113 | }, 114 | # Netprofile endpoints 115 | "installer_agf": { 116 | "url": "https://{}/installer/agf/index.json", 117 | "cache": 3600, 118 | "installer_required": True, 119 | "optional": True, 120 | }, 121 | # Tariff endpoints 122 | "admin_tariff": { 123 | "url": "https://{}/admin/lib/tariff", 124 | "cache": 300, 125 | "installer_required": False, 126 | "optional": True, 127 | }, 128 | } 129 | 130 | ENDPOINT_URL_STREAM = "https://{}/stream/meter" 131 | ENDPOINT_URL_INSTALLER_AGF_SET_PROFILE = "https://{}/installer/agf/set_profile.json" 132 | ENDPOINT_URL_INSTALLER_AGF_UPLOAD_PROFILE = ( 133 | "https://{}/installer/agf/upload_profile_package" 134 | ) 135 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/envoy_test_data.py: -------------------------------------------------------------------------------- 1 | TEST_DATA = "/config/custom_components/enphase_envoy/test_data/envoy_metered/" 2 | ENVOY_ENDPOINTS = { 3 | # Generic endpoints 4 | "info": { 5 | "url": TEST_DATA + "endpoint_info.xml", 6 | "cache": 20, 7 | "installer_required": False, 8 | "optional": False, 9 | }, 10 | "peb_newscan": { 11 | "url": TEST_DATA + "endpoint_peb_newscan.json", 12 | "cache": 3600, 13 | "installer_required": True, 14 | "optional": True, 15 | }, 16 | "dpel": { 17 | "url": TEST_DATA + "endpoint_dpel.json", 18 | "cache": 0, 19 | "installer_required": True, 20 | "optional": True, 21 | }, 22 | # Production/consumption endpoints 23 | "production_json": { 24 | "url": TEST_DATA + "endpoint_production_json.json", 25 | "cache": 0, 26 | "installer_required": False, 27 | "optional": False, 28 | }, 29 | "production_v1": { 30 | "url": TEST_DATA + "endpoint_production_v1.json", 31 | "cache": 20, 32 | "installer_required": False, 33 | "optional": False, 34 | }, 35 | "production_inverters": { 36 | "url": TEST_DATA + "endpoint_production_inverters.json", 37 | "cache": 20, 38 | "installer_required": False, 39 | "optional": False, 40 | }, 41 | "production_report": { 42 | "url": TEST_DATA + "endpoint_production_report.json", 43 | "cache": 0, 44 | "installer_required": False, 45 | "optional": False, 46 | }, 47 | "production_power": { 48 | "url": TEST_DATA + "endpoint_production_power.json", 49 | "cache": 20, 50 | "installer_required": False, 51 | "optional": True, 52 | }, 53 | "pdm_energy": { 54 | "url": TEST_DATA + "endpoint_pdm_energy.json", 55 | "cache": 20, 56 | "installer_required": True, 57 | "optional": False, 58 | }, 59 | # Battery endpoints 60 | "ensemble_inventory": { 61 | "url": TEST_DATA + "endpoint_ensemble_inventory.json", 62 | "cache": 20, 63 | "installer_required": False, 64 | "optional": True, 65 | }, 66 | "ensemble_secctrl": { 67 | "url": TEST_DATA + "endpoint_ensemble_secctrl.json", 68 | "cache": 20, 69 | "installer_required": False, 70 | "optional": True, 71 | }, 72 | "ensemble_power": { 73 | "url": TEST_DATA + "endpoint_ensemble_power.json", 74 | "cache": 20, 75 | "installer_required": False, 76 | "optional": True, 77 | }, 78 | # Inverter endpoints 79 | "inventory": { 80 | "url": TEST_DATA + "endpoint_inventory.json", 81 | "cache": 300, 82 | "installer_required": False, 83 | "optional": False, 84 | }, 85 | "device_data": { 86 | "url": TEST_DATA + "endpoint_device_data.json", 87 | "cache": 0, 88 | "installer_required": False, 89 | "optional": True, 90 | }, 91 | "devstatus": { 92 | "url": TEST_DATA + "endpoint_devstatus.json", 93 | "cache": 20, 94 | "installer_required": True, 95 | "optional": False, 96 | }, 97 | "pcu_comm_check": { 98 | "url": TEST_DATA + "endpoint_pcu_comm_check.json", 99 | "cache": 90, 100 | "installer_required": True, 101 | "optional": True, 102 | }, 103 | "meters": { 104 | "url": TEST_DATA + "endpoint_meters.json", 105 | "cache": 0, 106 | "installer_required": False, 107 | "optional": True, 108 | }, 109 | "meters_readings": { 110 | "url": TEST_DATA + "endpoint_meters_readings.json", 111 | "cache": 0, 112 | "installer_required": False, 113 | "optional": True, 114 | }, 115 | # Netprofile endpoints 116 | "installer_agf": { 117 | "url": TEST_DATA + "endpoint_installer_agf_index_json.json", 118 | "cache": 10, 119 | "installer_required": True, 120 | "optional": True, 121 | }, 122 | # Tariff endpoints 123 | "admin_tariff": { 124 | "url": TEST_DATA + "endpoint_admin_lib_tariff.json", 125 | "cache": 10, 126 | "installer_required": False, 127 | "optional": True, 128 | }, 129 | } 130 | 131 | ENDPOINT_URL_STREAM = None 132 | ENDPOINT_URL_INSTALLER_AGF_SET_PROFILE = None 133 | ENDPOINT_URL_INSTALLER_AGF_UPLOAD_PROFILE = None 134 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/switch.py: -------------------------------------------------------------------------------- 1 | from homeassistant.core import HomeAssistant 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 4 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 5 | from homeassistant.components.switch import SwitchEntity 6 | from homeassistant.helpers.entity import DeviceInfo 7 | 8 | from .const import ( 9 | COORDINATOR, 10 | DOMAIN, 11 | NAME, 12 | READER, 13 | SWITCHES, 14 | ) 15 | 16 | 17 | async def async_setup_entry( 18 | hass: HomeAssistant, 19 | config_entry: ConfigEntry, 20 | async_add_entities: AddEntitiesCallback, 21 | ) -> None: 22 | data = hass.data[DOMAIN][config_entry.entry_id] 23 | coordinator = data[COORDINATOR] 24 | name = data[NAME] 25 | reader = data[READER] 26 | 27 | entities = [] 28 | for switch_description in SWITCHES: 29 | if switch_description.key.startswith("storage_"): 30 | if ( 31 | coordinator.data.get("batteries") 32 | and coordinator.data.get(switch_description.key) is not None 33 | ): 34 | entity_name = f"{name} {switch_description.name}" 35 | entities.append( 36 | EnvoyStorageSwitchEntity( 37 | switch_description, 38 | entity_name, 39 | name, 40 | config_entry.unique_id, 41 | None, 42 | coordinator, 43 | reader, 44 | ) 45 | ) 46 | else: 47 | if coordinator.data.get(switch_description.key) is not None: 48 | entity_name = f"{name} {switch_description.name}" 49 | entities.append( 50 | EnvoySwitchEntity( 51 | switch_description, 52 | entity_name, 53 | name, 54 | config_entry.unique_id, 55 | None, 56 | coordinator, 57 | reader, 58 | ) 59 | ) 60 | async_add_entities(entities) 61 | 62 | 63 | class EnvoySwitchEntity(CoordinatorEntity, SwitchEntity): 64 | def __init__( 65 | self, 66 | description, 67 | name, 68 | device_name, 69 | device_serial_number, 70 | serial_number, 71 | coordinator, 72 | reader, 73 | ): 74 | self.entity_description = description 75 | self._name = name 76 | self._serial_number = serial_number 77 | self._device_name = device_name 78 | self._device_serial_number = device_serial_number 79 | CoordinatorEntity.__init__(self, coordinator) 80 | self._is_on = False 81 | self.reader = reader 82 | 83 | @property 84 | def name(self): 85 | """Return the name of the sensor.""" 86 | return self._name 87 | 88 | @property 89 | def unique_id(self): 90 | """Return the unique id of the sensor.""" 91 | if self._serial_number: 92 | return self._serial_number 93 | if self._device_serial_number: 94 | return f"{self._device_serial_number}_{self.entity_description.key}" 95 | 96 | @property 97 | def device_info(self) -> DeviceInfo or None: 98 | """Return the device_info of the device.""" 99 | if not self._device_serial_number: 100 | return None 101 | 102 | model = self.coordinator.data.get("envoy_info", {}).get("model", "Standard") 103 | 104 | return DeviceInfo( 105 | identifiers={(DOMAIN, str(self._device_serial_number))}, 106 | manufacturer="Enphase", 107 | model=f"Envoy-S {model}", 108 | name=self._device_name, 109 | ) 110 | 111 | @property 112 | def is_on(self) -> bool: 113 | """Return the status of the requested attribute.""" 114 | return self.coordinator.data.get(self.entity_description.key) 115 | 116 | async def async_turn_on(self, **kwargs): 117 | """Turn the entity on.""" 118 | set_func = getattr(self.reader, f"set_{self.entity_description.key}") 119 | await set_func(True) 120 | await self.coordinator.async_request_refresh() 121 | 122 | async def async_turn_off(self, **kwargs): 123 | """Turn the entity off.""" 124 | set_func = getattr(self.reader, f"set_{self.entity_description.key}") 125 | await set_func(False) 126 | await self.coordinator.async_request_refresh() 127 | 128 | 129 | class EnvoyStorageSwitchEntity(EnvoySwitchEntity): 130 | async def async_turn_on(self, **kwargs): 131 | """Turn the entity on.""" 132 | await self.reader.set_storage(self.entity_description.key[8:], True) 133 | await self.coordinator.async_request_refresh() 134 | 135 | async def async_turn_off(self, **kwargs): 136 | """Turn the entity off.""" 137 | await self.reader.set_storage(self.entity_description.key[8:], False) 138 | await self.coordinator.async_request_refresh() 139 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_production_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "production": [ 3 | { 4 | "type": "inverters", 5 | "activeCount": 14, 6 | "readingTime": 1689258464, 7 | "wNow": 2642, 8 | "whLifetime": 836653 9 | }, 10 | { 11 | "type": "eim", 12 | "activeCount": 1, 13 | "measurementType": "production", 14 | "readingTime": 1689258508, 15 | "wNow": 3441.236, 16 | "whLifetime": 1820822.12, 17 | "varhLeadLifetime": 0.042, 18 | "varhLagLifetime": 374039.343, 19 | "vahLifetime": 2007531.058, 20 | "rmsCurrent": 14.012, 21 | "rmsVoltage": 248.393, 22 | "reactPwr": 282.206, 23 | "apprntPwr": 3481.322, 24 | "pwrFactor": 0.99, 25 | "whToday": 21430.12, 26 | "whLastSevenDays": 199376.12, 27 | "vahToday": 23359.058, 28 | "varhLeadToday": 0.042, 29 | "varhLagToday": 4001.343, 30 | "lines": [ 31 | { 32 | "wNow": 3441.236, 33 | "whLifetime": 1820822.12, 34 | "varhLeadLifetime": 0.042, 35 | "varhLagLifetime": 374039.343, 36 | "vahLifetime": 2007531.058, 37 | "rmsCurrent": 14.012, 38 | "rmsVoltage": 248.393, 39 | "reactPwr": 282.206, 40 | "apprntPwr": 3481.322, 41 | "pwrFactor": 0.99, 42 | "whToday": 21430.12, 43 | "whLastSevenDays": 199376.12, 44 | "vahToday": 23359.058, 45 | "varhLeadToday": 0.042, 46 | "varhLagToday": 4001.343 47 | } 48 | ] 49 | } 50 | ], 51 | "consumption": [ 52 | { 53 | "type": "eim", 54 | "activeCount": 1, 55 | "measurementType": "total-consumption", 56 | "readingTime": 1689258508, 57 | "wNow": 3441.236, 58 | "whLifetime": 1820822.12, 59 | "varhLeadLifetime": -0.042, 60 | "varhLagLifetime": -374039.343, 61 | "vahLifetime": 0.0, 62 | "rmsCurrent": 14.012, 63 | "rmsVoltage": 248.339, 64 | "reactPwr": 282.206, 65 | "apprntPwr": 3479.79, 66 | "pwrFactor": 0.99, 67 | "whToday": 1820822.12, 68 | "whLastSevenDays": 1820822.12, 69 | "vahToday": 0.0, 70 | "varhLeadToday": 0.0, 71 | "varhLagToday": 0.0, 72 | "lines": [ 73 | { 74 | "wNow": 3441.236, 75 | "whLifetime": 1820822.12, 76 | "varhLeadLifetime": -0.042, 77 | "varhLagLifetime": -374039.343, 78 | "vahLifetime": 0.0, 79 | "rmsCurrent": 14.012, 80 | "rmsVoltage": 248.339, 81 | "reactPwr": 282.206, 82 | "apprntPwr": 3479.79, 83 | "pwrFactor": 0.99, 84 | "whToday": 1820822.12, 85 | "whLastSevenDays": 1820822.12, 86 | "vahToday": 0.0, 87 | "varhLeadToday": 0.0, 88 | "varhLagToday": 0.0 89 | } 90 | ] 91 | }, 92 | { 93 | "type": "eim", 94 | "activeCount": 0, 95 | "measurementType": "net-consumption", 96 | "readingTime": 1689258508, 97 | "wNow": 0.0, 98 | "whLifetime": 0.0, 99 | "varhLeadLifetime": 0.0, 100 | "varhLagLifetime": 0.0, 101 | "vahLifetime": 0.0, 102 | "rmsCurrent": 0.0, 103 | "rmsVoltage": 248.339, 104 | "reactPwr": -0.0, 105 | "apprntPwr": 0.0, 106 | "pwrFactor": 0.0, 107 | "whToday": 0, 108 | "whLastSevenDays": 0, 109 | "vahToday": 0, 110 | "varhLeadToday": 0, 111 | "varhLagToday": 0, 112 | "lines": [ 113 | { 114 | "wNow": 0.0, 115 | "whLifetime": 0.0, 116 | "varhLeadLifetime": 0.0, 117 | "varhLagLifetime": 0.0, 118 | "vahLifetime": 0.0, 119 | "rmsCurrent": 0.0, 120 | "rmsVoltage": 248.339, 121 | "reactPwr": -0.0, 122 | "apprntPwr": 0.0, 123 | "pwrFactor": 0.0, 124 | "whToday": 0, 125 | "whLastSevenDays": 0, 126 | "vahToday": 0, 127 | "varhLeadToday": 0, 128 | "varhLagToday": 0 129 | } 130 | ] 131 | } 132 | ], 133 | "storage": [ 134 | { 135 | "type": "acb", 136 | "activeCount": 0, 137 | "readingTime": 0, 138 | "wNow": 0, 139 | "whNow": 0, 140 | "state": "idle" 141 | } 142 | ] 143 | } 144 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_ensemble_inventory.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "ENCHARGE", 4 | "devices": [ 5 | { 6 | "part_num": "830-01760-r46", 7 | "installed": 1712942814, 8 | "serial_num": "999999995065", 9 | "device_status": [ 10 | "envoy.global.ok", 11 | "prop.done" 12 | ], 13 | "last_rpt_date": 1712944638, 14 | "admin_state": 6, 15 | "admin_state_str": "ENCHG_STATE_READY", 16 | "created_date": 1712942814, 17 | "img_load_date": 1712942814, 18 | "img_pnum_running": "2.6.5973_rel/22.11", 19 | "zigbee_dongle_fw_version": "100F", 20 | "bmu_fw_version": "2.1.34", 21 | "operating": true, 22 | "communicating": true, 23 | "sleep_enabled": false, 24 | "percentFull": 20, 25 | "temperature": 20, 26 | "maxCellTemp": 20, 27 | "comm_level_sub_ghz": 4, 28 | "comm_level_2_4_ghz": 4, 29 | "led_status": 17, 30 | "dc_switch_off": false, 31 | "encharge_rev": 2, 32 | "encharge_capacity": 3500 33 | }, 34 | { 35 | "part_num": "830-01760-r46", 36 | "installed": 1712942814, 37 | "serial_num": "999999995067", 38 | "device_status": [ 39 | "envoy.global.ok", 40 | "prop.done" 41 | ], 42 | "last_rpt_date": 1712944677, 43 | "admin_state": 6, 44 | "admin_state_str": "ENCHG_STATE_READY", 45 | "created_date": 1712942814, 46 | "img_load_date": 1712942814, 47 | "img_pnum_running": "2.6.5973_rel/22.11", 48 | "zigbee_dongle_fw_version": "100F", 49 | "bmu_fw_version": "2.1.34", 50 | "operating": true, 51 | "communicating": true, 52 | "sleep_enabled": false, 53 | "percentFull": 20, 54 | "temperature": 20, 55 | "maxCellTemp": 20, 56 | "comm_level_sub_ghz": 4, 57 | "comm_level_2_4_ghz": 4, 58 | "led_status": 17, 59 | "dc_switch_off": false, 60 | "encharge_rev": 2, 61 | "encharge_capacity": 3500 62 | }, 63 | { 64 | "part_num": "830-01760-r46", 65 | "installed": 1712942449, 66 | "serial_num": "999999995069", 67 | "device_status": [ 68 | "envoy.global.ok", 69 | "prop.done" 70 | ], 71 | "last_rpt_date": 1712944722, 72 | "admin_state": 6, 73 | "admin_state_str": "ENCHG_STATE_READY", 74 | "created_date": 1712942449, 75 | "img_load_date": 1712942449, 76 | "img_pnum_running": "2.6.5973_rel/22.11", 77 | "zigbee_dongle_fw_version": "100F", 78 | "bmu_fw_version": "2.1.34", 79 | "operating": true, 80 | "communicating": true, 81 | "sleep_enabled": false, 82 | "percentFull": 20, 83 | "temperature": 20, 84 | "maxCellTemp": 20, 85 | "comm_level_sub_ghz": 4, 86 | "comm_level_2_4_ghz": 4, 87 | "led_status": 17, 88 | "dc_switch_off": false, 89 | "encharge_rev": 2, 90 | "encharge_capacity": 3500 91 | } 92 | ] 93 | }, 94 | { 95 | "type":"ENPOWER", 96 | "devices":[ 97 | { 98 | "part_num":"860-00276-r28", 99 | "installed":1621354111, 100 | "serial_num":"xxx", 101 | "device_status":[ 102 | "envoy.global.ok", 103 | "prop.done" 104 | ], 105 | "last_rpt_date":1621464851, 106 | "admin_state":24, 107 | "admin_state_str":"ENPWR_STATE_OPER_CLOSED", 108 | "created_date":1621354111, 109 | "img_load_date":1621354111, 110 | "img_pnum_running":"1.2.2064_release/20.34", 111 | "zigbee_dongle_fw_version":"0x1009", 112 | "operating":true, 113 | "communicating":true, 114 | "temperature":79, 115 | "comm_level_sub_ghz":5, 116 | "comm_level_2_4_ghz":5, 117 | "mains_admin_state":"closed", 118 | "mains_oper_state":"closed", 119 | "Enpwr_grid_mode":"multimode-ongrid", 120 | "Enchg_grid_mode":"multimode-ongrid", 121 | "Enpwr_relay_state_bm":496, 122 | "Enpwr_curr_state_id":16 123 | } 124 | ] 125 | }, 126 | { 127 | "grid_profile_name":"IEEE 1547 default 2015", 128 | "id":"91937832-159a-410a-9594-0a964372e096:0", 129 | "grid_profile_version":"1.0.11", 130 | "item_count":2997 131 | } 132 | ] 133 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_meters_readings.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "eid": 704643328, 4 | "timestamp": 1739023480, 5 | "actEnergyDlvd": 994796.037, 6 | "actEnergyRcvd": 2433.555, 7 | "apparentEnergy": 1499967.970, 8 | "reactEnergyLagg": 534416.677, 9 | "reactEnergyLead": 30208.518, 10 | "instantaneousDemand": 449.492, 11 | "activePower": 449.492, 12 | "apparentPower": 677.284, 13 | "reactivePower": 342.466, 14 | "pwrFactor": 0.635, 15 | "voltage": 693.927, 16 | "current": 2.937, 17 | "freq": 50.000, 18 | "channels": [ 19 | { 20 | "eid": 1778385169, 21 | "timestamp": 1739023480, 22 | "actEnergyDlvd": 149015.515, 23 | "actEnergyRcvd": 203.690, 24 | "apparentEnergy": 192412.802, 25 | "reactEnergyLagg": 65799.026, 26 | "reactEnergyLead": 29894.120, 27 | "instantaneousDemand": 144.100, 28 | "activePower": 144.100, 29 | "apparentPower": 223.382, 30 | "reactivePower": 107.816, 31 | "pwrFactor": 0.565, 32 | "voltage": 229.506, 33 | "current": 0.986, 34 | "freq": 50.000 35 | }, 36 | { 37 | "eid": 1778385170, 38 | "timestamp": 1739023480, 39 | "actEnergyDlvd": 367796.653, 40 | "actEnergyRcvd": 143.057, 41 | "apparentEnergy": 573316.004, 42 | "reactEnergyLagg": 208897.520, 43 | "reactEnergyLead": 239.594, 44 | "instantaneousDemand": 142.204, 45 | "activePower": 142.204, 46 | "apparentPower": 216.909, 47 | "reactivePower": 107.226, 48 | "pwrFactor": 0.650, 49 | "voltage": 231.183, 50 | "current": 0.934, 51 | "freq": 50.000 52 | }, 53 | { 54 | "eid": 1778385171, 55 | "timestamp": 1739023480, 56 | "actEnergyDlvd": 477983.869, 57 | "actEnergyRcvd": 2086.807, 58 | "apparentEnergy": 734239.164, 59 | "reactEnergyLagg": 259720.130, 60 | "reactEnergyLead": 74.805, 61 | "instantaneousDemand": 163.189, 62 | "activePower": 163.189, 63 | "apparentPower": 236.992, 64 | "reactivePower": 127.424, 65 | "pwrFactor": 0.698, 66 | "voltage": 233.238, 67 | "current": 1.017, 68 | "freq": 50.000 69 | } 70 | ] 71 | }, 72 | { 73 | "eid": 704643584, 74 | "timestamp": 1739023480, 75 | "actEnergyDlvd": 906604.350, 76 | "actEnergyRcvd": 400646.164, 77 | "apparentEnergy": 3313198.502, 78 | "reactEnergyLagg": 775.530, 79 | "reactEnergyLead": 2517161.283, 80 | "instantaneousDemand": 94.563, 81 | "activePower": 94.563, 82 | "apparentPower": 1842.263, 83 | "reactivePower": -1650.094, 84 | "pwrFactor": 0.051, 85 | "voltage": 693.913, 86 | "current": 7.968, 87 | "freq": 50.000, 88 | "channels": [ 89 | { 90 | "eid": 1778385425, 91 | "timestamp": 1739023480, 92 | "actEnergyDlvd": 196871.066, 93 | "actEnergyRcvd": 139080.374, 94 | "apparentEnergy": 1061334.564, 95 | "reactEnergyLagg": 303.554, 96 | "reactEnergyLead": 860778.441, 97 | "instantaneousDemand": -74.371, 98 | "activePower": -74.371, 99 | "apparentPower": 592.752, 100 | "reactivePower": -569.215, 101 | "pwrFactor": -0.127, 102 | "voltage": 229.369, 103 | "current": 2.588, 104 | "freq": 50.000 105 | }, 106 | { 107 | "eid": 1778385426, 108 | "timestamp": 1739023480, 109 | "actEnergyDlvd": 559941.227, 110 | "actEnergyRcvd": 36746.894, 111 | "apparentEnergy": 1343518.876, 112 | "reactEnergyLagg": 145.286, 113 | "reactEnergyLead": 1015248.322, 114 | "instantaneousDemand": 315.471, 115 | "activePower": 315.471, 116 | "apparentPower": 782.800, 117 | "reactivePower": -650.452, 118 | "pwrFactor": 0.403, 119 | "voltage": 231.361, 120 | "current": 3.381, 121 | "freq": 50.000 122 | }, 123 | { 124 | "eid": 1778385427, 125 | "timestamp": 1739023480, 126 | "actEnergyDlvd": 149792.057, 127 | "actEnergyRcvd": 224818.896, 128 | "apparentEnergy": 908345.062, 129 | "reactEnergyLagg": 326.690, 130 | "reactEnergyLead": 641134.520, 131 | "instantaneousDemand": -146.536, 132 | "activePower": -146.536, 133 | "apparentPower": 466.711, 134 | "reactivePower": -430.428, 135 | "pwrFactor": -0.314, 136 | "voltage": 233.183, 137 | "current": 2.000, 138 | "freq": 50.000 139 | } 140 | ] 141 | }, 142 | { 143 | "eid": 704643840, 144 | "timestamp": 1722967007, 145 | "actEnergyDlvd": 4073871.031, 146 | "actEnergyRcvd": 5409935.465, 147 | "apparentEnergy": 14939666.293, 148 | "reactEnergyLagg": 6143996.517, 149 | "reactEnergyLead": 147616.472, 150 | "instantaneousDemand": -7083.656, 151 | "activePower": -7083.656, 152 | "apparentPower": 7210.614, 153 | "reactivePower": 49.385, 154 | "pwrFactor": -0.962, 155 | "voltage": 247.392, 156 | "current": 57.627, 157 | "freq": 60.0, 158 | "channels": [ 159 | { 160 | "eid": 1778385681, 161 | "timestamp": 1722967007, 162 | "actEnergyDlvd": 2036139.572, 163 | "actEnergyRcvd": 2703734.06, 164 | "apparentEnergy": 7470457.973, 165 | "reactEnergyLagg": 3072253.785, 166 | "reactEnergyLead": 73813.053, 167 | "instantaneousDemand": -3538.447, 168 | "activePower": -3538.447, 169 | "apparentPower": 3646.908, 170 | "reactivePower": 27.294, 171 | "pwrFactor": -0.994, 172 | "voltage": 123.696, 173 | "current": 28.813, 174 | "freq": 60.0 175 | }, 176 | { 177 | "eid": 1778385682, 178 | "timestamp": 1722967007, 179 | "actEnergyDlvd": 2037731.459, 180 | "actEnergyRcvd": 2706201.405, 181 | "apparentEnergy": 7469208.32, 182 | "reactEnergyLagg": 3071742.732, 183 | "reactEnergyLead": 73803.42, 184 | "instantaneousDemand": -3545.209, 185 | "activePower": -3545.209, 186 | "apparentPower": 3563.706, 187 | "reactivePower": 22.091, 188 | "pwrFactor": -0.994, 189 | "voltage": 123.696, 190 | "current": 28.813, 191 | "freq": 60.0 192 | }, 193 | { 194 | "eid": 1778385683, 195 | "timestamp": 1722967007, 196 | "actEnergyDlvd": 0.0, 197 | "actEnergyRcvd": 0.0, 198 | "apparentEnergy": 0.0, 199 | "reactEnergyLagg": 0.0, 200 | "reactEnergyLead": 0.0, 201 | "instantaneousDemand": 0.0, 202 | "activePower": 0.0, 203 | "apparentPower": 0.0, 204 | "reactivePower": 0.0, 205 | "pwrFactor": 0.0, 206 | "voltage": 0.0, 207 | "current": 0.0, 208 | "freq": 0.0 209 | } 210 | ] 211 | } 212 | ] -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_devstatus.json: -------------------------------------------------------------------------------- 1 | { 2 | "counters": { 3 | "pcu": { 4 | "expected": 14, 5 | "discovered": 14, 6 | "ctrlsTotal": 14, 7 | "ctrlsGone": 0, 8 | "ctrlsCommunicating": 14, 9 | "chansTotal": 14, 10 | "chansRecent": 14, 11 | "chansProducing": 14 12 | }, 13 | "acb": { 14 | "expected": 0, 15 | "discovered": 0, 16 | "ctrlsTotal": 0, 17 | "ctrlsGone": 0, 18 | "ctrlsCommunicating": 0, 19 | "chansTotal": 0, 20 | "chansRecent": 0, 21 | "chansProducing": 0 22 | }, 23 | "nsrb": { 24 | "expected": 2, 25 | "discovered": 2, 26 | "ctrlsTotal": 2, 27 | "ctrlsGone": 0, 28 | "ctrlsCommunicating": 2, 29 | "chansTotal": 2, 30 | "chansRecent": 2, 31 | "chansProducing": 0 32 | }, 33 | "pld": { 34 | "expected": 16, 35 | "discovered": 16, 36 | "ctrlsTotal": 16, 37 | "ctrlsGone": 0, 38 | "ctrlsCommunicating": 16, 39 | "chansTotal": 16, 40 | "chansRecent": 16, 41 | "chansProducing": 14 42 | }, 43 | "esub": { 44 | "expected": 0, 45 | "discovered": 0, 46 | "ctrlsTotal": 0, 47 | "ctrlsGone": 0, 48 | "ctrlsCommunicating": 0, 49 | "chansTotal": 0, 50 | "chansRecent": 0, 51 | "chansProducing": 0 52 | } 53 | }, 54 | "pcu": { 55 | "fields": [ 56 | "serialNumber", 57 | "devType", 58 | "communicating", 59 | "recent", 60 | "producing", 61 | "reportDate", 62 | "temperature", 63 | "dcVoltageINmV", 64 | "dcCurrentINmA", 65 | "acVoltageINmV", 66 | "acPowerINmW" 67 | ], 68 | "values": [ 69 | [ 70 | "999999913010", 71 | 1, 72 | true, 73 | true, 74 | true, 75 | 1689258553, 76 | 39, 77 | 30765, 78 | 8335, 79 | 250528, 80 | 251832 81 | ], 82 | [ 83 | "999999913012", 84 | 1, 85 | true, 86 | true, 87 | true, 88 | 1689258403, 89 | 36, 90 | 31038, 91 | 8656, 92 | 249832, 93 | 229937 94 | ], 95 | [ 96 | "999999912750", 97 | 1, 98 | true, 99 | true, 100 | true, 101 | 1689258555, 102 | 36, 103 | 32317, 104 | 6985, 105 | 250448, 106 | 223379 107 | ], 108 | [ 109 | "999999912983", 110 | 1, 111 | true, 112 | true, 113 | true, 114 | 1689258584, 115 | 38, 116 | 30913, 117 | 8127, 118 | 249792, 119 | 253993 120 | ], 121 | [ 122 | "999999908520", 123 | 1, 124 | true, 125 | true, 126 | true, 127 | 1689258526, 128 | 38, 129 | 30636, 130 | 9655, 131 | 248816, 132 | 289102 133 | ], 134 | [ 135 | "999999909983", 136 | 1, 137 | true, 138 | true, 139 | true, 140 | 1689258313, 141 | 35, 142 | 30958, 143 | 9958, 144 | 251840, 145 | 218666 146 | ], 147 | [ 148 | "999999908521", 149 | 1, 150 | true, 151 | true, 152 | true, 153 | 1689258526, 154 | 36, 155 | 30631, 156 | 9592, 157 | 249280, 158 | 286456 159 | ], 160 | [ 161 | "999999912669", 162 | 1, 163 | true, 164 | true, 165 | true, 166 | 1689258285, 167 | 35, 168 | 32625, 169 | 9471, 170 | 250024, 171 | 220724 172 | ], 173 | [ 174 | "999999913748", 175 | 1, 176 | true, 177 | true, 178 | true, 179 | 1689258586, 180 | 37, 181 | 30578, 182 | 9555, 183 | 249968, 184 | 287450 185 | ], 186 | [ 187 | "999999909985", 188 | 1, 189 | true, 190 | true, 191 | true, 192 | 1689258284, 193 | 36, 194 | 31243, 195 | 9895, 196 | 249616, 197 | 211995 198 | ], 199 | [ 200 | "999999915285", 201 | 1, 202 | true, 203 | true, 204 | true, 205 | 1689258345, 206 | 35, 207 | 32662, 208 | 9374, 209 | 251808, 210 | 244714 211 | ], 212 | [ 213 | "999999915246", 214 | 1, 215 | true, 216 | true, 217 | true, 218 | 1689258405, 219 | 35, 220 | 31637, 221 | 9738, 222 | 250976, 223 | 259065 224 | ], 225 | [ 226 | "999999912590", 227 | 1, 228 | true, 229 | true, 230 | true, 231 | 1689258344, 232 | 33, 233 | 34254, 234 | 2976, 235 | 251560, 236 | 99668 237 | ], 238 | [ 239 | "999999910862", 240 | 1, 241 | true, 242 | true, 243 | true, 244 | 1689258285, 245 | 29, 246 | 31524, 247 | 1776, 248 | 249928, 249 | 51092 250 | ], 251 | [ 252 | "999999968177", 253 | 12, 254 | true, 255 | true, 256 | false, 257 | 1689258314, 258 | 0, 259 | 0, 260 | 0, 261 | 0, 262 | 0 263 | ], 264 | [ 265 | "999999935944", 266 | 12, 267 | true, 268 | true, 269 | false, 270 | 1689258315, 271 | 0, 272 | 0, 273 | 0, 274 | 0, 275 | 0 276 | ] 277 | ] 278 | }, 279 | "acb": { 280 | "fields": [ 281 | "serialNumber", 282 | "SOC", 283 | "minCellTemp", 284 | "maxCellTemp", 285 | "capacity", 286 | "totVoltage", 287 | "sleepEnabled", 288 | "sleepMinSoc", 289 | "sleepMaxSoc" 290 | ], 291 | "values": [] 292 | }, 293 | "nsrb": { 294 | "fields": [ 295 | "serialNumber", 296 | "relay", 297 | "forced", 298 | "reason_code", 299 | "reason", 300 | "line-count", 301 | "line1-connected", 302 | "line2-connected", 303 | "line3-connected" 304 | ], 305 | "values": [ 306 | [ 307 | "999999968177", 308 | "closed", 309 | false, 310 | -1, 311 | "ok", 312 | 1, 313 | true, 314 | false, 315 | false 316 | ], 317 | [ 318 | "999999935944", 319 | "closed", 320 | false, 321 | -1, 322 | "ok", 323 | 1, 324 | true, 325 | false, 326 | false 327 | ] 328 | ] 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/__init__.py: -------------------------------------------------------------------------------- 1 | """The Enphase Envoy integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from contextlib import suppress 7 | from datetime import timedelta 8 | import logging 9 | import time 10 | from typing import Optional 11 | import copy 12 | 13 | import async_timeout 14 | from .envoy_reader import EnvoyReader, StreamData 15 | import httpx 16 | from numpy import isin 17 | 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.const import ( 20 | CONF_HOST, 21 | CONF_NAME, 22 | CONF_PASSWORD, 23 | CONF_USERNAME, 24 | EVENT_HOMEASSISTANT_STOP, 25 | ) 26 | from homeassistant.core import ( 27 | HomeAssistant, 28 | callback, 29 | CoreState, 30 | Event, 31 | ServiceCall, 32 | ServiceResponse, 33 | SupportsResponse, 34 | ) 35 | from homeassistant.exceptions import ConfigEntryAuthFailed 36 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 37 | from homeassistant.helpers.storage import Store 38 | from homeassistant.util import Throttle 39 | 40 | 41 | from .const import ( 42 | COORDINATOR, 43 | DOMAIN, 44 | NAME, 45 | PLATFORMS, 46 | BINARY_SENSORS, 47 | SENSORS, 48 | PHASE_SENSORS, 49 | CONF_SERIAL, 50 | READER, 51 | DEFAULT_SCAN_INTERVAL, 52 | DEFAULT_REALTIME_UPDATE_THROTTLE, 53 | LIVE_UPDATEABLE_ENTITIES, 54 | DEFAULT_GETDATA_TIMEOUT, 55 | ) 56 | 57 | STORAGE_KEY = "envoy" 58 | STORAGE_VERSION = 1 59 | 60 | _LOGGER = logging.getLogger(__name__) 61 | 62 | 63 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 64 | """Set up Enphase Envoy from a config entry.""" 65 | 66 | task: Optional[asyncio.Future] = None 67 | config = entry.data 68 | options = entry.options 69 | name = config[CONF_NAME] 70 | 71 | # Setup persistent storage, to save tokens between home assistant restarts 72 | store = Store(hass, STORAGE_VERSION, ".".join([STORAGE_KEY, entry.entry_id])) 73 | 74 | disabled_endpoints = options.get("disabled_endpoints", []) 75 | if ( 76 | not options.get("enable_pcu_comm_check") 77 | and "endpoint_pcu_comm_check" not in disabled_endpoints 78 | ): 79 | disabled_endpoints = copy.copy(disabled_endpoints) 80 | disabled_endpoints.append("endpoint_pcu_comm_check") 81 | 82 | envoy_reader = EnvoyReader( 83 | config[CONF_HOST], 84 | enlighten_user=config[CONF_USERNAME], 85 | enlighten_pass=config[CONF_PASSWORD], 86 | inverters=True, 87 | enlighten_serial_num=config[CONF_SERIAL], 88 | store=store, 89 | disable_negative_production=options.get("disable_negative_production", False), 90 | disabled_endpoints=disabled_endpoints, 91 | lifetime_production_correction=options.get("lifetime_production_correction", 0), 92 | device_data_endpoint=( 93 | "endpoint_devstatus" 94 | if options.get("devstatus_device_data", False) 95 | else "endpoint_device_data" 96 | ), 97 | ) 98 | await envoy_reader._sync_store(load=True) 99 | 100 | async def async_update_data(): 101 | """Fetch data from API endpoint.""" 102 | data = {} 103 | async with async_timeout.timeout( 104 | options.get("getdata_timeout", DEFAULT_GETDATA_TIMEOUT) 105 | ): 106 | try: 107 | await envoy_reader.get_data() 108 | except httpx.HTTPStatusError as err: 109 | raise ConfigEntryAuthFailed from err 110 | except httpx.HTTPError as err: 111 | raise UpdateFailed(f"Error communicating with API: {err}") from err 112 | 113 | # The envoy_reader.all_values will adjust production values, based on option key 114 | data = envoy_reader.all_values 115 | 116 | await envoy_reader._sync_store() 117 | return data 118 | 119 | coordinator = DataUpdateCoordinator( 120 | hass, 121 | _LOGGER, 122 | name=f"envoy {name}", 123 | update_method=async_update_data, 124 | update_interval=timedelta( 125 | seconds=options.get("time_between_update", DEFAULT_SCAN_INTERVAL) 126 | ), 127 | ) 128 | 129 | try: 130 | await coordinator.async_config_entry_first_refresh() 131 | except ConfigEntryAuthFailed: 132 | envoy_reader.get_inverters = False 133 | await coordinator.async_config_entry_first_refresh() 134 | 135 | if not entry.unique_id: 136 | try: 137 | serial = await envoy_reader.get_full_serial_number() 138 | except httpx.HTTPError: 139 | pass 140 | else: 141 | hass.config_entries.async_update_entry(entry, unique_id=serial) 142 | 143 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { 144 | COORDINATOR: coordinator, 145 | NAME: name, 146 | READER: envoy_reader, 147 | } 148 | live_entities = hass.data[DOMAIN][entry.entry_id].setdefault( 149 | LIVE_UPDATEABLE_ENTITIES, {} 150 | ) 151 | 152 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 153 | 154 | # Finally, start measuring production counters 155 | time_between_realtime_updates = timedelta( 156 | seconds=options.get( 157 | "realtime_update_throttle", DEFAULT_REALTIME_UPDATE_THROTTLE 158 | ), 159 | ) 160 | 161 | async def async_enable_dpel(call: ServiceCall): 162 | await envoy_reader.enable_dpel( 163 | watt=call.data.get("watt"), 164 | slew=call.data.get("slew_rate", 50), 165 | export_limit=call.data.get("export_limit", True), 166 | ) 167 | await coordinator.async_request_refresh() 168 | 169 | hass.services.async_register(DOMAIN, "enable_dpel", async_enable_dpel) 170 | 171 | async def async_disable_dpel(call: ServiceCall): 172 | await envoy_reader.disable_dpel() 173 | await coordinator.async_request_refresh() 174 | 175 | hass.services.async_register(DOMAIN, "disable_dpel", async_disable_dpel) 176 | 177 | async def get_grid_profiles(call: ServiceCall) -> ServiceResponse: 178 | return { 179 | "selected_profile": coordinator.data.get("grid_profile"), 180 | "available_profiles": [ 181 | k["profile_id"] for k in coordinator.data.get("grid_profiles_available") 182 | ], 183 | } 184 | 185 | hass.services.async_register( 186 | DOMAIN, 187 | "get_grid_profiles", 188 | get_grid_profiles, 189 | supports_response=SupportsResponse.ONLY, 190 | ) 191 | 192 | async def set_grid_profile(call: ServiceCall): 193 | await envoy_reader.set_grid_profile(call.data["profile"]) 194 | await coordinator.async_request_refresh() 195 | 196 | hass.services.async_register( 197 | DOMAIN, 198 | "set_grid_profile", 199 | set_grid_profile, 200 | ) 201 | 202 | async def upload_grid_profile(call: ServiceCall): 203 | await envoy_reader.upload_grid_profile(call.data["file"]) 204 | await coordinator.async_request_refresh() 205 | 206 | hass.services.async_register( 207 | DOMAIN, 208 | "upload_grid_profile", 209 | upload_grid_profile, 210 | ) 211 | 212 | @Throttle(time_between_realtime_updates) 213 | def update_production_meters(streamdata: StreamData): 214 | new_data = {} 215 | for phase in ["l1", "l2", "l3"]: 216 | production_watts = envoy_reader.process_production_value( 217 | streamdata.production[phase].watts 218 | ) 219 | new_data.update( 220 | { 221 | "production_" + phase: production_watts, 222 | "voltage_" + phase: streamdata.production[phase].volt, 223 | "ampere_" + phase: streamdata.production[phase].amps, 224 | "apparent_power_" + phase: streamdata.production[phase].volt_ampere, 225 | "power_factor" + phase: streamdata.production[phase].pf, 226 | "reactive_power_" + phase: streamdata.production[phase].var, 227 | "frequency_" + phase: streamdata.production[phase].hz, 228 | "consumption_" + phase: streamdata.consumption[phase].watts, 229 | } 230 | ) 231 | 232 | for key, value in new_data.items(): 233 | if live_entities.get(key, False) and coordinator.data.get(key) != value: 234 | # Update the value in the coordinator 235 | coordinator.data[key] = value 236 | 237 | # Let hass know the data is updated 238 | live_entities[key].async_write_ha_state() 239 | 240 | async def read_realtime_updates() -> None: 241 | while ( 242 | hass.state == CoreState.not_running 243 | or hass.is_running 244 | and options.get("enable_realtime_updates", False) 245 | ): 246 | result = await envoy_reader.stream_reader( 247 | meter_callback=update_production_meters 248 | ) 249 | if result == False: 250 | # If result is False, then we are done reconnecting 251 | _LOGGER.warning( 252 | "Reading /stream/meter failed, stopping realtime updates" 253 | ) 254 | return 255 | 256 | _LOGGER.warning("Re-connecting /stream/meter") 257 | # throttle reconnect attempts 258 | await asyncio.sleep(30) 259 | 260 | if options.get("enable_realtime_updates", False): 261 | # Setup a home assistant task (that will never die...) 262 | _LOGGER.debug("Starting loop for /stream/meter") 263 | task = asyncio.create_task(read_realtime_updates()) 264 | 265 | @callback 266 | async def _async_stop(_: Event) -> None: 267 | _LOGGER.debug("Stopping loop for /stream/meter") 268 | await _cancel_realtime_task(task) 269 | 270 | hass.data[DOMAIN][entry.entry_id]["realtime_loop"] = False 271 | 272 | # Make sure task is cancelled on shutdown (or tests complete) 273 | entry.async_on_unload( 274 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) 275 | ) 276 | 277 | # Save the task to be able to cancel it when unloading 278 | hass.data[DOMAIN][entry.entry_id]["realtime_loop"] = task 279 | return True 280 | 281 | 282 | async def _cancel_realtime_task(task: Optional[asyncio.Future]) -> None: 283 | if not task: 284 | _LOGGER.debug("No task to cancel") 285 | return 286 | 287 | task.cancel() 288 | try: 289 | await task 290 | except asyncio.CancelledError: 291 | pass 292 | except Exception as e: 293 | _LOGGER.exception( 294 | f"While waiting for task to be cancelled, this execption occured: {e}" 295 | ) 296 | 297 | 298 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 299 | """Unload a config entry.""" 300 | 301 | if task := hass.data[DOMAIN][entry.entry_id].get("realtime_loop"): 302 | _LOGGER.debug("Stopping loop for /stream/meter") 303 | await _cancel_realtime_task(task) 304 | 305 | hass.data[DOMAIN][entry.entry_id]["realtime_loop"] = False 306 | 307 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 308 | if unload_ok: 309 | hass.data[DOMAIN].pop(entry.entry_id) 310 | return unload_ok 311 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Enphase Envoy Installer 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![Maintainer][maintainer-shield]][maintainer] 5 | [![HACS Custom][hacs-shield]][hacs-url] 6 | 7 | This is a HACS custom integration for enphase envoys with firmware version 7 and up. 8 | 9 | Especially made to provide extra functionality with your installer or DIY account. 10 | You can also use the integration with a Home Owner account, without the extra functionality. 11 | 12 | Features: 13 | - Individual device per inverter with all information available. 14 | - Individual device per Q-relay with relay status. 15 | - Individual device per battery with information available. 16 | - Production switch to turn on/off solar power production. 17 | - Communication level sensors (optional). 18 | - 3 Phase CT readings. 19 | - "Realtime" updates of CT readings. 20 | - Configurable polling interval. 21 | - Negative production reading correction (optional). 22 | - Service call to retrieve and set and upload grid profile. 23 | - Service call to enable/disable DPEL. 24 | - Lifetime production correction (optional). 25 | 26 | ## Screenshots 27 | 28 | ![phase_sensors](https://github.com/vincentwolsink/home_assistant_enphase_envoy_installer/assets/1639734/87fc0c3d-1fd8-4e2c-b7ce-df48531c90e6) 29 | 30 | 31 | ## Available Entities 32 | Available entities differ per Envoy type and conf 33 | iguration. 34 | 35 | ### Envoy 36 | |Entity name|Entity ID|Unit| 37 | |-----------|---------|----| 38 | |Envoy xxx Apparent Power ¹|sensor.envoy_xxx_apparent_power|VA| 39 | |Envoy xxx Apparent Power L1 ¹|sensor.envoy_xxx_apparent_power_l1|VA| 40 | |Envoy xxx Apparent Power L2 ¹|sensor.envoy_xxx_apparent_power_l2|VA| 41 | |Envoy xxx Apparent Power L3 ¹|sensor.envoy_xxx_apparent_power_l3|VA| 42 | |Envoy xxx Batteries Available Energy|sensor.envoy_xxx_batteries_available_energy|Wh| 43 | |Envoy xxx Batteries Capacity|sensor.envoy_xxx_batteries_capacity|Wh| 44 | |Envoy xxx Batteries Charge|sensor.envoy_xxx_batteries_charge|%| 45 | |Envoy xxx Batteries Charge From Grid|switch.envoy_xxx_batteries_charge_from_grid|| 46 | |Envoy xxx Batteries Mode|select.envoy_xxx_batteries_mode|| 47 | |Envoy xxx Batteries Power|sensor.envoy_xxx_batteries_power|W| 48 | |Envoy xxx Batteries Reserve Charge|number.envoy_xxx_batteries_reserve_charge|%| 49 | |Envoy xxx Current Amps ¹|sensor.envoy_xxx_current_amps|A| 50 | |Envoy xxx Current Amps L1 ¹|sensor.envoy_xxx_current_amps_l1|A| 51 | |Envoy xxx Current Amps L2 ¹|sensor.envoy_xxx_current_amps_l2|A| 52 | |Envoy xxx Current Amps L3 ¹|sensor.envoy_xxx_current_amps_l3|A| 53 | |Envoy xxx Current Power Consumption|sensor.envoy_xxx_current_power_consumption|W| 54 | |Envoy xxx Current Power Consumption L1|sensor.envoy_xxx_current_power_consumption_l1|W| 55 | |Envoy xxx Current Power Consumption L2|sensor.envoy_xxx_current_power_consumption_l2|W| 56 | |Envoy xxx Current Power Consumption L3|sensor.envoy_xxx_current_power_consumption_l3|W| 57 | |Envoy xxx Current Power Production|sensor.envoy_xxx_current_power_production|W| 58 | |Envoy xxx Current Power Production L1|sensor.envoy_xxx_current_power_production_l1|W| 59 | |Envoy xxx Current Power Production L2|sensor.envoy_xxx_current_power_production_l2|W| 60 | |Envoy xxx Current Power Production L3|sensor.envoy_xxx_current_power_production_l3|W| 61 | |Envoy xxx Current Voltage|sensor.envoy_xxx_current_voltage|V| 62 | |Envoy xxx Current Voltage L1|sensor.envoy_xxx_current_voltage_l1|V| 63 | |Envoy xxx Current Voltage L2|sensor.envoy_xxx_current_voltage_l2|V| 64 | |Envoy xxx Current Voltage L3|sensor.envoy_xxx_current_voltage_l3|V| 65 | |Envoy xxx DPEL|binary_sensor.envoy_xxx_dpel_enabled|| 66 | |Envoy xxx DPEL Limit|sensor.envoy_xxx_dpel_limit|W| 67 | |Envoy xxx DPEL Mode|select.envoy_xxx_dpel_mode|| 68 | |Envoy xxx Frequency L1 ¹|sensor.envoy_xxx_frequency_l1|Hz| 69 | |Envoy xxx Frequency L2 ¹|sensor.envoy_xxx_frequency_l2|Hz| 70 | |Envoy xxx Frequency L3 ¹|sensor.envoy_xxx_frequency_l3|Hz| 71 | |Envoy xxx Grid Profile|sensor.envoy_xxx_grid_profile|| 72 | |Envoy xxx Grid Status|binary_sensor.envoy_xxx_grid_status|| 73 | |Envoy xxx Lifetime Batteries Energy Charged|sensor.envoy_xxx_lifetime_batteries_charged|Wh| 74 | |Envoy xxx Lifetime Batteries Energy Charged L1|sensor.envoy_xxx_lifetime_batteries_charged_l1|Wh| 75 | |Envoy xxx Lifetime Batteries Energy Charged L2|sensor.envoy_xxx_lifetime_batteries_charged_l2|Wh| 76 | |Envoy xxx Lifetime Batteries Energy Charged L3|sensor.envoy_xxx_lifetime_batteries_charged_l3|Wh| 77 | |Envoy xxx Lifetime Batteries Energy Discharged|sensor.envoy_xxx_lifetime_batteries_discharged|Wh| 78 | |Envoy xxx Lifetime Batteries Energy Discharged L1|sensor.envoy_xxx_lifetime_batteries_discharged_l1|Wh| 79 | |Envoy xxx Lifetime Batteries Energy Discharged L2|sensor.envoy_xxx_lifetime_batteries_discharged_l2|Wh| 80 | |Envoy xxx Lifetime Batteries Energy Discharged L3|sensor.envoy_xxx_lifetime_batteries_discharged_l3|Wh| 81 | |Envoy xxx Lifetime Energy Consumption|sensor.envoy_xxx_lifetime_energy_consumption|Wh| 82 | |Envoy xxx Lifetime Energy Consumption L1|sensor.envoy_xxx_lifetime_energy_consumption_l1|Wh| 83 | |Envoy xxx Lifetime Energy Consumption L2|sensor.envoy_xxx_lifetime_energy_consumption_l2|Wh| 84 | |Envoy xxx Lifetime Energy Consumption L3|sensor.envoy_xxx_lifetime_energy_consumption_l3|Wh| 85 | |Envoy xxx Lifetime Energy Production|sensor.envoy_xxx_lifetime_energy_production|Wh| 86 | |Envoy xxx Lifetime Energy Production L1|sensor.envoy_xxx_lifetime_energy_production_l1|Wh| 87 | |Envoy xxx Lifetime Energy Production L2|sensor.envoy_xxx_lifetime_energy_production_l2|Wh| 88 | |Envoy xxx Lifetime Energy Production L3|sensor.envoy_xxx_lifetime_energy_production_l3|Wh| 89 | |Envoy xxx Lifetime Net Energy Consumption|sensor.envoy_xxx_lifetime_net_energy_consumption|Wh| 90 | |Envoy xxx Lifetime Net Energy Consumption L1|sensor.envoy_xxx_lifetime_net_energy_consumption_l1|Wh| 91 | |Envoy xxx Lifetime Net Energy Consumption L2|sensor.envoy_xxx_lifetime_net_energy_consumption_l2|Wh| 92 | |Envoy xxx Lifetime Net Energy Consumption L3|sensor.envoy_xxx_lifetime_net_energy_consumption_l3|Wh| 93 | |Envoy xxx Lifetime Net Energy Production|sensor.envoy_xxx_lifetime_energy_production|Wh| 94 | |Envoy xxx Lifetime Net Energy Production L1|sensor.envoy_xxx_lifetime_net_energy_production_l1|Wh| 95 | |Envoy xxx Lifetime Net Energy Production L2|sensor.envoy_xxx_lifetime_net_energy_production_l2|Wh| 96 | |Envoy xxx Lifetime Net Energy Production L3|sensor.envoy_xxx_lifetime_net_energy_production_l3|Wh| 97 | |Envoy xxx Power Factor L1 ¹|sensor.envoy_xxx_power_factor_l1|| 98 | |Envoy xxx Power Factor L2 ¹|sensor.envoy_xxx_power_factor_l2|| 99 | |Envoy xxx Power Factor L3 ¹|sensor.envoy_xxx_power_factor_l3|| 100 | |Envoy xxx Production|switch.envoy_xxx_production|| 101 | |Envoy xxx Reactive Power L1 ¹|sensor.envoy_xxx_reactive_power_l1|var| 102 | |Envoy xxx Reactive Power L2 ¹|sensor.envoy_xxx_reactive_power_l2|var| 103 | |Envoy xxx Reactive Power L3 ¹|sensor.envoy_xxx_reactive_power_l3|var| 104 | |Envoy xxx Today's Energy Consumption|sensor.envoy_xxx_today_s_energy_consumption|Wh| 105 | |Envoy xxx Today's Energy Consumption L1|sensor.envoy_xxx_today_s_energy_consumption_l1|Wh| 106 | |Envoy xxx Today's Energy Consumption L2|sensor.envoy_xxx_today_s_energy_consumption_l2|Wh| 107 | |Envoy xxx Today's Energy Consumption L3|sensor.envoy_xxx_today_s_energy_consumption_l3|Wh| 108 | |Envoy xxx Today's Energy Production|sensor.envoy_xxx_today_s_energy_production|Wh| 109 | |Envoy xxx Today's Energy Production L1|sensor.envoy_xxx_today_s_energy_production_l1|Wh| 110 | |Envoy xxx Today's Energy Production L2|sensor.envoy_xxx_today_s_energy_production_l2|Wh| 111 | |Envoy xxx Today's Energy Production L3|sensor.envoy_xxx_today_s_energy_production_l3|Wh| 112 | 113 | ### Inverter 114 | |Entity name|Entity ID|Unit| 115 | |-----------|---------|----| 116 | |Inverter xxx AC Current|sensor.inverter_xxx_ac_current|A| 117 | |Inverter xxx AC Frequency|sensor.inverter_xxx_ac_frequency|Hz| 118 | |Inverter xxx AC Voltage|sensor.inverter_xxx_ac_voltage|V| 119 | |Inverter xxx Communicating|binary_sensor.inverter_xxx_communicating| 120 | |Inverter xxx Communication Level ¹|sensor.inverter_xxx_communication_level| 121 | |Inverter xxx DC Current|sensor.inverter_xxx_dc_current|A| 122 | |Inverter xxx Last Reading|sensor.inverter_xxx_last_reading|| 123 | |Inverter xxx Lifetime Energy Production|sensor.inverter_xxx_lifetime_energy_production|Wh| 124 | |Inverter xxx Power Conversion Error Cycles|sensor.inverter_xxx_power_conversion_error_cycles|| 125 | |Inverter xxx Power Conversion Error Seconds|sensor.inverter_xxx_power_conversion_error_seconds|s| 126 | |Inverter xxx Producing|binary_sensor.inverter_xxx_producing|| 127 | |Inverter xxx Production|sensor.inverter_xxx_production|W| 128 | |Inverter xxx Temperature|sensor.inverter_xxx_temperature|°C| 129 | |Inverter xxx This Week's Energy Production|sensor.inverter_xxx_this_week_s_energy_production|Wh| 130 | |Inverter xxx Today's Energy Production|sensor.inverter_xxx_today_s_energy_production|Wh| 131 | |Inverter xxx Yesterday's Energy Production|sensor.inverter_xxx_yesterday_s_energy_production|Wh| 132 | 133 | ### Battery 134 | |Entity name|Entity ID|Unit| 135 | |-----------|---------|----| 136 | |Battery xxx Available Energy|sensor.battery_xxx_available_energy|Wh| 137 | |Battery xxx Capacity|sensor.battery_xxx_capacity|Wh| 138 | |Battery xxx Charge|sensor.battery_xxx_charge|%| 139 | |Battery xxx Communicating|binary_sensor.battery_xxx_communicating|| 140 | |Battery xxx DC Switch|binary_sensor.battery_xxx_dc_switch|| 141 | |Battery xxx Operating|binary_sensor.battery_xxx_operating|| 142 | |Battery xxx Power|sensor.battery_xxx_power|W| 143 | |Battery xxx Sleep|binary_sensor.battery_xxx_sleep|| 144 | |Battery xxx Status|sensor.battery_xxx_status|| 145 | |Battery xxx Temperature|sensor.battery_xxx_temperature|°C| 146 | 147 | ### Relay 148 | |Entity name|Entity ID|Unit| 149 | |-----------|---------|----| 150 | |Relay xxx Communicating|binary_sensor.relay_xxx_communicating|| 151 | |Relay xxx Communication Level ¹|sensor.relay_xxx_communication_level|| 152 | |Relay xxx Contact|binary_sensor.relay_xxx_contact|| 153 | |Relay xxx Frequency|binary_sensor.relay_xxx_frequency|Hz| 154 | |Relay xxx Last Reading|sensor.relay_xxx_last_reading|| 155 | |Relay xxx State Change Count|sensor.relay_xxx_state_change_count|| 156 | |Relay xxx Temperature|sensor.relay_xxx_temperature|°C| 157 | |Relay xxx Voltage L1|sensor.relay_xxx_voltage_l1|V| 158 | |Relay xxx Voltage L2|sensor.relay_xxx_voltage_l2|V| 159 | |Relay xxx Voltage L3|sensor.relay_xxx_voltage_l3|V| 160 | 161 | ¹ Optional. Enable via integration configuration. 162 | 163 | ## Installation 164 | 165 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=vincentwolsink&repository=home_assistant_enphase_envoy_installer&category=integration) 166 | 167 | Or follow these steps: 168 | 1. Install [HACS](https://hacs.xyz/) if you haven't already 169 | 2. Add this repository as a [custom integration repository](https://hacs.xyz/docs/faq/custom_repositories) in HACS 170 | 4. Restart home assistant 171 | 5. Add the integration through the home assistant configuration flow 172 | 173 | ## Credits 174 | Based on work from [@briancmpbll](https://github.com/briancmpbll/home_assistant_custom_envoy) 175 | 176 | [releases-shield]: https://img.shields.io/github/v/release/vincentwolsink/home_assistant_enphase_envoy_installer.svg?style=for-the-badge 177 | [releases]: https://github.com/vincentwolsink/home_assistant_enphase_envoy_installer/releases 178 | [maintainer-shield]: https://img.shields.io/badge/maintainer-vincentwolsink-blue.svg?style=for-the-badge 179 | [maintainer]: https://github.com/vincentwolsink 180 | [hacs-shield]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge 181 | [hacs-url]: https://github.com/vincentwolsink/home_assistant_enphase_envoy_installer 182 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Enphase Envoy integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import logging 7 | from typing import Any 8 | 9 | from .envoy_reader import EnvoyReader, EnlightenError, EnvoyError 10 | import httpx 11 | import voluptuous as vol 12 | 13 | from homeassistant import config_entries 14 | from homeassistant.components import zeroconf 15 | from homeassistant.const import ( 16 | CONF_HOST, 17 | CONF_NAME, 18 | CONF_PASSWORD, 19 | CONF_USERNAME, 20 | ) 21 | from homeassistant.core import HomeAssistant, callback 22 | from homeassistant.data_entry_flow import FlowResult 23 | from homeassistant.helpers import config_validation as cv 24 | from homeassistant.exceptions import HomeAssistantError 25 | from homeassistant.util.network import is_ipv4_address, is_ipv6_address 26 | 27 | from .const import ( 28 | DOMAIN, 29 | CONF_SERIAL, 30 | DEFAULT_SCAN_INTERVAL, 31 | DEFAULT_REALTIME_UPDATE_THROTTLE, 32 | ENABLE_ADDITIONAL_METRICS, 33 | DEFAULT_GETDATA_TIMEOUT, 34 | ) 35 | from .envoy_endpoints import ENVOY_ENDPOINTS 36 | 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | ENVOY = "Envoy" 41 | 42 | 43 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> EnvoyReader: 44 | """Validate the user input allows us to connect.""" 45 | envoy_reader = EnvoyReader( 46 | data[CONF_HOST], 47 | enlighten_user=data[CONF_USERNAME], 48 | enlighten_pass=data[CONF_PASSWORD], 49 | inverters=False, 50 | enlighten_serial_num=data[CONF_SERIAL], 51 | ) 52 | 53 | try: 54 | await envoy_reader.get_data() 55 | except EnlightenError as err: 56 | raise InvalidAuth from err 57 | except (EnvoyError, httpx.ConnectError) as err: 58 | raise CannotConnect from err 59 | 60 | return envoy_reader 61 | 62 | 63 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 64 | """Handle a config flow for Enphase Envoy.""" 65 | 66 | VERSION = 1 67 | 68 | def __init__(self): 69 | """Initialize an envoy flow.""" 70 | self.ip_address = None 71 | self.username = None 72 | self._reauth_entry = None 73 | 74 | @callback 75 | def _async_generate_schema(self): 76 | """Generate schema.""" 77 | schema = {} 78 | 79 | if self.ip_address: 80 | schema[vol.Required(CONF_HOST, default=self.ip_address)] = vol.In( 81 | [self.ip_address] 82 | ) 83 | else: 84 | schema[vol.Required(CONF_HOST)] = str 85 | 86 | schema[vol.Required(CONF_SERIAL, default=self.unique_id)] = str 87 | schema[vol.Required(CONF_USERNAME, default=self.username)] = str 88 | schema[vol.Required(CONF_PASSWORD, default="")] = str 89 | 90 | return vol.Schema(schema) 91 | 92 | @callback 93 | def _async_current_hosts(self): 94 | """Return a set of hosts.""" 95 | return { 96 | entry.data[CONF_HOST] 97 | for entry in self._async_current_entries(include_ignore=False) 98 | if CONF_HOST in entry.data 99 | } 100 | 101 | async def async_step_zeroconf( 102 | self, discovery_info: zeroconf.ZeroconfServiceInfo 103 | ) -> FlowResult: 104 | """Handle a flow initialized by zeroconf discovery.""" 105 | serial = discovery_info.properties["serialnum"] 106 | await self.async_set_unique_id(serial) 107 | self.ip_address = discovery_info.host 108 | 109 | for entry in self._async_current_entries(include_ignore=False): 110 | if entry.unique_id == self.unique_id: 111 | if entry.data[CONF_HOST] != self.ip_address: 112 | """Update current host ip to new discovered one if same ip version""" 113 | if ( 114 | is_ipv4_address(entry.data[CONF_HOST]) 115 | and is_ipv4_address(self.ip_address) 116 | ) or ( 117 | is_ipv6_address(entry.data[CONF_HOST]) 118 | and is_ipv6_address(self.ip_address) 119 | ): 120 | self.hass.config_entries.async_update_entry( 121 | entry, data={**entry.data, CONF_HOST: self.ip_address} 122 | ) 123 | self.hass.async_create_task( 124 | self.hass.config_entries.async_reload(entry.entry_id), 125 | f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", 126 | ) 127 | 128 | return self.async_abort(reason="already_configured") 129 | elif ( 130 | entry.unique_id is None 131 | and CONF_HOST in entry.data 132 | and entry.data[CONF_HOST] == self.ip_address 133 | ): 134 | title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY 135 | self.hass.config_entries.async_update_entry( 136 | entry, title=title, unique_id=serial 137 | ) 138 | self.hass.async_create_task( 139 | self.hass.config_entries.async_reload(entry.entry_id) 140 | ) 141 | return self.async_abort(reason="already_configured") 142 | 143 | return await self.async_step_user() 144 | 145 | async def async_step_reauth(self, user_input): 146 | """Handle configuration by re-auth.""" 147 | self._reauth_entry = self.hass.config_entries.async_get_entry( 148 | self.context["entry_id"] 149 | ) 150 | return await self.async_step_user() 151 | 152 | def _async_envoy_name(self) -> str: 153 | """Return the name of the envoy.""" 154 | if self.unique_id: 155 | return f"{ENVOY} {self.unique_id}" 156 | return ENVOY 157 | 158 | async def _async_set_unique_id_from_envoy(self, envoy_reader: EnvoyReader) -> bool: 159 | """Set the unique id by fetching it from the envoy.""" 160 | serial = None 161 | with contextlib.suppress(httpx.HTTPError): 162 | serial = await envoy_reader.get_full_serial_number() 163 | if serial: 164 | await self.async_set_unique_id(serial) 165 | return True 166 | return False 167 | 168 | async def async_step_user( 169 | self, user_input: dict[str, Any] | None = None 170 | ) -> FlowResult: 171 | """Handle the initial step.""" 172 | errors = {} 173 | 174 | if user_input is not None: 175 | if ( 176 | not self._reauth_entry 177 | and user_input[CONF_HOST] in self._async_current_hosts() 178 | ): 179 | return self.async_abort(reason="already_configured") 180 | try: 181 | envoy_reader = await validate_input(self.hass, user_input) 182 | except CannotConnect: 183 | errors["base"] = "cannot_connect" 184 | except InvalidAuth: 185 | errors["base"] = "invalid_auth" 186 | except Exception: # pylint: disable=broad-except 187 | _LOGGER.exception("Unexpected exception") 188 | errors["base"] = "unknown" 189 | else: 190 | data = user_input.copy() 191 | data[CONF_NAME] = self._async_envoy_name() 192 | 193 | if self._reauth_entry: 194 | self.hass.config_entries.async_update_entry( 195 | self._reauth_entry, 196 | data=data, 197 | ) 198 | return self.async_abort(reason="reauth_successful") 199 | 200 | if not self.unique_id and await self._async_set_unique_id_from_envoy( 201 | envoy_reader 202 | ): 203 | data[CONF_NAME] = self._async_envoy_name() 204 | 205 | if self.unique_id: 206 | self._abort_if_unique_id_configured({CONF_HOST: data[CONF_HOST]}) 207 | 208 | return self.async_create_entry(title=data[CONF_NAME], data=data) 209 | 210 | if self.unique_id: 211 | self.context["title_placeholders"] = { 212 | CONF_SERIAL: self.unique_id, 213 | CONF_HOST: self.ip_address, 214 | } 215 | return self.async_show_form( 216 | step_id="user", 217 | data_schema=self._async_generate_schema(), 218 | errors=errors, 219 | ) 220 | 221 | @staticmethod 222 | @callback 223 | def async_get_options_flow(config_entry: ConfigEntry): 224 | return EnvoyOptionsFlowHandler() 225 | 226 | 227 | class EnvoyOptionsFlowHandler(config_entries.OptionsFlow): 228 | """Envoy config flow options handler.""" 229 | 230 | async def async_step_init(self, _user_input=None): 231 | """Manage the options.""" 232 | return await self.async_step_user() 233 | 234 | async def async_step_user(self, user_input=None): 235 | """Handle a flow initialized by the user.""" 236 | 237 | if user_input is not None: 238 | return self.async_create_entry(title="", data=user_input) 239 | 240 | optional_endpoints = { 241 | f"endpoint_{key}": key 242 | for key, endpoint in ENVOY_ENDPOINTS.items() 243 | if endpoint["optional"] 244 | } 245 | disabled_endpoints = [ 246 | ep 247 | for ep in self.config_entry.options.get("disabled_endpoints", []) 248 | if ep in optional_endpoints.keys() 249 | ] 250 | 251 | schema = { 252 | vol.Optional( 253 | "time_between_update", 254 | default=self.config_entry.options.get( 255 | "time_between_update", DEFAULT_SCAN_INTERVAL 256 | ), 257 | ): vol.All(vol.Coerce(int), vol.Range(min=5)), 258 | vol.Optional( 259 | "getdata_timeout", 260 | default=self.config_entry.options.get( 261 | "getdata_timeout", DEFAULT_GETDATA_TIMEOUT 262 | ), 263 | ): vol.All(vol.Coerce(int), vol.Range(min=30)), 264 | vol.Optional( 265 | "disable_negative_production", 266 | default=self.config_entry.options.get( 267 | "disable_negative_production", False 268 | ), 269 | ): bool, 270 | vol.Optional( 271 | "enable_realtime_updates", 272 | default=self.config_entry.options.get("enable_realtime_updates", False), 273 | ): bool, 274 | vol.Optional( 275 | "realtime_update_throttle", 276 | default=self.config_entry.options.get( 277 | "realtime_update_throttle", DEFAULT_REALTIME_UPDATE_THROTTLE 278 | ), 279 | ): vol.All(vol.Coerce(int), vol.Range(min=0)), 280 | vol.Optional( 281 | ENABLE_ADDITIONAL_METRICS, 282 | default=self.config_entry.options.get(ENABLE_ADDITIONAL_METRICS, False), 283 | ): bool, 284 | vol.Optional( 285 | "enable_pcu_comm_check", 286 | default=self.config_entry.options.get("enable_pcu_comm_check", False), 287 | ): bool, 288 | vol.Optional( 289 | "devstatus_device_data", 290 | default=self.config_entry.options.get("devstatus_device_data", False), 291 | ): bool, 292 | vol.Optional( 293 | "lifetime_production_correction", 294 | default=self.config_entry.options.get( 295 | "lifetime_production_correction", 0 296 | ), 297 | ): vol.All(vol.Coerce(int)), 298 | vol.Optional( 299 | "disabled_endpoints", 300 | description={"suggested_value": disabled_endpoints}, 301 | ): cv.multi_select(optional_endpoints), 302 | } 303 | return self.async_show_form(step_id="user", data_schema=vol.Schema(schema)) 304 | 305 | 306 | class CannotConnect(HomeAssistantError): 307 | """Error to indicate we cannot connect.""" 308 | 309 | 310 | class InvalidAuth(HomeAssistantError): 311 | """Error to indicate there is invalid auth.""" 312 | -------------------------------------------------------------------------------- /custom_components/enphase_envoy/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 6 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 7 | from homeassistant.components.binary_sensor import BinarySensorEntity 8 | from homeassistant.helpers.entity import DeviceInfo 9 | 10 | from .const import ( 11 | COORDINATOR, 12 | DOMAIN, 13 | NAME, 14 | READER, 15 | BINARY_SENSORS, 16 | resolve_hardware_id, 17 | get_model_name, 18 | ) 19 | 20 | 21 | async def async_setup_entry( 22 | hass: HomeAssistant, 23 | config_entry: ConfigEntry, 24 | async_add_entities: AddEntitiesCallback, 25 | ) -> None: 26 | data = hass.data[DOMAIN][config_entry.entry_id] 27 | coordinator = data[COORDINATOR] 28 | name = data[NAME] 29 | reader = data[READER] 30 | 31 | entities = [] 32 | for sensor_description in BINARY_SENSORS: 33 | if sensor_description.key.startswith("inverter_data_"): 34 | if coordinator.data.get("inverter_device_data"): 35 | for inverter in coordinator.data["inverter_device_data"].keys(): 36 | device_name = f"Inverter {inverter}" 37 | entity_name = f"{device_name} {sensor_description.name}" 38 | entities.append( 39 | EnvoyInverterEntity( 40 | sensor_description, 41 | entity_name, 42 | device_name, 43 | inverter, 44 | None, 45 | coordinator, 46 | ) 47 | ) 48 | 49 | elif sensor_description.key.startswith("inverter_info_"): 50 | if coordinator.data.get("inverter_info"): 51 | for inverter in coordinator.data["inverter_info"].keys(): 52 | device_name = f"Inverter {inverter}" 53 | entity_name = f"{device_name} {sensor_description.name}" 54 | entities.append( 55 | EnvoyInverterEntity( 56 | sensor_description, 57 | entity_name, 58 | device_name, 59 | inverter, 60 | None, 61 | coordinator, 62 | ) 63 | ) 64 | 65 | elif sensor_description.key.startswith("relay_info_"): 66 | if coordinator.data.get("relay_info") != None: 67 | for serial_number, data in coordinator.data["relay_info"].items(): 68 | device_name = f"Relay {serial_number}" 69 | entity_name = f"{device_name} {sensor_description.name}" 70 | 71 | if sensor_description.key == "relay_info_relay": 72 | entities.append( 73 | EnvoyRelayContactEntity( 74 | sensor_description, 75 | entity_name, 76 | device_name, 77 | serial_number, 78 | serial_number, 79 | coordinator, 80 | config_entry.unique_id, 81 | ) 82 | ) 83 | else: 84 | entities.append( 85 | EnvoyRelayEntity( 86 | sensor_description, 87 | entity_name, 88 | device_name, 89 | serial_number, 90 | None, 91 | coordinator, 92 | config_entry.unique_id, 93 | ) 94 | ) 95 | 96 | elif sensor_description.key.startswith("batteries_"): 97 | if coordinator.data.get("batteries"): 98 | for battery in coordinator.data["batteries"].keys(): 99 | device_name = f"Battery {battery}" 100 | entity_name = f"{device_name} {sensor_description.name}" 101 | serial_number = battery 102 | entities.append( 103 | EnvoyBatteryEntity( 104 | sensor_description, 105 | entity_name, 106 | device_name, 107 | serial_number, 108 | None, 109 | coordinator, 110 | config_entry.unique_id, 111 | ) 112 | ) 113 | 114 | else: 115 | data = coordinator.data.get(sensor_description.key) 116 | if data is None: 117 | continue 118 | 119 | entity_name = f"{name} {sensor_description.name}" 120 | entities.append( 121 | EnvoyBinaryEntity( 122 | sensor_description, 123 | entity_name, 124 | name, 125 | config_entry.unique_id, 126 | None, 127 | coordinator, 128 | reader, 129 | ) 130 | ) 131 | 132 | async_add_entities(entities) 133 | 134 | 135 | class EnvoyInverterEntity(CoordinatorEntity, BinarySensorEntity): 136 | def __init__( 137 | self, 138 | description, 139 | name, 140 | device_name, 141 | device_serial_number, 142 | serial_number, 143 | coordinator, 144 | ): 145 | self.entity_description = description 146 | self._name = name 147 | self._serial_number = serial_number 148 | self._device_name = device_name 149 | self._device_serial_number = device_serial_number 150 | CoordinatorEntity.__init__(self, coordinator) 151 | 152 | @property 153 | def name(self): 154 | """Return the name of the sensor.""" 155 | return self._name 156 | 157 | @property 158 | def unique_id(self): 159 | """Return the unique id of the sensor.""" 160 | if self._serial_number: 161 | return self._serial_number 162 | if self._device_serial_number: 163 | return f"{self._device_serial_number}_{self.entity_description.key}" 164 | 165 | @property 166 | def extra_state_attributes(self): 167 | """Return the state attributes.""" 168 | try: 169 | if self.coordinator.data.get("inverter_info"): 170 | value = ( 171 | self.coordinator.data.get("inverter_info") 172 | .get(self._device_serial_number) 173 | .get("last_rpt_date") 174 | ) 175 | return { 176 | "last_reported": datetime.datetime.fromtimestamp( 177 | int(value), tz=datetime.timezone.utc 178 | ) 179 | } 180 | except (ValueError, TypeError): 181 | return None 182 | 183 | @property 184 | def device_info(self) -> DeviceInfo or None: 185 | """Return the device_info of the device.""" 186 | if not self._device_serial_number: 187 | return None 188 | 189 | hw_version = ( 190 | self.coordinator.data.get("inverter_info", {}) 191 | .get(self._device_serial_number, {}) 192 | .get("part_num", None) 193 | ) 194 | return DeviceInfo( 195 | identifiers={(DOMAIN, str(self._device_serial_number))}, 196 | manufacturer="Enphase", 197 | model=get_model_name("Inverter", hw_version), 198 | name=self._device_name, 199 | ) 200 | 201 | @property 202 | def is_on(self) -> bool: 203 | """Return the status of the requested attribute.""" 204 | if self.entity_description.key.startswith("inverter_data_"): 205 | return ( 206 | self.coordinator.data.get("inverter_device_data") 207 | .get(self._device_serial_number) 208 | .get(self.entity_description.key[14:]) 209 | ) 210 | if self.entity_description.key.startswith("inverter_info_"): 211 | return ( 212 | self.coordinator.data.get("inverter_info") 213 | .get(self._device_serial_number) 214 | .get(self.entity_description.key[14:]) 215 | ) 216 | 217 | 218 | class EnvoyBaseEntity(CoordinatorEntity): 219 | """Envoy entity""" 220 | 221 | MODEL = "Envoy" 222 | 223 | def __init__( 224 | self, 225 | description, 226 | name, 227 | device_name, 228 | device_serial_number, 229 | serial_number, 230 | coordinator, 231 | parent_device=None, 232 | ): 233 | """Initialize Envoy entity.""" 234 | self.entity_description = description 235 | self._name = name 236 | self._serial_number = serial_number 237 | self._device_name = device_name 238 | self._device_serial_number = device_serial_number 239 | self._parent_device = parent_device 240 | 241 | super().__init__(coordinator) 242 | 243 | @property 244 | def name(self): 245 | """Return the name of the sensor.""" 246 | return self._name 247 | 248 | @property 249 | def unique_id(self): 250 | """Return the unique id of the sensor.""" 251 | if self._serial_number: 252 | return self._serial_number 253 | if self._device_serial_number: 254 | return f"{self._device_serial_number}_{self.entity_description.key}" 255 | 256 | @property 257 | def native_value(self): 258 | """Return the state of the sensor.""" 259 | return self.coordinator.data.get(self.entity_description.key) 260 | 261 | @property 262 | def extra_state_attributes(self): 263 | """Return the state attributes.""" 264 | return None 265 | 266 | @property 267 | def device_info(self) -> DeviceInfo | None: 268 | """Return the device_info of the device.""" 269 | if not self._device_serial_number: 270 | return None 271 | device_info_kw = {} 272 | if self._parent_device: 273 | device_info_kw["via_device"] = (DOMAIN, self._parent_device) 274 | 275 | model_name = self.MODEL 276 | if self.MODEL == "Envoy": 277 | model = self.coordinator.data.get("envoy_info", {}).get("model", "Standard") 278 | model_name = f"Envoy-S {model}" 279 | 280 | elif self.MODEL == "Relay": 281 | info = self.coordinator.data.get("relay_info", {}).get( 282 | self._device_serial_number, {} 283 | ) 284 | device_info_kw["sw_version"] = info.get("img_pnum_running", None) 285 | device_info_kw["hw_version"] = resolve_hardware_id( 286 | info.get("part_num", None) 287 | ) 288 | model_name = get_model_name(model_name, info.get("part_num", None)) 289 | 290 | return DeviceInfo( 291 | identifiers={(DOMAIN, str(self._device_serial_number))}, 292 | manufacturer="Enphase", 293 | model=model_name, 294 | name=self._device_name, 295 | **device_info_kw, 296 | ) 297 | 298 | 299 | class EnvoyBinaryEntity(EnvoyBaseEntity, BinarySensorEntity): 300 | def __init__( 301 | self, 302 | description, 303 | name, 304 | device_name, 305 | device_serial_number, 306 | serial_number, 307 | coordinator, 308 | parent_device=None, 309 | ): 310 | super().__init__( 311 | description=description, 312 | name=name, 313 | device_name=device_name, 314 | device_serial_number=device_serial_number, 315 | serial_number=serial_number, 316 | coordinator=coordinator, 317 | parent_device=parent_device, 318 | ) 319 | 320 | @property 321 | def is_on(self) -> bool | None: 322 | return self.coordinator.data.get(self.entity_description.key) 323 | 324 | 325 | class EnvoyRelayEntity(EnvoyBinaryEntity): 326 | """Envoy relay entity.""" 327 | 328 | @property 329 | def is_on(self) -> bool | None: 330 | if self.coordinator.data.get("relay_info"): 331 | return ( 332 | self.coordinator.data.get("relay_info") 333 | .get(self._device_serial_number) 334 | .get(self.entity_description.key[11:]) 335 | ) 336 | 337 | @property 338 | def extra_state_attributes(self) -> dict | None: 339 | """Return the state attributes.""" 340 | relay_info = self.coordinator.data.get("relay_info").get( 341 | self._device_serial_number 342 | ) 343 | return { 344 | "last_reported": datetime.datetime.fromtimestamp( 345 | int(relay_info.get("last_rpt_date")), tz=datetime.timezone.utc 346 | ), 347 | "reason_code": relay_info.get("reason_code"), 348 | "reason": relay_info.get("reason"), 349 | } 350 | 351 | 352 | class EnvoyRelayContactEntity(EnvoyRelayEntity): 353 | @property 354 | def icon(self): 355 | return "mdi:electric-switch-closed" if self.is_on else "mdi:electric-switch" 356 | 357 | @property 358 | def is_on(self) -> bool | None: 359 | return ( 360 | self.coordinator.data.get("relay_info") 361 | .get(self._device_serial_number) 362 | .get("relay") 363 | == "closed" 364 | ) 365 | 366 | 367 | class EnvoyBatteryEntity(CoordinatorEntity, BinarySensorEntity): 368 | """Envoy battery entity.""" 369 | 370 | def __init__( 371 | self, 372 | description, 373 | name, 374 | device_name, 375 | device_serial_number, 376 | serial_number, 377 | coordinator, 378 | parent_device, 379 | ): 380 | self.entity_description = description 381 | self._name = name 382 | self._serial_number = serial_number 383 | self._device_name = device_name 384 | self._device_serial_number = device_serial_number 385 | self._parent_device = parent_device 386 | CoordinatorEntity.__init__(self, coordinator) 387 | 388 | @property 389 | def name(self): 390 | """Return the name of the sensor.""" 391 | return self._name 392 | 393 | @property 394 | def unique_id(self): 395 | """Return the unique id of the sensor.""" 396 | if self._serial_number: 397 | return self._serial_number 398 | if self._device_serial_number: 399 | return f"{self._device_serial_number}_{self.entity_description.key}" 400 | 401 | @property 402 | def is_on(self) -> bool: 403 | """Return the status of the requested attribute.""" 404 | if self.coordinator.data.get("batteries"): 405 | return ( 406 | self.coordinator.data.get("batteries") 407 | .get(self._device_serial_number) 408 | .get(self.entity_description.key[10:]) 409 | ) 410 | 411 | @property 412 | def extra_state_attributes(self): 413 | """Return the state attributes.""" 414 | if self.coordinator.data.get("batteries"): 415 | battery = self.coordinator.data.get("batteries").get( 416 | self._device_serial_number 417 | ) 418 | return {"last_reported": battery.get("report_date")} 419 | 420 | return None 421 | 422 | @property 423 | def device_info(self) -> DeviceInfo | None: 424 | """Return the device_info of the device.""" 425 | if not self._device_serial_number: 426 | return None 427 | 428 | sw_version = None 429 | hw_version = None 430 | if self.coordinator.data.get("batteries") and self.coordinator.data.get( 431 | "batteries" 432 | ).get(self._device_serial_number): 433 | sw_version = ( 434 | self.coordinator.data.get("batteries") 435 | .get(self._device_serial_number) 436 | .get("img_pnum_running") 437 | ) 438 | hw_version = ( 439 | self.coordinator.data.get("batteries") 440 | .get(self._device_serial_number) 441 | .get("part_num") 442 | ) 443 | 444 | return DeviceInfo( 445 | identifiers={(DOMAIN, str(self._device_serial_number))}, 446 | manufacturer="Enphase", 447 | model=get_model_name("Battery", hw_version), 448 | name=self._device_name, 449 | via_device=(DOMAIN, self._parent_device), 450 | sw_version=sw_version, 451 | hw_version=resolve_hardware_id(hw_version), 452 | ) 453 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_inventory.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "PCU", 4 | "devices": [ 5 | { 6 | "part_num": "800-01736-r02", 7 | "installed": "1670744318", 8 | "serial_num": "999999913010", 9 | "device_status": [ 10 | "envoy.global.ok" 11 | ], 12 | "last_rpt_date": "1689258554", 13 | "admin_state": 2, 14 | "dev_type": 1, 15 | "created_date": "1670744318", 16 | "img_load_date": "1613405093", 17 | "img_pnum_running": "520-00082-r01-v04.27.04", 18 | "ptpn": "540-00135-r01-v04.27.10", 19 | "chaneid": 9999990225, 20 | "device_control": [ 21 | { 22 | "gficlearset": false 23 | } 24 | ], 25 | "producing": true, 26 | "communicating": true, 27 | "provisioned": true, 28 | "operating": false 29 | }, 30 | { 31 | "part_num": "800-01736-r02", 32 | "installed": "1670744318", 33 | "serial_num": "999999913012", 34 | "device_status": [ 35 | "envoy.global.ok" 36 | ], 37 | "last_rpt_date": "1689258404", 38 | "admin_state": 2, 39 | "dev_type": 1, 40 | "created_date": "1670744318", 41 | "img_load_date": "1613405093", 42 | "img_pnum_running": "520-00082-r01-v04.27.04", 43 | "ptpn": "540-00135-r01-v04.27.10", 44 | "chaneid": 9999990481, 45 | "device_control": [ 46 | { 47 | "gficlearset": false 48 | } 49 | ], 50 | "producing": true, 51 | "communicating": true, 52 | "provisioned": true, 53 | "operating": false 54 | }, 55 | { 56 | "part_num": "800-01736-r02", 57 | "installed": "1670744318", 58 | "serial_num": "999999912750", 59 | "device_status": [ 60 | "envoy.global.ok" 61 | ], 62 | "last_rpt_date": "1689258555", 63 | "admin_state": 2, 64 | "dev_type": 1, 65 | "created_date": "1670744318", 66 | "img_load_date": "1613405093", 67 | "img_pnum_running": "520-00082-r01-v04.27.04", 68 | "ptpn": "540-00135-r01-v04.27.10", 69 | "chaneid": 9999990737, 70 | "device_control": [ 71 | { 72 | "gficlearset": false 73 | } 74 | ], 75 | "producing": true, 76 | "communicating": true, 77 | "provisioned": true, 78 | "operating": false 79 | }, 80 | { 81 | "part_num": "800-01736-r02", 82 | "installed": "1670744318", 83 | "serial_num": "999999912983", 84 | "device_status": [ 85 | "envoy.global.ok" 86 | ], 87 | "last_rpt_date": "1689258584", 88 | "admin_state": 2, 89 | "dev_type": 1, 90 | "created_date": "1670744318", 91 | "img_load_date": "1613405093", 92 | "img_pnum_running": "520-00082-r01-v04.27.04", 93 | "ptpn": "540-00135-r01-v04.27.10", 94 | "chaneid": 9999990993, 95 | "device_control": [ 96 | { 97 | "gficlearset": false 98 | } 99 | ], 100 | "producing": true, 101 | "communicating": true, 102 | "provisioned": true, 103 | "operating": false 104 | }, 105 | { 106 | "part_num": "800-01736-r02", 107 | "installed": "1670744319", 108 | "serial_num": "999999908520", 109 | "device_status": [ 110 | "envoy.global.ok" 111 | ], 112 | "last_rpt_date": "1689258526", 113 | "admin_state": 2, 114 | "dev_type": 1, 115 | "created_date": "1670744319", 116 | "img_load_date": "1613405093", 117 | "img_pnum_running": "520-00082-r01-v04.27.04", 118 | "ptpn": "540-00135-r01-v04.27.10", 119 | "chaneid": 9999991249, 120 | "device_control": [ 121 | { 122 | "gficlearset": false 123 | } 124 | ], 125 | "producing": true, 126 | "communicating": true, 127 | "provisioned": true, 128 | "operating": false 129 | }, 130 | { 131 | "part_num": "800-01736-r02", 132 | "installed": "1670744319", 133 | "serial_num": "999999909983", 134 | "device_status": [ 135 | "envoy.global.ok" 136 | ], 137 | "last_rpt_date": "1689258644", 138 | "admin_state": 2, 139 | "dev_type": 1, 140 | "created_date": "1670744319", 141 | "img_load_date": "1613405093", 142 | "img_pnum_running": "520-00082-r01-v04.27.04", 143 | "ptpn": "540-00135-r01-v04.27.10", 144 | "chaneid": 9999991505, 145 | "device_control": [ 146 | { 147 | "gficlearset": false 148 | } 149 | ], 150 | "producing": true, 151 | "communicating": true, 152 | "provisioned": true, 153 | "operating": false 154 | }, 155 | { 156 | "part_num": "800-01736-r02", 157 | "installed": "1670744319", 158 | "serial_num": "999999908521", 159 | "device_status": [ 160 | "envoy.global.ok" 161 | ], 162 | "last_rpt_date": "1689258615", 163 | "admin_state": 2, 164 | "dev_type": 1, 165 | "created_date": "1670744319", 166 | "img_load_date": "1613405093", 167 | "img_pnum_running": "520-00082-r01-v04.27.04", 168 | "ptpn": "540-00135-r01-v04.27.10", 169 | "chaneid": 9999991761, 170 | "device_control": [ 171 | { 172 | "gficlearset": false 173 | } 174 | ], 175 | "producing": true, 176 | "communicating": true, 177 | "provisioned": true, 178 | "operating": false 179 | }, 180 | { 181 | "part_num": "800-01736-r02", 182 | "installed": "1670744319", 183 | "serial_num": "999999912669", 184 | "device_status": [ 185 | "envoy.global.ok" 186 | ], 187 | "last_rpt_date": "1689258617", 188 | "admin_state": 2, 189 | "dev_type": 1, 190 | "created_date": "1670744319", 191 | "img_load_date": "1613405093", 192 | "img_pnum_running": "520-00082-r01-v04.27.04", 193 | "ptpn": "540-00135-r01-v04.27.10", 194 | "chaneid": 9999992017, 195 | "device_control": [ 196 | { 197 | "gficlearset": false 198 | } 199 | ], 200 | "producing": true, 201 | "communicating": true, 202 | "provisioned": true, 203 | "operating": false 204 | }, 205 | { 206 | "part_num": "800-01736-r02", 207 | "installed": "1670744319", 208 | "serial_num": "999999913748", 209 | "device_status": [ 210 | "envoy.global.ok" 211 | ], 212 | "last_rpt_date": "1689258619", 213 | "admin_state": 2, 214 | "dev_type": 1, 215 | "created_date": "1670744319", 216 | "img_load_date": "1613405093", 217 | "img_pnum_running": "520-00082-r01-v04.27.04", 218 | "ptpn": "540-00135-r01-v04.27.10", 219 | "chaneid": 9999992273, 220 | "device_control": [ 221 | { 222 | "gficlearset": false 223 | } 224 | ], 225 | "producing": true, 226 | "communicating": true, 227 | "provisioned": true, 228 | "operating": false 229 | }, 230 | { 231 | "part_num": "800-01736-r02", 232 | "installed": "1670744319", 233 | "serial_num": "999999909985", 234 | "device_status": [ 235 | "envoy.global.ok" 236 | ], 237 | "last_rpt_date": "1689258620", 238 | "admin_state": 2, 239 | "dev_type": 1, 240 | "created_date": "1670744319", 241 | "img_load_date": "1613405093", 242 | "img_pnum_running": "520-00082-r01-v04.27.04", 243 | "ptpn": "540-00135-r01-v04.27.10", 244 | "chaneid": 9999992529, 245 | "device_control": [ 246 | { 247 | "gficlearset": false 248 | } 249 | ], 250 | "producing": true, 251 | "communicating": true, 252 | "provisioned": true, 253 | "operating": false 254 | }, 255 | { 256 | "part_num": "800-01736-r02", 257 | "installed": "1670744319", 258 | "serial_num": "999999915285", 259 | "device_status": [ 260 | "envoy.global.ok" 261 | ], 262 | "last_rpt_date": "1689258345", 263 | "admin_state": 2, 264 | "dev_type": 1, 265 | "created_date": "1670744319", 266 | "img_load_date": "1613405093", 267 | "img_pnum_running": "520-00082-r01-v04.27.04", 268 | "ptpn": "540-00135-r01-v04.27.10", 269 | "chaneid": 9999992785, 270 | "device_control": [ 271 | { 272 | "gficlearset": false 273 | } 274 | ], 275 | "producing": true, 276 | "communicating": true, 277 | "provisioned": true, 278 | "operating": false 279 | }, 280 | { 281 | "part_num": "800-01736-r02", 282 | "installed": "1670744320", 283 | "serial_num": "999999915246", 284 | "device_status": [ 285 | "envoy.global.ok" 286 | ], 287 | "last_rpt_date": "1689258405", 288 | "admin_state": 2, 289 | "dev_type": 1, 290 | "created_date": "1670744320", 291 | "img_load_date": "1613405093", 292 | "img_pnum_running": "520-00082-r01-v04.27.04", 293 | "ptpn": "540-00135-r01-v04.27.10", 294 | "chaneid": 9999993041, 295 | "device_control": [ 296 | { 297 | "gficlearset": false 298 | } 299 | ], 300 | "producing": true, 301 | "communicating": true, 302 | "provisioned": true, 303 | "operating": false 304 | }, 305 | { 306 | "part_num": "800-01736-r02", 307 | "installed": "1670744320", 308 | "serial_num": "999999912590", 309 | "device_status": [ 310 | "envoy.global.ok" 311 | ], 312 | "last_rpt_date": "1689258346", 313 | "admin_state": 2, 314 | "dev_type": 1, 315 | "created_date": "1670744320", 316 | "img_load_date": "1613405093", 317 | "img_pnum_running": "520-00082-r01-v04.27.04", 318 | "ptpn": "540-00135-r01-v04.27.10", 319 | "chaneid": 9999993297, 320 | "device_control": [ 321 | { 322 | "gficlearset": false 323 | } 324 | ], 325 | "producing": true, 326 | "communicating": true, 327 | "provisioned": true, 328 | "operating": false 329 | }, 330 | { 331 | "part_num": "800-01736-r02", 332 | "installed": "1670746114", 333 | "serial_num": "999999910862", 334 | "device_status": [ 335 | "envoy.global.ok" 336 | ], 337 | "last_rpt_date": "1689258621", 338 | "admin_state": 1, 339 | "dev_type": 1, 340 | "created_date": "1670746114", 341 | "img_load_date": "1613405093", 342 | "img_pnum_running": "520-00082-r01-v04.27.04", 343 | "ptpn": "540-00135-r01-v04.27.10", 344 | "chaneid": 9999993553, 345 | "device_control": [ 346 | { 347 | "gficlearset": false 348 | } 349 | ], 350 | "producing": true, 351 | "communicating": true, 352 | "provisioned": true, 353 | "operating": false 354 | } 355 | ] 356 | }, 357 | { 358 | "type": "ACB", 359 | "devices": [] 360 | }, 361 | { 362 | "type": "NSRB", 363 | "devices": [ 364 | { 365 | "part_num": "800-00598-r04", 366 | "installed": "1670706046", 367 | "serial_num": "999999968177", 368 | "device_status": [ 369 | "envoy.global.ok" 370 | ], 371 | "last_rpt_date": "1689258645", 372 | "admin_state": 1, 373 | "dev_type": 12, 374 | "created_date": "1670706046", 375 | "img_load_date": "1522376696", 376 | "img_pnum_running": "520-00086-r01-v02.12.07", 377 | "ptpn": "540-00139-r01-v02.12.00", 378 | "chaneid": 9999999601, 379 | "device_control": [ 380 | { 381 | "gficlearset": false 382 | } 383 | ], 384 | "producing": false, 385 | "communicating": true, 386 | "provisioned": true, 387 | "operating": true, 388 | "relay": "closed", 389 | "reason_code": -1, 390 | "reason": "ok", 391 | "line-count": 1, 392 | "line1-connected": true, 393 | "line2-connected": false, 394 | "line3-connected": false 395 | }, 396 | { 397 | "part_num": "800-00598-r04", 398 | "installed": "1671018504", 399 | "serial_num": "999999935944", 400 | "device_status": [ 401 | "envoy.global.ok" 402 | ], 403 | "last_rpt_date": "1689258646", 404 | "admin_state": 1, 405 | "dev_type": 12, 406 | "created_date": "1671018504", 407 | "img_load_date": "1522376696", 408 | "img_pnum_running": "520-00086-r01-v02.12.07", 409 | "ptpn": "540-00139-r01-v02.12.00", 410 | "chaneid": 9999999857, 411 | "device_control": [ 412 | { 413 | "gficlearset": false 414 | } 415 | ], 416 | "producing": false, 417 | "communicating": true, 418 | "provisioned": true, 419 | "operating": true, 420 | "relay": "closed", 421 | "reason_code": -1, 422 | "reason": "ok", 423 | "line-count": 1, 424 | "line1-connected": true, 425 | "line2-connected": false, 426 | "line3-connected": false 427 | } 428 | ] 429 | }, 430 | { 431 | "type": "ESUB", 432 | "devices": [] 433 | } 434 | ] 435 | -------------------------------------------------------------------------------- /test_data/envoy_metered/endpoint_device_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "553648384": { 3 | "devName": "pcu", 4 | "sn": "999999913010", 5 | "active": true, 6 | "modGone": false, 7 | "channels": [ 8 | { 9 | "chanEid": 1627390225, 10 | "created": 1738574464, 11 | "wattHours": { 12 | "today": 59, 13 | "yesterday": 1072, 14 | "week": 4453 15 | }, 16 | "watts": { 17 | "now": 104, 18 | "nowUsed": 0, 19 | "max": 220 20 | }, 21 | "lastReading": { 22 | "eid": 1627390225, 23 | "interval_type": 0, 24 | "endDate": 1738574464, 25 | "duration": 908, 26 | "flags": 0, 27 | "flags_hex": "0x0000000000000000", 28 | "joulesProduced": 94725, 29 | "acVoltageINmV": 233125, 30 | "acFrequencyINmHz": 49982, 31 | "dcVoltageINmV": 36203, 32 | "dcCurrentINmA": 3398, 33 | "channelTemp": 8, 34 | "pwrConvErrSecs": 0, 35 | "pwrConvMaxErrCycles": 0, 36 | "joulesUsed": 0, 37 | "leadingVArs": 76, 38 | "laggingVArs": 5, 39 | "acCurrentInmA": 514, 40 | "l1NAcVoltageInmV": 0, 41 | "l2NAcVoltageInmV": 0, 42 | "l3NAcVoltageInmV": 0, 43 | "rssi": 104, 44 | "issi": 66 45 | }, 46 | "lifetime": { 47 | "createdTime": 1734527480, 48 | "duration": 61878313, 49 | "joulesProduced": 2709931725 50 | } 51 | } 52 | ] 53 | }, 54 | "553648640": { 55 | "devName": "pcu", 56 | "sn": "999999913012", 57 | "active": true, 58 | "modGone": false, 59 | "channels": [ 60 | { 61 | "chanEid": 1627390481, 62 | "created": 1738574433, 63 | "wattHours": { 64 | "today": 57, 65 | "yesterday": 1110, 66 | "week": 4637 67 | }, 68 | "watts": { 69 | "now": 103, 70 | "nowUsed": 0, 71 | "max": 218 72 | }, 73 | "lastReading": { 74 | "eid": 1627390481, 75 | "interval_type": 0, 76 | "endDate": 1738574433, 77 | "duration": 907, 78 | "flags": 0, 79 | "flags_hex": "0x0000000000000000", 80 | "joulesProduced": 93150, 81 | "acVoltageINmV": 232906, 82 | "acFrequencyINmHz": 49983, 83 | "dcVoltageINmV": 36543, 84 | "dcCurrentINmA": 3320, 85 | "channelTemp": 8, 86 | "pwrConvErrSecs": 0, 87 | "pwrConvMaxErrCycles": 0, 88 | "joulesUsed": 0, 89 | "leadingVArs": 74, 90 | "laggingVArs": 6, 91 | "acCurrentInmA": 493, 92 | "l1NAcVoltageInmV": 0, 93 | "l2NAcVoltageInmV": 0, 94 | "l3NAcVoltageInmV": 0, 95 | "rssi": 104, 96 | "issi": 62 97 | }, 98 | "lifetime": { 99 | "createdTime": 1734527481, 100 | "duration": 61880666, 101 | "joulesProduced": 2817729225 102 | } 103 | } 104 | ] 105 | }, 106 | "553648896": { 107 | "devName": "pcu", 108 | "sn": "999999912750", 109 | "active": true, 110 | "modGone": false, 111 | "channels": [ 112 | { 113 | "chanEid": 1627390737, 114 | "created": 1738574675, 115 | "wattHours": { 116 | "today": 27, 117 | "yesterday": 1003, 118 | "week": 4344 119 | }, 120 | "watts": { 121 | "now": 27, 122 | "nowUsed": 0, 123 | "max": 218 124 | }, 125 | "lastReading": { 126 | "eid": 1627390737, 127 | "interval_type": 0, 128 | "endDate": 1738574675, 129 | "duration": 908, 130 | "flags": 0, 131 | "flags_hex": "0x0000000000000000", 132 | "joulesProduced": 24075, 133 | "acVoltageINmV": 231531, 134 | "acFrequencyINmHz": 50010, 135 | "dcVoltageINmV": 38043, 136 | "dcCurrentINmA": 918, 137 | "channelTemp": 5, 138 | "pwrConvErrSecs": 0, 139 | "pwrConvMaxErrCycles": 0, 140 | "joulesUsed": 0, 141 | "leadingVArs": 76, 142 | "laggingVArs": 6, 143 | "acCurrentInmA": 187, 144 | "l1NAcVoltageInmV": 0, 145 | "l2NAcVoltageInmV": 0, 146 | "l3NAcVoltageInmV": 0, 147 | "rssi": 104, 148 | "issi": 62 149 | }, 150 | "lifetime": { 151 | "createdTime": 1734527482, 152 | "duration": 61877272, 153 | "joulesProduced": 2791171125 154 | } 155 | } 156 | ] 157 | }, 158 | "553649152": { 159 | "devName": "pcu", 160 | "sn": "999999912983", 161 | "active": true, 162 | "modGone": false, 163 | "channels": [ 164 | { 165 | "chanEid": 1627390993, 166 | "created": 1738574494, 167 | "wattHours": { 168 | "today": 58, 169 | "yesterday": 1087, 170 | "week": 4582 171 | }, 172 | "watts": { 173 | "now": 102, 174 | "nowUsed": 0, 175 | "max": 219 176 | }, 177 | "lastReading": { 178 | "eid": 1627390993, 179 | "interval_type": 0, 180 | "endDate": 1738574494, 181 | "duration": 908, 182 | "flags": 0, 183 | "flags_hex": "0x0000000000000000", 184 | "joulesProduced": 92700, 185 | "acVoltageINmV": 232844, 186 | "acFrequencyINmHz": 49996, 187 | "dcVoltageINmV": 36328, 188 | "dcCurrentINmA": 3340, 189 | "channelTemp": 8, 190 | "pwrConvErrSecs": 0, 191 | "pwrConvMaxErrCycles": 0, 192 | "joulesUsed": 0, 193 | "leadingVArs": 75, 194 | "laggingVArs": 6, 195 | "acCurrentInmA": 502, 196 | "l1NAcVoltageInmV": 0, 197 | "l2NAcVoltageInmV": 0, 198 | "l3NAcVoltageInmV": 0, 199 | "rssi": 104, 200 | "issi": 62 201 | }, 202 | "lifetime": { 203 | "createdTime": 1734527483, 204 | "duration": 61878139, 205 | "joulesProduced": 2799610875 206 | } 207 | } 208 | ] 209 | }, 210 | "553649408": { 211 | "devName": "pcu", 212 | "sn": "999999908520", 213 | "active": true, 214 | "modGone": false, 215 | "channels": [ 216 | { 217 | "chanEid": 1627391249, 218 | "created": 1738574466, 219 | "wattHours": { 220 | "today": 58, 221 | "yesterday": 1105, 222 | "week": 4573 223 | }, 224 | "watts": { 225 | "now": 102, 226 | "nowUsed": 0, 227 | "max": 217 228 | }, 229 | "lastReading": { 230 | "eid": 1627391249, 231 | "interval_type": 0, 232 | "endDate": 1738574466, 233 | "duration": 909, 234 | "flags": 0, 235 | "flags_hex": "0x0000000000000000", 236 | "joulesProduced": 92925, 237 | "acVoltageINmV": 233563, 238 | "acFrequencyINmHz": 49978, 239 | "dcVoltageINmV": 36082, 240 | "dcCurrentINmA": 3359, 241 | "channelTemp": 8, 242 | "pwrConvErrSecs": 0, 243 | "pwrConvMaxErrCycles": 0, 244 | "joulesUsed": 0, 245 | "leadingVArs": 75, 246 | "laggingVArs": 5, 247 | "acCurrentInmA": 488, 248 | "l1NAcVoltageInmV": 0, 249 | "l2NAcVoltageInmV": 0, 250 | "l3NAcVoltageInmV": 0, 251 | "rssi": 104, 252 | "issi": 64 253 | }, 254 | "lifetime": { 255 | "createdTime": 1734527484, 256 | "duration": 61879155, 257 | "joulesProduced": 2794654800 258 | } 259 | } 260 | ] 261 | }, 262 | "553649664": { 263 | "devName": "pcu", 264 | "sn": "999999909983", 265 | "active": true, 266 | "modGone": false, 267 | "channels": [ 268 | { 269 | "chanEid": 1627391505, 270 | "created": 1738574525, 271 | "wattHours": { 272 | "today": 26, 273 | "yesterday": 1027, 274 | "week": 4399 275 | }, 276 | "watts": { 277 | "now": 30, 278 | "nowUsed": 0, 279 | "max": 220 280 | }, 281 | "lastReading": { 282 | "eid": 1627391505, 283 | "interval_type": 0, 284 | "endDate": 1738574525, 285 | "duration": 908, 286 | "flags": 0, 287 | "flags_hex": "0x0000000000000000", 288 | "joulesProduced": 27675, 289 | "acVoltageINmV": 232594, 290 | "acFrequencyINmHz": 49993, 291 | "dcVoltageINmV": 39109, 292 | "dcCurrentINmA": 1504, 293 | "channelTemp": 5, 294 | "pwrConvErrSecs": 0, 295 | "pwrConvMaxErrCycles": 0, 296 | "joulesUsed": 0, 297 | "leadingVArs": 74, 298 | "laggingVArs": 5, 299 | "acCurrentInmA": 263, 300 | "l1NAcVoltageInmV": 0, 301 | "l2NAcVoltageInmV": 0, 302 | "l3NAcVoltageInmV": 0, 303 | "rssi": 104, 304 | "issi": 60 305 | }, 306 | "lifetime": { 307 | "createdTime": 1734527510, 308 | "duration": 61874765, 309 | "joulesProduced": 2754079200 310 | } 311 | } 312 | ] 313 | }, 314 | "553649920": { 315 | "devName": "pcu", 316 | "sn": "999999908521", 317 | "active": true, 318 | "modGone": false, 319 | "channels": [ 320 | { 321 | "chanEid": 1627391761, 322 | "created": 1738574526, 323 | "wattHours": { 324 | "today": 60, 325 | "yesterday": 1117, 326 | "week": 4651 327 | }, 328 | "watts": { 329 | "now": 104, 330 | "nowUsed": 0, 331 | "max": 222 332 | }, 333 | "lastReading": { 334 | "eid": 1627391761, 335 | "interval_type": 0, 336 | "endDate": 1738574526, 337 | "duration": 909, 338 | "flags": 0, 339 | "flags_hex": "0x0000000000000000", 340 | "joulesProduced": 94725, 341 | "acVoltageINmV": 232938, 342 | "acFrequencyINmHz": 49997, 343 | "dcVoltageINmV": 36250, 344 | "dcCurrentINmA": 3426, 345 | "channelTemp": 8, 346 | "pwrConvErrSecs": 0, 347 | "pwrConvMaxErrCycles": 0, 348 | "joulesUsed": 0, 349 | "leadingVArs": 74, 350 | "laggingVArs": 6, 351 | "acCurrentInmA": 506, 352 | "l1NAcVoltageInmV": 0, 353 | "l2NAcVoltageInmV": 0, 354 | "l3NAcVoltageInmV": 0, 355 | "rssi": 104, 356 | "issi": 62 357 | }, 358 | "lifetime": { 359 | "createdTime": 1734527511, 360 | "duration": 61878799, 361 | "joulesProduced": 2812178475 362 | } 363 | } 364 | ] 365 | }, 366 | "553650176": { 367 | "devName": "pcu", 368 | "sn": "999999912669", 369 | "active": true, 370 | "modGone": false, 371 | "channels": [ 372 | { 373 | "chanEid": 1627392017, 374 | "created": 1738574467, 375 | "wattHours": { 376 | "today": 43, 377 | "yesterday": 1060, 378 | "week": 4507 379 | }, 380 | "watts": { 381 | "now": 86, 382 | "nowUsed": 0, 383 | "max": 219 384 | }, 385 | "lastReading": { 386 | "eid": 1627392017, 387 | "interval_type": 0, 388 | "endDate": 1738574467, 389 | "duration": 909, 390 | "flags": 0, 391 | "flags_hex": "0x0000000000000000", 392 | "joulesProduced": 77850, 393 | "acVoltageINmV": 233438, 394 | "acFrequencyINmHz": 49980, 395 | "dcVoltageINmV": 36277, 396 | "dcCurrentINmA": 3285, 397 | "channelTemp": 7, 398 | "pwrConvErrSecs": 0, 399 | "pwrConvMaxErrCycles": 0, 400 | "joulesUsed": 0, 401 | "leadingVArs": 75, 402 | "laggingVArs": 6, 403 | "acCurrentInmA": 501, 404 | "l1NAcVoltageInmV": 0, 405 | "l2NAcVoltageInmV": 0, 406 | "l3NAcVoltageInmV": 0, 407 | "rssi": 104, 408 | "issi": 64 409 | }, 410 | "lifetime": { 411 | "createdTime": 1734527512, 412 | "duration": 61877649, 413 | "joulesProduced": 2773026225 414 | } 415 | } 416 | ] 417 | }, 418 | "553650432": { 419 | "devName": "pcu", 420 | "sn": "999999913748", 421 | "active": true, 422 | "modGone": false, 423 | "channels": [ 424 | { 425 | "chanEid": 1627392273, 426 | "created": 1738574434, 427 | "wattHours": { 428 | "today": 56, 429 | "yesterday": 1090, 430 | "week": 4601 431 | }, 432 | "watts": { 433 | "now": 102, 434 | "nowUsed": 0, 435 | "max": 218 436 | }, 437 | "lastReading": { 438 | "eid": 1627392273, 439 | "interval_type": 0, 440 | "endDate": 1738574434, 441 | "duration": 907, 442 | "flags": 0, 443 | "flags_hex": "0x0000000000000000", 444 | "joulesProduced": 92700, 445 | "acVoltageINmV": 232688, 446 | "acFrequencyINmHz": 49978, 447 | "dcVoltageINmV": 36238, 448 | "dcCurrentINmA": 3316, 449 | "channelTemp": 8, 450 | "pwrConvErrSecs": 0, 451 | "pwrConvMaxErrCycles": 0, 452 | "joulesUsed": 0, 453 | "leadingVArs": 73, 454 | "laggingVArs": 6, 455 | "acCurrentInmA": 502, 456 | "l1NAcVoltageInmV": 0, 457 | "l2NAcVoltageInmV": 0, 458 | "l3NAcVoltageInmV": 0, 459 | "rssi": 104, 460 | "issi": 62 461 | }, 462 | "lifetime": { 463 | "createdTime": 1734527524, 464 | "duration": 61879478, 465 | "joulesProduced": 2813389650 466 | } 467 | } 468 | ] 469 | }, 470 | "553650688": { 471 | "devName": "pcu", 472 | "sn": "999999909985", 473 | "active": true, 474 | "modGone": false, 475 | "channels": [ 476 | { 477 | "chanEid": 1627392529, 478 | "created": 1738574526, 479 | "wattHours": { 480 | "today": 54, 481 | "yesterday": 1072, 482 | "week": 4554 483 | }, 484 | "watts": { 485 | "now": 101, 486 | "nowUsed": 0, 487 | "max": 218 488 | }, 489 | "lastReading": { 490 | "eid": 1627392529, 491 | "interval_type": 0, 492 | "endDate": 1738574526, 493 | "duration": 908, 494 | "flags": 0, 495 | "flags_hex": "0x0000000000000000", 496 | "joulesProduced": 91350, 497 | "acVoltageINmV": 231406, 498 | "acFrequencyINmHz": 49987, 499 | "dcVoltageINmV": 36191, 500 | "dcCurrentINmA": 3344, 501 | "channelTemp": 8, 502 | "pwrConvErrSecs": 0, 503 | "pwrConvMaxErrCycles": 0, 504 | "joulesUsed": 0, 505 | "leadingVArs": 30, 506 | "laggingVArs": 1, 507 | "acCurrentInmA": 501, 508 | "l1NAcVoltageInmV": 0, 509 | "l2NAcVoltageInmV": 0, 510 | "l3NAcVoltageInmV": 0, 511 | "rssi": 104, 512 | "issi": 64 513 | }, 514 | "lifetime": { 515 | "createdTime": 1734527525, 516 | "duration": 61879181, 517 | "joulesProduced": 2806788825 518 | } 519 | } 520 | ] 521 | }, 522 | "553650944": { 523 | "devName": "pcu", 524 | "sn": "999999915285", 525 | "active": true, 526 | "modGone": false, 527 | "channels": [ 528 | { 529 | "chanEid": 1627392785, 530 | "created": 1738574528, 531 | "wattHours": { 532 | "today": 24, 533 | "yesterday": 998, 534 | "week": 4301 535 | }, 536 | "watts": { 537 | "now": 23, 538 | "nowUsed": 0, 539 | "max": 221 540 | }, 541 | "lastReading": { 542 | "eid": 1627392785, 543 | "interval_type": 0, 544 | "endDate": 1738574528, 545 | "duration": 909, 546 | "flags": 0, 547 | "flags_hex": "0x0000000000000000", 548 | "joulesProduced": 21150, 549 | "acVoltageINmV": 231531, 550 | "acFrequencyINmHz": 49986, 551 | "dcVoltageINmV": 35961, 552 | "dcCurrentINmA": 832, 553 | "channelTemp": 5, 554 | "pwrConvErrSecs": 0, 555 | "pwrConvMaxErrCycles": 0, 556 | "joulesUsed": 0, 557 | "leadingVArs": 77, 558 | "laggingVArs": 6, 559 | "acCurrentInmA": 165, 560 | "l1NAcVoltageInmV": 0, 561 | "l2NAcVoltageInmV": 0, 562 | "l3NAcVoltageInmV": 0, 563 | "rssi": 104, 564 | "issi": 62 565 | }, 566 | "lifetime": { 567 | "createdTime": 1734527526, 568 | "duration": 61877033, 569 | "joulesProduced": 2760636825 570 | } 571 | } 572 | ] 573 | }, 574 | "553651200": { 575 | "devName": "pcu", 576 | "sn": "999999915246", 577 | "active": true, 578 | "modGone": false, 579 | "channels": [ 580 | { 581 | "chanEid": 1627393041, 582 | "created": 1738574554, 583 | "wattHours": { 584 | "today": 39, 585 | "yesterday": 1037, 586 | "week": 4434 587 | }, 588 | "watts": { 589 | "now": 74, 590 | "nowUsed": 0, 591 | "max": 216 592 | }, 593 | "lastReading": { 594 | "eid": 1627393041, 595 | "interval_type": 0, 596 | "endDate": 1738574554, 597 | "duration": 907, 598 | "flags": 0, 599 | "flags_hex": "0x0000000000000000", 600 | "joulesProduced": 67500, 601 | "acVoltageINmV": 232594, 602 | "acFrequencyINmHz": 49989, 603 | "dcVoltageINmV": 36750, 604 | "dcCurrentINmA": 3195, 605 | "channelTemp": 6, 606 | "pwrConvErrSecs": 0, 607 | "pwrConvMaxErrCycles": 0, 608 | "joulesUsed": 0, 609 | "leadingVArs": 74, 610 | "laggingVArs": 6, 611 | "acCurrentInmA": 477, 612 | "l1NAcVoltageInmV": 0, 613 | "l2NAcVoltageInmV": 0, 614 | "l3NAcVoltageInmV": 0, 615 | "rssi": 104, 616 | "issi": 64 617 | }, 618 | "lifetime": { 619 | "createdTime": 1734527527, 620 | "duration": 61282278, 621 | "joulesProduced": 2764114650 622 | } 623 | } 624 | ] 625 | }, 626 | "738197760": { 627 | "devName": "nsrb", 628 | "sn": "999999968177", 629 | "active": true, 630 | "modGone": false, 631 | "channels": [ 632 | { 633 | "chanEid": 1811939601, 634 | "created": 1738573619, 635 | "wattHours": { 636 | "today": 0, 637 | "yesterday": 0, 638 | "week": 0 639 | }, 640 | "watts": { 641 | "now": 0, 642 | "nowUsed": 0, 643 | "max": 0 644 | }, 645 | "lastReading": { 646 | "eid": 1811939601, 647 | "interval_type": 0, 648 | "endDate": 1738573619, 649 | "duration": 2721, 650 | "flags": 16, 651 | "flags_hex": "0x0000000000000010", 652 | "temperature": 21, 653 | "acCurrOffset": -48, 654 | "VrmsL1N": 231220, 655 | "VrmsL2N": 1640, 656 | "VrmsL3N": 0, 657 | "freqInmHz": 49976, 658 | "stateChngCnt": 1, 659 | "vrmsB1L12_or_L12": 0, 660 | "vrmsB2L12_or_L23": 0, 661 | "vrmsL31": 0 662 | }, 663 | "lifetime": {} 664 | } 665 | ] 666 | }, 667 | "deviceCount": 13, 668 | "deviceDataLimit": 50 669 | } -------------------------------------------------------------------------------- /custom_components/enphase_envoy/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Enphase Envoy solar energy monitor.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import logging 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | from homeassistant.components.sensor import SensorEntity 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import CONF_HOST 13 | from homeassistant.core import HomeAssistant, callback 14 | from homeassistant.helpers.entity import DeviceInfo 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 17 | 18 | from .const import ( 19 | COORDINATOR, 20 | DOMAIN, 21 | NAME, 22 | SENSORS, 23 | PHASE_SENSORS, 24 | LIVE_UPDATEABLE_ENTITIES, 25 | ENABLE_ADDITIONAL_METRICS, 26 | ADDITIONAL_METRICS, 27 | BATTERY_STATE_MAPPING, 28 | resolve_hardware_id, 29 | get_model_name, 30 | ) 31 | 32 | 33 | async def async_setup_entry( 34 | hass: HomeAssistant, 35 | config_entry: ConfigEntry, 36 | async_add_entities: AddEntitiesCallback, 37 | ) -> None: 38 | """Set up envoy sensor platform.""" 39 | data = hass.data[DOMAIN][config_entry.entry_id] 40 | coordinator = data[COORDINATOR] 41 | name = data[NAME] 42 | live_entities = data[LIVE_UPDATEABLE_ENTITIES] 43 | options = config_entry.options 44 | 45 | entities = [] 46 | _LOGGER.debug("Setting up Sensors") 47 | for sensor_description in SENSORS: 48 | if not options.get(ENABLE_ADDITIONAL_METRICS, False): 49 | if sensor_description.key in ADDITIONAL_METRICS: 50 | continue 51 | if sensor_description.key == "inverter_pcu_communication_level": 52 | if coordinator.data.get("pcu_availability"): 53 | for serial_number in coordinator.data["inverter_device_data"]: 54 | device_name = f"Inverter {serial_number}" 55 | entities.append( 56 | EnvoyInverterSignalEntity( 57 | description=sensor_description, 58 | name=f"{device_name} {sensor_description.name}", 59 | device_name=device_name, 60 | device_serial_number=serial_number, 61 | serial_number=None, 62 | coordinator=coordinator, 63 | parent_device=config_entry.unique_id, 64 | ) 65 | ) 66 | 67 | elif sensor_description.key == "relay_pcu_communication_level": 68 | if coordinator.data.get("relay_device_data") and coordinator.data.get( 69 | "pcu_availability" 70 | ): 71 | for serial_number in coordinator.data["relay_device_data"].keys(): 72 | device_name = f"Relay {serial_number}" 73 | entities.append( 74 | EnvoyRelaySignalEntity( 75 | description=sensor_description, 76 | name=f"{device_name} {sensor_description.name}", 77 | device_name=device_name, 78 | device_serial_number=serial_number, 79 | serial_number=None, 80 | coordinator=coordinator, 81 | parent_device=config_entry.unique_id, 82 | ) 83 | ) 84 | 85 | elif sensor_description.key == "inverter_data_watts": 86 | if coordinator.data.get("inverter_production"): 87 | for inverter in coordinator.data["inverter_production"].keys(): 88 | device_name = f"Inverter {inverter}" 89 | serial_number = inverter 90 | entities.append( 91 | EnvoyInverterEntity( 92 | description=sensor_description, 93 | name=f"{device_name} {sensor_description.name}", 94 | device_name=device_name, 95 | device_serial_number=serial_number, 96 | serial_number=None, 97 | coordinator=coordinator, 98 | parent_device=config_entry.unique_id, 99 | ) 100 | ) 101 | 102 | elif sensor_description.key.startswith("inverter_data_"): 103 | if coordinator.data.get("inverter_device_data"): 104 | for inverter in coordinator.data["inverter_device_data"].keys(): 105 | if ( 106 | coordinator.data["inverter_device_data"][inverter].get( 107 | sensor_description.key[14:] 108 | ) 109 | is not None 110 | ): 111 | device_name = f"Inverter {inverter}" 112 | serial_number = inverter 113 | entities.append( 114 | EnvoyInverterEntity( 115 | description=sensor_description, 116 | name=f"{device_name} {sensor_description.name}", 117 | device_name=device_name, 118 | device_serial_number=serial_number, 119 | serial_number=None, 120 | coordinator=coordinator, 121 | parent_device=config_entry.unique_id, 122 | ) 123 | ) 124 | 125 | elif sensor_description.key.startswith("inverter_info_"): 126 | if coordinator.data.get("inverter_info"): 127 | for inverter in coordinator.data["inverter_info"].keys(): 128 | device_name = f"Inverter {inverter}" 129 | serial_number = inverter 130 | entities.append( 131 | EnvoyInverterEntity( 132 | description=sensor_description, 133 | name=f"{device_name} {sensor_description.name}", 134 | device_name=device_name, 135 | device_serial_number=serial_number, 136 | serial_number=None, 137 | coordinator=coordinator, 138 | parent_device=config_entry.unique_id, 139 | ) 140 | ) 141 | 142 | elif sensor_description.key.startswith("relay_data_"): 143 | if coordinator.data.get("relay_device_data"): 144 | for relay in coordinator.data["relay_device_data"].keys(): 145 | if ( 146 | coordinator.data["relay_device_data"][relay].get( 147 | sensor_description.key[11:] 148 | ) 149 | is not None 150 | ): 151 | device_name = f"Relay {relay}" 152 | serial_number = relay 153 | 154 | if sensor_description.key.endswith(("l1", "l2", "l3")): 155 | line = sensor_description.key[-2:].replace("l", "line") 156 | line_connected = ( 157 | coordinator.data.get("relay_info", {}) 158 | .get(relay, {}) 159 | .get(f"{line}-connected") 160 | ) 161 | if line_connected is False: 162 | continue 163 | 164 | entities.append( 165 | EnvoyRelayEntity( 166 | description=sensor_description, 167 | name=f"{device_name} {sensor_description.name}", 168 | device_name=device_name, 169 | device_serial_number=serial_number, 170 | serial_number=None, 171 | coordinator=coordinator, 172 | parent_device=config_entry.unique_id, 173 | ) 174 | ) 175 | 176 | elif sensor_description.key.startswith("relay_info_"): 177 | if coordinator.data.get("relay_info"): 178 | for relay in coordinator.data["relay_info"].keys(): 179 | device_name = f"Relay {relay}" 180 | serial_number = relay 181 | entities.append( 182 | EnvoyRelayEntity( 183 | description=sensor_description, 184 | name=f"{device_name} {sensor_description.name}", 185 | device_name=device_name, 186 | device_serial_number=serial_number, 187 | serial_number=None, 188 | coordinator=coordinator, 189 | parent_device=config_entry.unique_id, 190 | ) 191 | ) 192 | 193 | elif sensor_description.key == "batteries_software": 194 | if coordinator.data.get("batteries"): 195 | for battery in coordinator.data["batteries"].keys(): 196 | device_name = f"Battery {battery}" 197 | serial_number = battery 198 | entities.append( 199 | EnvoyBatteryFirmwareEntity( 200 | description=sensor_description, 201 | name=f"{device_name} {sensor_description.name}", 202 | device_name=device_name, 203 | device_serial_number=serial_number, 204 | serial_number=None, 205 | coordinator=coordinator, 206 | parent_device=config_entry.unique_id, 207 | ) 208 | ) 209 | 210 | elif sensor_description.key.startswith("batteries_"): 211 | if coordinator.data.get("batteries"): 212 | for battery in coordinator.data["batteries"].keys(): 213 | device_name = f"Battery {battery}" 214 | serial_number = battery 215 | entities.append( 216 | EnvoyBatteryEntity( 217 | description=sensor_description, 218 | name=f"{device_name} {sensor_description.name}", 219 | device_name=device_name, 220 | device_serial_number=serial_number, 221 | serial_number=None, 222 | coordinator=coordinator, 223 | parent_device=config_entry.unique_id, 224 | ) 225 | ) 226 | 227 | elif sensor_description.key.startswith("agg_batteries_"): 228 | if coordinator.data.get("batteries"): 229 | entities.append( 230 | CoordinatedEnvoyEntity( 231 | description=sensor_description, 232 | name=f"{name} {sensor_description.name}", 233 | device_name=name, 234 | device_serial_number=config_entry.unique_id, 235 | serial_number=None, 236 | coordinator=coordinator, 237 | device_host=config_entry.data[CONF_HOST], 238 | ) 239 | ) 240 | 241 | else: 242 | data = coordinator.data.get(sensor_description.key) 243 | if data is None: 244 | continue 245 | 246 | entities.append( 247 | CoordinatedEnvoyEntity( 248 | description=sensor_description, 249 | name=f"{name} {sensor_description.name}", 250 | device_name=name, 251 | device_serial_number=config_entry.unique_id, 252 | serial_number=None, 253 | coordinator=coordinator, 254 | device_host=config_entry.data[CONF_HOST], 255 | ) 256 | ) 257 | 258 | for sensor_description in PHASE_SENSORS: 259 | if not options.get(ENABLE_ADDITIONAL_METRICS, False): 260 | if sensor_description.key in ADDITIONAL_METRICS: 261 | continue 262 | 263 | data = coordinator.data.get(sensor_description.key) 264 | if data is None: 265 | continue 266 | 267 | live_entities[sensor_description.key] = CoordinatedEnvoyEntity( 268 | description=sensor_description, 269 | name=f"{name} {sensor_description.name}", 270 | device_name=name, 271 | device_serial_number=config_entry.unique_id, 272 | serial_number=None, 273 | coordinator=coordinator, 274 | device_host=config_entry.data[CONF_HOST], 275 | ) 276 | entities.append(live_entities[sensor_description.key]) 277 | 278 | async_add_entities(entities) 279 | 280 | 281 | class EnvoyEntity(SensorEntity): 282 | """Envoy entity""" 283 | 284 | def __init__( 285 | self, 286 | description, 287 | name, 288 | device_name, 289 | device_serial_number, 290 | serial_number, 291 | ): 292 | """Initialize Envoy entity.""" 293 | self.entity_description = description 294 | self._name = name 295 | self._serial_number = serial_number 296 | self._device_name = device_name 297 | self._device_serial_number = device_serial_number 298 | 299 | @property 300 | def name(self): 301 | """Return the name of the sensor.""" 302 | return self._name 303 | 304 | @property 305 | def unique_id(self): 306 | """Return the unique id of the sensor.""" 307 | if self._serial_number: 308 | return self._serial_number 309 | if self._device_serial_number: 310 | return f"{self._device_serial_number}_{self.entity_description.key}" 311 | 312 | @property 313 | def extra_state_attributes(self): 314 | """Return the state attributes.""" 315 | return None 316 | 317 | 318 | class CoordinatedEnvoyEntity(EnvoyEntity, CoordinatorEntity): 319 | def __init__( 320 | self, 321 | description, 322 | name, 323 | device_name, 324 | device_serial_number, 325 | serial_number, 326 | coordinator, 327 | device_host, 328 | ): 329 | EnvoyEntity.__init__( 330 | self, description, name, device_name, device_serial_number, serial_number 331 | ) 332 | CoordinatorEntity.__init__(self, coordinator) 333 | self.device_host = device_host 334 | 335 | @property 336 | def native_value(self): 337 | """Return the state of the sensor.""" 338 | return self.coordinator.data.get(self.entity_description.key) 339 | 340 | @property 341 | def device_info(self) -> DeviceInfo | None: 342 | """Return the device_info of the device.""" 343 | if not self._device_serial_number: 344 | return None 345 | 346 | sw_version = self.coordinator.data.get("envoy_info", {}).get("software", None) 347 | hw_version = self.coordinator.data.get("envoy_info", {}).get("pn", None) 348 | model = self.coordinator.data.get("envoy_info", {}).get("model", "Standard") 349 | 350 | return DeviceInfo( 351 | identifiers={(DOMAIN, str(self._device_serial_number))}, 352 | manufacturer="Enphase", 353 | model=f"Envoy-S {model}", 354 | name=self._device_name, 355 | sw_version=sw_version, 356 | hw_version=resolve_hardware_id(hw_version), 357 | configuration_url=( 358 | f"https://{self.device_host}/" if self.device_host else None 359 | ), 360 | ) 361 | 362 | 363 | class EnvoyDeviceEntity(CoordinatorEntity, SensorEntity): 364 | 365 | def __init__( 366 | self, 367 | description, 368 | name, 369 | device_name, 370 | device_serial_number, 371 | serial_number, 372 | coordinator, 373 | parent_device, 374 | ): 375 | self.entity_description = description 376 | self._name = name 377 | self._serial_number = serial_number 378 | self._device_name = device_name 379 | self._device_serial_number = device_serial_number 380 | self._parent_device = parent_device 381 | CoordinatorEntity.__init__(self, coordinator) 382 | 383 | @property 384 | def name(self): 385 | """Return the name of the sensor.""" 386 | return self._name 387 | 388 | @property 389 | def unique_id(self): 390 | """Return the unique id of the sensor.""" 391 | if self._serial_number: 392 | return self._serial_number 393 | if self._device_serial_number: 394 | return f"{self._device_serial_number}_{self.entity_description.key}" 395 | 396 | 397 | class EnvoyInverterEntity(EnvoyDeviceEntity): 398 | 399 | @property 400 | def native_value(self): 401 | """Return the state of the sensor.""" 402 | if self.entity_description.key == "inverter_data_watts": 403 | if self.coordinator.data.get("inverter_production"): 404 | return ( 405 | self.coordinator.data.get("inverter_production") 406 | .get(self._device_serial_number) 407 | .get("lastReportWatts") 408 | ) 409 | elif self.entity_description.key.startswith("inverter_data_"): 410 | if self.coordinator.data.get("inverter_device_data"): 411 | value = ( 412 | self.coordinator.data.get("inverter_device_data") 413 | .get(self._device_serial_number) 414 | .get(self.entity_description.key[14:]) 415 | ) 416 | if self.entity_description.key.endswith("last_reading"): 417 | return datetime.datetime.fromtimestamp( 418 | int(value), tz=datetime.timezone.utc 419 | ) 420 | if ( 421 | self.coordinator.data.get("inverter_device_data") 422 | .get(self._device_serial_number) 423 | .get("gone") 424 | and not self.entity_description.retain 425 | ): 426 | return None 427 | return value 428 | elif self.entity_description.key.startswith("inverter_info_"): 429 | if self.coordinator.data.get("inverter_info"): 430 | return ( 431 | self.coordinator.data.get("inverter_info") 432 | .get(self._device_serial_number) 433 | .get(self.entity_description.key[14:]) 434 | ) 435 | 436 | return None 437 | 438 | @property 439 | def extra_state_attributes(self): 440 | """Return the state attributes.""" 441 | try: 442 | if self.entity_description.key == "inverter_data_watts": 443 | if self.coordinator.data.get("inverter_production"): 444 | value = ( 445 | self.coordinator.data.get("inverter_production") 446 | .get(self._device_serial_number) 447 | .get("lastReportDate") 448 | ) 449 | return { 450 | "last_reported": datetime.datetime.fromtimestamp( 451 | int(value), tz=datetime.timezone.utc 452 | ) 453 | } 454 | elif self.entity_description.key.startswith("inverter_info_"): 455 | if self.coordinator.data.get("inverter_info"): 456 | value = ( 457 | self.coordinator.data.get("inverter_info") 458 | .get(self._device_serial_number) 459 | .get("last_rpt_date") 460 | ) 461 | return { 462 | "last_reported": datetime.datetime.fromtimestamp( 463 | int(value), tz=datetime.timezone.utc 464 | ) 465 | } 466 | elif self.entity_description.key.startswith("inverter_data_"): 467 | if self.coordinator.data.get("inverter_device_data"): 468 | value = ( 469 | self.coordinator.data.get("inverter_device_data") 470 | .get(self._device_serial_number) 471 | .get("last_reading") 472 | ) 473 | return { 474 | "last_reported": datetime.datetime.fromtimestamp( 475 | int(value), tz=datetime.timezone.utc 476 | ) 477 | } 478 | except (ValueError, TypeError): 479 | return None 480 | 481 | @property 482 | def device_info(self) -> DeviceInfo | None: 483 | """Return the device_info of the device.""" 484 | if not self._device_serial_number: 485 | return None 486 | device_info_kw = {} 487 | if self._parent_device: 488 | device_info_kw["via_device"] = (DOMAIN, self._parent_device) 489 | 490 | if self.coordinator.data.get("inverter_info") and self.coordinator.data.get( 491 | "inverter_info" 492 | ).get(self._device_serial_number): 493 | device_info_kw["sw_version"] = ( 494 | self.coordinator.data.get("inverter_info") 495 | .get(self._device_serial_number) 496 | .get("img_pnum_running") 497 | ) 498 | device_info_kw["hw_version"] = ( 499 | self.coordinator.data.get("inverter_info") 500 | .get(self._device_serial_number) 501 | .get("part_num") 502 | ) 503 | model_name = (get_model_name("Inverter", device_info_kw["hw_version"]),) 504 | 505 | return DeviceInfo( 506 | identifiers={(DOMAIN, str(self._device_serial_number))}, 507 | manufacturer="Enphase", 508 | model=model_name, 509 | name=self._device_name, 510 | **device_info_kw, 511 | ) 512 | 513 | 514 | class EnvoyRelayEntity(EnvoyDeviceEntity): 515 | 516 | @property 517 | def native_value(self): 518 | if self.entity_description.key.startswith("relay_data_"): 519 | if self.coordinator.data.get("relay_device_data"): 520 | serial = self.coordinator.data.get("relay_device_data").get( 521 | self._device_serial_number 522 | ) 523 | value = serial.get(self.entity_description.key[11:]) 524 | if self.entity_description.key.endswith("last_reading"): 525 | return datetime.datetime.fromtimestamp( 526 | int(value), tz=datetime.timezone.utc 527 | ) 528 | if serial.get("gone", True): 529 | return None 530 | return value 531 | elif self.entity_description.key.startswith("relay_info_"): 532 | if self.coordinator.data.get("relay_info"): 533 | return ( 534 | self.coordinator.data.get("relay_info") 535 | .get(self._device_serial_number) 536 | .get(self.entity_description.key[11:]) 537 | ) 538 | 539 | @property 540 | def extra_state_attributes(self): 541 | """Return the state attributes.""" 542 | if self.entity_description.key.startswith("relay_data_"): 543 | if self.coordinator.data.get("relay_device_data"): 544 | value = ( 545 | self.coordinator.data.get("relay_device_data") 546 | .get(self._device_serial_number) 547 | .get("last_reading") 548 | ) 549 | return { 550 | "last_reported": datetime.datetime.fromtimestamp( 551 | int(value), tz=datetime.timezone.utc 552 | ) 553 | } 554 | elif self.entity_description.key.startswith("relay_info_"): 555 | if self.coordinator.data.get("relay_info"): 556 | value = ( 557 | self.coordinator.data.get("relay_info") 558 | .get(self._device_serial_number) 559 | .get("last_rpt_date") 560 | ) 561 | return { 562 | "last_reported": datetime.datetime.fromtimestamp( 563 | int(value), tz=datetime.timezone.utc 564 | ) 565 | } 566 | 567 | @property 568 | def device_info(self) -> DeviceInfo | None: 569 | """Return the device_info of the device.""" 570 | if not self._device_serial_number: 571 | return None 572 | device_info_kw = {} 573 | if self._parent_device: 574 | device_info_kw["via_device"] = (DOMAIN, self._parent_device) 575 | 576 | info = self.coordinator.data.get("relay_info", {}).get( 577 | self._device_serial_number, {} 578 | ) 579 | device_info_kw["sw_version"] = info.get("img_pnum_running", None) 580 | device_info_kw["hw_version"] = resolve_hardware_id(info.get("part_num", None)) 581 | model_name = get_model_name("Relay", info.get("part_num", None)) 582 | 583 | return DeviceInfo( 584 | identifiers={(DOMAIN, str(self._device_serial_number))}, 585 | manufacturer="Enphase", 586 | model=model_name, 587 | name=self._device_name, 588 | **device_info_kw, 589 | ) 590 | 591 | 592 | class EnvoySignalEntity(EnvoyDeviceEntity): 593 | 594 | @property 595 | def icon(self): 596 | return { 597 | 5: "mdi:wifi-strength-4", 598 | 4: "mdi:wifi-strength-3", 599 | 3: "mdi:wifi-strength-2", 600 | 2: "mdi:wifi-strength-1", 601 | 1: "mdi:wifi-strength-outline", 602 | 0: "mdi:wifi-strength-off-outline", 603 | }.get(self.native_value) 604 | 605 | @property 606 | def extra_state_attributes(self): 607 | return None 608 | 609 | @property 610 | def native_value(self) -> int: 611 | """Return the status of the requested attribute.""" 612 | data = self.coordinator.data.get("pcu_availability") 613 | if data is None: 614 | return 0 615 | return int(data.get(self._device_serial_number, 0)) 616 | 617 | 618 | class EnvoyInverterSignalEntity(EnvoySignalEntity, EnvoyInverterEntity): 619 | pass 620 | 621 | 622 | class EnvoyRelaySignalEntity(EnvoySignalEntity, EnvoyRelayEntity): 623 | pass 624 | 625 | 626 | class EnvoyBatteryEntity(EnvoyDeviceEntity): 627 | """Envoy battery entity.""" 628 | 629 | @property 630 | def native_value(self): 631 | """Return the state of the sensor.""" 632 | if self.coordinator.data.get("batteries"): 633 | if self.entity_description.key == "batteries_power": 634 | return int( 635 | self.coordinator.data.get("batteries_power") 636 | .get(self._device_serial_number) 637 | .get("real_power_mw") 638 | / 1000 639 | ) 640 | elif self.entity_description.key == "batteries_led_status": 641 | return BATTERY_STATE_MAPPING.get( 642 | self.coordinator.data.get("batteries") 643 | .get(self._device_serial_number) 644 | .get("led_status") 645 | ) 646 | else: 647 | return ( 648 | self.coordinator.data.get("batteries") 649 | .get(self._device_serial_number) 650 | .get(self.entity_description.key[10:]) 651 | ) 652 | 653 | return None 654 | 655 | @property 656 | def extra_state_attributes(self): 657 | """Return the state attributes.""" 658 | if self.coordinator.data.get("batteries"): 659 | battery = self.coordinator.data.get("batteries").get( 660 | self._device_serial_number 661 | ) 662 | return {"last_reported": battery.get("report_date")} 663 | 664 | return None 665 | 666 | @property 667 | def device_info(self) -> DeviceInfo | None: 668 | """Return the device_info of the device.""" 669 | if not self._device_serial_number: 670 | return None 671 | 672 | sw_version = None 673 | hw_version = None 674 | if self.coordinator.data.get("batteries") and self.coordinator.data.get( 675 | "batteries" 676 | ).get(self._device_serial_number): 677 | sw_version = ( 678 | self.coordinator.data.get("batteries") 679 | .get(self._device_serial_number) 680 | .get("img_pnum_running") 681 | ) 682 | hw_version = ( 683 | self.coordinator.data.get("batteries") 684 | .get(self._device_serial_number) 685 | .get("part_num") 686 | ) 687 | 688 | return DeviceInfo( 689 | identifiers={(DOMAIN, str(self._device_serial_number))}, 690 | manufacturer="Enphase", 691 | model=get_model_name("Battery", hw_version), 692 | name=self._device_name, 693 | via_device=(DOMAIN, self._parent_device), 694 | sw_version=sw_version, 695 | hw_version=resolve_hardware_id(hw_version), 696 | ) 697 | 698 | 699 | class EnvoyBatteryFirmwareEntity(EnvoyBatteryEntity): 700 | 701 | @property 702 | def native_value(self) -> str: 703 | if self.coordinator.data.get("batteries") and self.coordinator.data.get( 704 | "batteries" 705 | ).get(self._device_serial_number): 706 | return ( 707 | self.coordinator.data.get("batteries") 708 | .get(self._device_serial_number) 709 | .get("img_pnum_running") 710 | ) 711 | --------------------------------------------------------------------------------