├── .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 | 
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 | [](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 |
--------------------------------------------------------------------------------