Skip to content

Commit

Permalink
Merge pull request #64 from krbaker/merge-CanisUrsa-refactor_with_ess
Browse files Browse the repository at this point in the history
Merge canis ursa refactor with ess
krbaker authored Apr 17, 2024

Verified

This commit was signed with the committer’s verified signature.
scala-steward Scala Steward
2 parents dc47308 + 007549a commit f453254
Showing 17 changed files with 2,316 additions and 626 deletions.
43 changes: 43 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
[flake8]
# generous but reasonable line length
max-line-length = 99

# files to skip
exclude =
.flake8,
.pre-commit-config.yaml,
.git,
.gitignore,
.dockerignore,
requirements.txt,
__pycache__,
venv,
pip-download-cache,
migrations,
Dockerfile,
docker-entrypoint.sh,
docker-compose.yaml,
frontend,
*.md,
*.txt

extend-select =
B, # bugbear extensions
C, # complexity issues
D, # formatting?
DJ10, # django rules
DJ11, # django rules
E, # “errors” reported by pycodestyle
F, # violationsreported by pyflakes
PT, # pytest rules
W, # “warnings” reported by pycodestyle
B9 # bugbear warnings

# specific rules to ignore
ignore =

# use google style docstrings
docstring-convention = google

# force use of double quotes
inline-quotes = "
23 changes: 23 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Continuous Integration

on:
push:
branches: [main]
pull_request:
branches: [main]

# allow workflow to be triggered manually
workflow_dispatch:

jobs:
#########################################################################
pre-commit:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Run pre-commit
uses: pre-commit/[email protected]
env:
SKIP: no-commit-to-branch
3 changes: 3 additions & 0 deletions .isort.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[settings]
profile = black
force_grid_wrap = 2
7 changes: 7 additions & 0 deletions .markdownlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Default state for all rules
default: true

# MD013/line-length - Line length
MD013:
line_length: 99
tables: false
75 changes: 75 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
repos:
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.5
hooks:
- id: remove-crlf
- id: forbid-tabs

- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.13.0
hooks:
- id: pretty-format-yaml
args: [--autofix, --offset, '2', --preserve-quotes]

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: no-commit-to-branch

- id: check-executables-have-shebangs

- id: check-shebang-scripts-are-executable

- id: check-merge-conflict

- id: end-of-file-fixer

- id: mixed-line-ending
args: [--fix=lf]

- id: requirements-txt-fixer

- id: trailing-whitespace

- id: check-yaml

- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.39.0
hooks:
- id: markdownlint
args: [--fix]

- repo: https://github.com/codespell-project/codespell
rev: v2.2.6
hooks:
- id: codespell
args: [-L hass]

- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort

- repo: https://github.com/asottile/add-trailing-comma
rev: v3.1.0
hooks:
- id: add-trailing-comma

- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
args: [--line-length, "99"]

- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
name: flake8

additional_dependencies:
- flake8-bugbear==24.2.6
- flake8-plugin-utils==1.3.3
- flake8-pytest==1.4
- flake8-pytest-style==1.7.2
- flake8-quotes==3.4.0
268 changes: 203 additions & 65 deletions README.md

Large diffs are not rendered by default.

276 changes: 249 additions & 27 deletions custom_components/sunpower/__init__.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
"""The sunpower integration."""

import asyncio
from datetime import timedelta
import logging
import time
from datetime import timedelta

import voluptuous as vol

from .sunpower import SunPowerMonitor, ConnectionException, ParseException

from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.config_entries import (
SOURCE_IMPORT,
ConfigEntry,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)

from .const import (
BATTERY_DEVICE_TYPE,
DEFAULT_SUNPOWER_UPDATE_INTERVAL,
DEFAULT_SUNVAULT_UPDATE_INTERVAL,
DOMAIN,
UPDATE_INTERVAL,
SUNPOWER_OBJECT,
ESS_DEVICE_TYPE,
HUBPLUS_DEVICE_TYPE,
PVS_DEVICE_TYPE,
SETUP_TIMEOUT_MIN,
SUNPOWER_COORDINATOR,
SUNPOWER_ESS,
SUNPOWER_HOST,
SETUP_TIMEOUT_MIN,
SUNPOWER_OBJECT,
SUNPOWER_UPDATE_INTERVAL,
SUNVAULT_DEVICE_TYPE,
SUNVAULT_UPDATE_INTERVAL,
)
from .sunpower import (
ConnectionException,
ParseException,
SunPowerMonitor,
)

_LOGGER = logging.getLogger(__name__)
@@ -27,22 +45,203 @@

PLATFORMS = ["sensor", "binary_sensor"]

PREVIOUS_PVS_SAMPLE_TIME = 0
PREVIOUS_PVS_SAMPLE = {}
PREVIOUS_ESS_SAMPLE_TIME = 0
PREVIOUS_ESS_SAMPLE = {}


def convert_sunpower_data(sunpower_data):
"""Convert PVS data into indexable format data[device_type][serial]"""
data = {}
for device in sunpower_data["devices"]:
data.setdefault(device["DEVICE_TYPE"], {})[device["SERIAL"]] = device
return data


def convert_ess_data(ess_data, pvs_serial):
"""Do all the gymnastics to Integrate ESS data from its unique data source into the PVS data"""
data = {}
sunvault_amperages = []
sunvault_voltages = []
sunvault_temperatures = []
sunvault_customer_state_of_charges = []
sunvault_system_state_of_charges = []
sunvault_power = []
sunvault_power_inputs = []
sunvault_power_outputs = []
sunvault_state = "working"
for device in ess_data["ess_report"]["battery_status"]:
data[BATTERY_DEVICE_TYPE][device["serial_number"]]["battery_amperage"] = device[
"battery_amperage"
]["value"]
data[BATTERY_DEVICE_TYPE][device["serial_number"]]["battery_voltage"] = device[
"battery_voltage"
]["value"]
data[BATTERY_DEVICE_TYPE][device["serial_number"]]["customer_state_of_charge"] = device[
"customer_state_of_charge"
]["value"]
data[BATTERY_DEVICE_TYPE][device["serial_number"]]["system_state_of_charge"] = device[
"system_state_of_charge"
]["value"]
data[BATTERY_DEVICE_TYPE][device["serial_number"]]["temperature"] = device["temperature"][
"value"
]
if data[BATTERY_DEVICE_TYPE][device["serial_number"]]["STATE"] != "working":
sunvault_state = data[BATTERY_DEVICE_TYPE][device["serial_number"]]["STATE"]
sunvault_amperages.append(device["battery_amperage"]["value"])
sunvault_voltages.append(device["battery_voltage"]["value"])
sunvault_temperatures.append(device["temperature"]["value"])
sunvault_customer_state_of_charges.append(
device["customer_state_of_charge"]["value"],
)
sunvault_system_state_of_charges.append(device["system_state_of_charge"]["value"])
sunvault_power.append(sunvault_amperages[-1] * sunvault_voltages[-1])
if sunvault_amperages[-1] < 0:
sunvault_power_outputs.append(
abs(sunvault_amperages[-1] * sunvault_voltages[-1]),
)
sunvault_power_inputs.append(0)
elif sunvault_amperages[-1] > 0:
sunvault_power_inputs.append(sunvault_amperages[-1] * sunvault_voltages[-1])
sunvault_power_outputs.append(0)
else:
sunvault_power_inputs.append(0)
sunvault_power_outputs.append(0)
for device in ess_data["ess_report"]["ess_status"]:
data[ESS_DEVICE_TYPE][device["serial_number"]]["enclosure_humidity"] = device[
"enclosure_humidity"
]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["enclosure_temperature"] = device[
"enclosure_temperature"
]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["agg_power"] = device["ess_meter_reading"][
"agg_power"
]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["meter_a_current"] = device[
"ess_meter_reading"
]["meter_a"]["reading"]["current"]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["meter_a_power"] = device[
"ess_meter_reading"
]["meter_a"]["reading"]["power"]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["meter_a_voltage"] = device[
"ess_meter_reading"
]["meter_a"]["reading"]["voltage"]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["meter_b_current"] = device[
"ess_meter_reading"
]["meter_b"]["reading"]["current"]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["meter_b_power"] = device[
"ess_meter_reading"
]["meter_b"]["reading"]["power"]["value"]
data[ESS_DEVICE_TYPE][device["serial_number"]]["meter_b_voltage"] = device[
"ess_meter_reading"
]["meter_b"]["reading"]["voltage"]["value"]
if True:
device = ess_data["ess_report"]["hub_plus_status"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["contactor_position"] = device[
"contactor_position"
]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["grid_frequency_state"] = device[
"grid_frequency_state"
]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["grid_phase1_voltage"] = device[
"grid_phase1_voltage"
]["value"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["grid_phase2_voltage"] = device[
"grid_phase2_voltage"
]["value"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["grid_voltage_state"] = device[
"grid_voltage_state"
]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["hub_humidity"] = device[
"hub_humidity"
]["value"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["hub_temperature"] = device[
"hub_temperature"
]["value"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["inverter_connection_voltage"] = device[
"inverter_connection_voltage"
]["value"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["load_frequency_state"] = device[
"load_frequency_state"
]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["load_phase1_voltage"] = device[
"load_phase1_voltage"
]["value"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["load_phase2_voltage"] = device[
"load_phase2_voltage"
]["value"]
data[HUBPLUS_DEVICE_TYPE][device["serial_number"]]["main_voltage"] = device[
"main_voltage"
]["value"]
if True:
# Generate a usable serial number for this virtual device, use PVS serial as base
# since we must be talking through one and it has a serial
sunvault_serial = f"sunvault_{pvs_serial}"
data[SUNVAULT_DEVICE_TYPE] = {sunvault_serial: {}}
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_amperage"] = sum(
sunvault_amperages,
)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_voltage"] = sum(
sunvault_voltages,
) / len(sunvault_voltages)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_temperature"] = sum(
sunvault_temperatures,
) / len(sunvault_temperatures)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_customer_state_of_charge"] = sum(
sunvault_customer_state_of_charges,
) / len(sunvault_customer_state_of_charges)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_system_state_of_charge"] = sum(
sunvault_system_state_of_charges,
) / len(sunvault_system_state_of_charges)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_power_input"] = sum(
sunvault_power_inputs,
)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_power_output"] = sum(
sunvault_power_outputs,
)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["sunvault_power"] = sum(sunvault_power)
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["STATE"] = sunvault_state
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["SERIAL"] = sunvault_serial
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["SWVER"] = "1.0"
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["HWVER"] = "Virtual"
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["DESCR"] = "Virtual SunVault"
data[SUNVAULT_DEVICE_TYPE][sunvault_serial]["MODEL"] = "Virtual SunVault"
return data


def sunpower_fetch(sunpower_monitor, use_ess, sunpower_update_invertal, sunvault_update_invertal):
"""Basic data fetch routine to get and reformat sunpower data to a dict of device
type and serial #"""
global PREVIOUS_PVS_SAMPLE_TIME
global PREVIOUS_PVS_SAMPLE
global PREVIOUS_ESS_SAMPLE_TIME
global PREVIOUS_ESS_SAMPLE

sunpower_data = PREVIOUS_PVS_SAMPLE
ess_data = PREVIOUS_ESS_SAMPLE

def sunpower_fetch(sunpower_monitor):
"""Basic data fetch routine to get and reformat sunpower data to a dict of device type and serial #"""
try:
sunpower_data = sunpower_monitor.device_list()
_LOGGER.debug("got data %s", sunpower_data)
data = {}
# Convert data into indexable format data[device_type][serial]
for device in sunpower_data["devices"]:
if device["DEVICE_TYPE"] not in data:
data[device["DEVICE_TYPE"]] = {device["SERIAL"]: device}
else:
data[device["DEVICE_TYPE"]][device["SERIAL"]] = device
return data
if (time.time() - PREVIOUS_PVS_SAMPLE_TIME) >= (sunpower_update_invertal - 1):
PREVIOUS_PVS_SAMPLE_TIME = time.time()
sunpower_data = sunpower_monitor.device_list()
PREVIOUS_PVS_SAMPLE = sunpower_data
_LOGGER.debug("got PVS data %s", sunpower_data)

if use_ess and (time.time() - PREVIOUS_ESS_SAMPLE_TIME) >= (sunvault_update_invertal - 1):
PREVIOUS_ESS_SAMPLE_TIME = time.time()
ess_data = sunpower_monitor.energy_storage_system_status()
PREVIOUS_ESS_SAMPLE = sunpower_data
_LOGGER.debug("got ESS data %s", ess_data)
except ConnectionException as error:
raise UpdateFailed from error

try:
data = convert_sunpower_data(sunpower_data)
pvs_serial = next(iter(data[PVS_DEVICE_TYPE])) # only one PVS
if use_ess:
data.update(convert_ess_data(ess_data, pvs_serial))
return data
except ParseException as error:
raise UpdateFailed from error

@@ -60,7 +259,7 @@ async def async_setup(hass: HomeAssistant, config: dict):
DOMAIN,
context={"source": SOURCE_IMPORT},
data=conf,
)
),
)
return True

@@ -71,18 +270,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):

hass.data[DOMAIN].setdefault(entry_id, {})
sunpower_monitor = SunPowerMonitor(entry.data[SUNPOWER_HOST])
use_ess = entry.data.get(SUNPOWER_ESS, False)
sunpower_update_invertal = entry.data.get(
SUNPOWER_UPDATE_INTERVAL,
DEFAULT_SUNPOWER_UPDATE_INTERVAL,
)
sunvault_update_invertal = entry.data.get(
SUNVAULT_UPDATE_INTERVAL,
DEFAULT_SUNVAULT_UPDATE_INTERVAL,
)

async def async_update_data():
"""Fetch data from API endpoint, used by coordinator to get mass data updates"""
_LOGGER.debug("Updating SunPower data")
return await hass.async_add_executor_job(sunpower_fetch, sunpower_monitor)
return await hass.async_add_executor_job(
sunpower_fetch,
sunpower_monitor,
use_ess,
sunpower_update_invertal,
sunvault_update_invertal,
)

# This could be better, taking the shortest time interval as the coordinator update is fine
# if the long interval is an even multiple of the short or *much* smaller
coordinator_interval = (
sunvault_update_invertal
if sunvault_update_invertal < sunpower_update_invertal and use_ess
else sunpower_update_invertal
)

coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="SunPower PVS",
update_method=async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
update_interval=timedelta(seconds=coordinator_interval),
)

hass.data[DOMAIN][entry.entry_id] = {
@@ -101,7 +323,7 @@ async def async_update_data():

for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
hass.config_entries.async_forward_entry_setup(entry, component),
)

return True
@@ -114,8 +336,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
],
),
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
208 changes: 98 additions & 110 deletions custom_components/sunpower/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
"""Support for Sunpower binary sensors."""

import logging

from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.binary_sensor import BinarySensorEntity

from .const import (
DOMAIN,
PVS_DEVICE_TYPE,
SUNPOWER_BINARY_SENSORS,
SUNPOWER_COORDINATOR,
SUNPOWER_DESCRIPTIVE_NAMES,
PVS_DEVICE_TYPE,
INVERTER_DEVICE_TYPE,
METER_DEVICE_TYPE,
PVS_STATE,
METER_STATE,
INVERTER_STATE,
WORKING_STATE,
SUNPOWER_ESS,
SUNPOWER_PRODUCT_NAMES,
SUNVAULT_BINARY_SENSORS,
)
from .entity import SunPowerPVSEntity, SunPowerMeterEntity, SunPowerInverterEntity
from .entity import SunPowerEntity

_LOGGER = logging.getLogger(__name__)

@@ -26,138 +24,128 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
sunpower_state = hass.data[DOMAIN][config_entry.entry_id]
_LOGGER.debug("Sunpower_state: %s", sunpower_state)

if not SUNPOWER_DESCRIPTIVE_NAMES in config_entry.data:
config_entry.data[SUNPOWER_DESCRIPTIVE_NAMES] = False
do_descriptive_names = config_entry.data[SUNPOWER_DESCRIPTIVE_NAMES]
do_descriptive_names = False
if SUNPOWER_DESCRIPTIVE_NAMES in config_entry.data:
do_descriptive_names = config_entry.data[SUNPOWER_DESCRIPTIVE_NAMES]

do_product_names = False
if SUNPOWER_PRODUCT_NAMES in config_entry.data:
do_product_names = config_entry.data[SUNPOWER_PRODUCT_NAMES]

do_ess = False
if SUNPOWER_ESS in config_entry.data:
do_ess = config_entry.data[SUNPOWER_ESS]

coordinator = sunpower_state[SUNPOWER_COORDINATOR]
sunpower_data = coordinator.data

if PVS_DEVICE_TYPE not in sunpower_data:
_LOGGER.error("Cannot find PVS Entry")
else:
pvs = next(iter(sunpower_data[PVS_DEVICE_TYPE].values()))

entities = [SunPowerPVSState(coordinator, pvs, do_descriptive_names)]
entities = []

if METER_DEVICE_TYPE not in sunpower_data:
_LOGGER.error("Cannot find any power meters")
else:
for data in sunpower_data[METER_DEVICE_TYPE].values():
entities.append(SunPowerMeterState(coordinator, data, pvs, do_descriptive_names))
pvs = next(iter(sunpower_data[PVS_DEVICE_TYPE].values()))

if INVERTER_DEVICE_TYPE not in sunpower_data:
_LOGGER.error("Cannot find any power inverters")
else:
for data in sunpower_data[INVERTER_DEVICE_TYPE].values():
entities.append(SunPowerInverterState(coordinator, data, pvs, do_descriptive_names))
BINARY_SENSORS = SUNPOWER_BINARY_SENSORS
if do_ess:
BINARY_SENSORS.update(SUNVAULT_BINARY_SENSORS)

for device_type in BINARY_SENSORS:
if device_type not in sunpower_data:
_LOGGER.error(f"Cannot find any {device_type}")
continue
unique_id = BINARY_SENSORS[device_type]["unique_id"]
sensors = BINARY_SENSORS[device_type]["sensors"]
for index, sensor_data in enumerate(sunpower_data[device_type].values()):
for sensor_name in sensors:
sensor = sensors[sensor_name]
sensor_type = (
"" if not do_descriptive_names else f"{sensor_data.get('TYPE', '')} "
)
sensor_description = (
"" if not do_descriptive_names else f"{sensor_data.get('DESCR', '')} "
)
text_sunpower = "" if not do_product_names else "SunPower "
text_sunvault = "" if not do_product_names else "SunVault "
text_pvs = "" if not do_product_names else "PVS "
sensor_index = "" if not do_descriptive_names else f"{index + 1} "
sunpower_sensor = SunPowerState(
coordinator=coordinator,
my_info=sensor_data,
parent_info=pvs if device_type != PVS_DEVICE_TYPE else None,
id_code=unique_id,
device_type=device_type,
field=sensor["field"],
title=sensor["title"].format(
index=sensor_index,
TYPE=sensor_type,
DESCR=sensor_description,
SUN_POWER=text_sunpower,
SUN_VAULT=text_sunvault,
PVS=text_pvs,
SERIAL=sensor_data.get("SERIAL", "Unknown"),
MODEL=sensor_data.get("MODEL", "Unknown"),
),
device_class=sensor["device"],
on_value=sensor["on_value"],
)
entities.append(sunpower_sensor)

async_add_entities(entities, True)


class SunPowerPVSState(SunPowerPVSEntity, BinarySensorEntity):
"""Representation of SunPower PVS Working State"""

def __init__(self, coordinator, pvs_info, do_descriptive_names):
super().__init__(coordinator, pvs_info)
self._do_descriptive_names = do_descriptive_names

@property
def name(self):
"""Device Name."""
if self._do_descriptive_names:
return "PVS System State"
else:
return "System State"

@property
def device_class(self):
"""Device Class."""
return SensorDeviceClass.POWER

@property
def unique_id(self):
"""Device Uniqueid."""
return f"{self.base_unique_id}_pvs_state"

@property
def state(self):
"""Get the current value"""
return self.coordinator.data[PVS_DEVICE_TYPE][self.base_unique_id][PVS_STATE]

@property
def is_on(self):
"""Return true if the binary sensor is on."""
return self.state == WORKING_STATE


class SunPowerMeterState(SunPowerMeterEntity, BinarySensorEntity):
class SunPowerState(SunPowerEntity, BinarySensorEntity):
"""Representation of SunPower Meter Working State"""

def __init__(self, coordinator, meter_info, pvs_info, do_descriptive_names):
super().__init__(coordinator, meter_info, pvs_info)
self._do_descriptive_names = do_descriptive_names

@property
def name(self):
"""Device Name."""
if self._do_descriptive_names:
return f"{self._meter_info['DESCR']} System State"
else:
return "System State"

@property
def device_class(self):
"""Device Class."""
return SensorDeviceClass.POWER

@property
def unique_id(self):
"""Device Uniqueid."""
return f"{self.base_unique_id}_meter_state"

@property
def state(self):
"""Get the current value"""
return self.coordinator.data[METER_DEVICE_TYPE][self.base_unique_id][METER_STATE]

@property
def is_on(self):
"""Return true if the binary sensor is on."""
return self.state == WORKING_STATE


class SunPowerInverterState(SunPowerInverterEntity, BinarySensorEntity):
"""Representation of SunPower Inverter Working State"""

def __init__(self, coordinator, inverter_info, pvs_info, do_descriptive_names):
super().__init__(coordinator, inverter_info, pvs_info)
self._do_descriptive_names = do_descriptive_names
def __init__(
self,
coordinator,
my_info,
parent_info,
id_code,
device_type,
field,
title,
device_class,
on_value,
):
super().__init__(coordinator, my_info, parent_info)
self._id_code = id_code
self._device_type = device_type
self._title = title
self._field = field
self._my_device_class = device_class
self._on_value = on_value

@property
def name(self):
"""Device Name."""
if self._do_descriptive_names:
return f"{self._inverter_info['DESCR']} System State"
else:
return "System State"
return self._title

@property
def device_class(self):
"""Device Class."""
return SensorDeviceClass.POWER
return self._my_device_class

@property
def unique_id(self):
"""Device Uniqueid."""
return f"{self.base_unique_id}_inverter_state"
"""Device Uniqueid.
https://developers.home-assistant.io/docs/entity_registry_index/#unique-id
Should not include the domain, home assistant does that for us
base_unique_id is the serial number of the device (Inverter, PVS, Meter etc)
"_pvs_" just as a divider - in case we start pulling data from some other source
_field is the field within the data that this came from which is a dict so there
is only one.
Updating this format is a breaking change and should be called out if changed in a PR
"""
return f"{self.base_unique_id}_pvs_{self._field}"

@property
def state(self):
"""Get the current value"""
return self.coordinator.data[INVERTER_DEVICE_TYPE][self.base_unique_id][INVERTER_STATE]
return self.coordinator.data[self._device_type][self.base_unique_id][self._field]

@property
def is_on(self):
"""Return true if the binary sensor is on."""
return self.state == WORKING_STATE
return self.state == self._on_value
36 changes: 30 additions & 6 deletions custom_components/sunpower/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
"""Config flow for sunpower integration."""

import logging
from .sunpower import SunPowerMonitor, ConnectionException
import voluptuous as vol

from homeassistant import config_entries, core, exceptions
import voluptuous as vol
from homeassistant import (
config_entries,
core,
exceptions,
)
from homeassistant.const import CONF_HOST

from .const import DOMAIN, SUNPOWER_HOST, SUNPOWER_DESCRIPTIVE_NAMES
from .const import (
DEFAULT_SUNPOWER_UPDATE_INTERVAL,
DEFAULT_SUNVAULT_UPDATE_INTERVAL,
DOMAIN,
SUNPOWER_DESCRIPTIVE_NAMES,
SUNPOWER_ESS,
SUNPOWER_HOST,
SUNPOWER_PRODUCT_NAMES,
SUNPOWER_UPDATE_INTERVAL,
SUNVAULT_UPDATE_INTERVAL,
)
from .sunpower import (
ConnectionException,
SunPowerMonitor,
)

_LOGGER = logging.getLogger(__name__)

DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(SUNPOWER_DESCRIPTIVE_NAMES, default=False): bool,
}
vol.Required(SUNPOWER_PRODUCT_NAMES, default=False): bool,
vol.Required(SUNPOWER_ESS, default=False): bool,
vol.Required(SUNPOWER_UPDATE_INTERVAL, default=DEFAULT_SUNPOWER_UPDATE_INTERVAL): int,
vol.Required(SUNVAULT_UPDATE_INTERVAL, default=DEFAULT_SUNVAULT_UPDATE_INTERVAL): int,
},
)


@@ -56,7 +78,9 @@ async def async_step_user(self, user_input=None):
errors["base"] = "unknown"

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
)

async def async_step_import(self, user_input):
858 changes: 701 additions & 157 deletions custom_components/sunpower/const.py

Large diffs are not rendered by default.

79 changes: 19 additions & 60 deletions custom_components/sunpower/entity.py
Original file line number Diff line number Diff line change
@@ -5,73 +5,32 @@
from .const import DOMAIN


class SunPowerPVSEntity(CoordinatorEntity):
"""Base class for sunpower pvs entities."""

def __init__(self, coordinator, pvs_info):
"""Initialize the sensor."""
super().__init__(coordinator)
self.base_unique_id = pvs_info["SERIAL"]
self._pvs_info = pvs_info

@property
def device_info(self):
"""Sunpower PVS device info."""
device_info = {
"identifiers": {(DOMAIN, self.base_unique_id)},
"name": "{} {}".format(self._pvs_info["MODEL"], self._pvs_info["SERIAL"]),
"manufacturer": "SunPower",
"model": self._pvs_info["MODEL"],
"sw_version": "{}, Hardware: {}".format(
self._pvs_info["SWVER"], self._pvs_info["HWVER"]
),
}
return device_info


class SunPowerMeterEntity(CoordinatorEntity):
"""Base class for sunpower meter entities."""

def __init__(self, coordinator, meter_info, pvs_info):
class SunPowerEntity(CoordinatorEntity):
def __init__(self, coordinator, my_info, parent_info):
"""Initialize the sensor."""
super().__init__(coordinator)
self.base_unique_id = meter_info["SERIAL"]
self._pvs_info = pvs_info
self._meter_info = meter_info
self._my_info = my_info
self._parent_info = parent_info
self.base_unique_id = self._my_info.get("SERIAL", "")

@property
def device_info(self):
"""Sunpower Inverter device info."""
serial = self._my_info.get("SERIAL", "UnknownSerial")
model = self._my_info.get("MODEL", "UnknownModel")
name = self._my_info.get("DESCR", f"{model} {serial}")
hw_version = self._my_info.get("HWVER", self._my_info.get("hw_version", "Unknown"))
sw_version = self._my_info.get("SWVER", "Unknown")
version = f"{sw_version} Hardware: {hw_version}"
device_info = {
"identifiers": {(DOMAIN, self.base_unique_id)},
"name": self._meter_info["DESCR"],
"name": name,
"manufacturer": "SunPower",
"model": self._meter_info["MODEL"],
"sw_version": self._meter_info["SWVER"],
"via_device": (DOMAIN, self._pvs_info["SERIAL"]),
}
return device_info


class SunPowerInverterEntity(CoordinatorEntity):
"""Base class for sunpower inverter entities."""

def __init__(self, coordinator, inverter_info, pvs_info):
"""Initialize the sensor."""
super().__init__(coordinator)
self.base_unique_id = inverter_info["SERIAL"]
self._pvs_info = pvs_info
self._inverter_info = inverter_info

@property
def device_info(self):
"""Sunpower Inverter device info."""
device_info = {
"identifiers": {(DOMAIN, self.base_unique_id)},
"name": self._inverter_info["DESCR"],
"manufacturer": self._inverter_info["TYPE"],
"model": self._inverter_info["MODEL"],
"sw_version": self._inverter_info["SWVER"],
"via_device": (DOMAIN, self._pvs_info["SERIAL"]),
"model": model,
"sw_version": version,
}
if self._parent_info is not None:
device_info["via_device"] = (
DOMAIN,
f"{self._parent_info.get('SERIAL', 'UnknownParent')}",
)
return device_info
2 changes: 1 addition & 1 deletion custom_components/sunpower/manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.0.13",
"version": "2024.4.2",
"domain": "sunpower",
"name": "sunpower",
"config_flow": true,
298 changes: 105 additions & 193 deletions custom_components/sunpower/sensor.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
"""Support for Sunpower sensors."""

import logging

from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
)

from .const import (
DOMAIN,
PVS_DEVICE_TYPE,
SUNPOWER_COORDINATOR,
SUNPOWER_DESCRIPTIVE_NAMES,
PVS_DEVICE_TYPE,
INVERTER_DEVICE_TYPE,
METER_DEVICE_TYPE,
PVS_SENSORS,
METER_SENSORS,
INVERTER_SENSORS,
SUNPOWER_ESS,
SUNPOWER_PRODUCT_NAMES,
SUNPOWER_SENSORS,
SUNVAULT_SENSORS,
)
from .entity import SunPowerPVSEntity, SunPowerMeterEntity, SunPowerInverterEntity

from .entity import SunPowerEntity

_LOGGER = logging.getLogger(__name__)

@@ -25,202 +27,98 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
sunpower_state = hass.data[DOMAIN][config_entry.entry_id]
_LOGGER.debug("Sunpower_state: %s", sunpower_state)

if not SUNPOWER_DESCRIPTIVE_NAMES in config_entry.data:
config_entry.data[SUNPOWER_DESCRIPTIVE_NAMES] = False
do_descriptive_names = config_entry.data[SUNPOWER_DESCRIPTIVE_NAMES]
do_descriptive_names = False
if SUNPOWER_DESCRIPTIVE_NAMES in config_entry.data:
do_descriptive_names = config_entry.data[SUNPOWER_DESCRIPTIVE_NAMES]

do_product_names = False
if SUNPOWER_PRODUCT_NAMES in config_entry.data:
do_product_names = config_entry.data[SUNPOWER_PRODUCT_NAMES]

do_ess = False
if SUNPOWER_ESS in config_entry.data:
do_ess = config_entry.data[SUNPOWER_ESS]

coordinator = sunpower_state[SUNPOWER_COORDINATOR]
sunpower_data = coordinator.data

if PVS_DEVICE_TYPE not in sunpower_data:
_LOGGER.error("Cannot find PVS Entry")
else:
pvs = next(iter(sunpower_data[PVS_DEVICE_TYPE].values()))

entities = []
for sensor in PVS_SENSORS:
if do_descriptive_names:
title = f"{pvs['DEVICE_TYPE']} {PVS_SENSORS[sensor][1]}"
else:
title = PVS_SENSORS[sensor][1]
spb = SunPowerPVSBasic(
coordinator,
pvs,
PVS_SENSORS[sensor][0],
title,
PVS_SENSORS[sensor][2],
PVS_SENSORS[sensor][3],
PVS_SENSORS[sensor][4],
PVS_SENSORS[sensor][5],
)
if spb.native_value is not None: # ensure we can pull a value here, otherwise throw out this value
entities.append(spb)

if METER_DEVICE_TYPE not in sunpower_data:
_LOGGER.error("Cannot find any power meters")
else:
for data in sunpower_data[METER_DEVICE_TYPE].values():
for sensor in METER_SENSORS:
if do_descriptive_names:
title = f"{data['DESCR']} {METER_SENSORS[sensor][1]}"
else:
title = METER_SENSORS[sensor][1]
smb = SunPowerMeterBasic(
coordinator,
data,
pvs,
METER_SENSORS[sensor][0],
title,
METER_SENSORS[sensor][2],
METER_SENSORS[sensor][3],
METER_SENSORS[sensor][4],
METER_SENSORS[sensor][5],
)
if smb.native_value is not None: # ensure we can pull a value here, otherwise throw out this value
entities.append(smb)
pvs = next(iter(sunpower_data[PVS_DEVICE_TYPE].values()))

if INVERTER_DEVICE_TYPE not in sunpower_data:
_LOGGER.error("Cannot find any power inverters")
else:
for data in sunpower_data[INVERTER_DEVICE_TYPE].values():
for sensor in INVERTER_SENSORS:
if do_descriptive_names:
title = f"{data['DESCR']} {INVERTER_SENSORS[sensor][1]}"
else:
title = INVERTER_SENSORS[sensor][1]
sib = SunPowerInverterBasic(
coordinator,
data,
pvs,
INVERTER_SENSORS[sensor][0],
title,
INVERTER_SENSORS[sensor][2],
INVERTER_SENSORS[sensor][3],
INVERTER_SENSORS[sensor][4],
INVERTER_SENSORS[sensor][5]
SENSORS = SUNPOWER_SENSORS
if do_ess:
SENSORS.update(SUNVAULT_SENSORS)

for device_type in SENSORS:
if device_type not in sunpower_data:
_LOGGER.error(f"Cannot find any {device_type}")
continue
unique_id = SENSORS[device_type]["unique_id"]
sensors = SENSORS[device_type]["sensors"]
for index, sensor_data in enumerate(sunpower_data[device_type].values()):
for sensor_name in sensors:
sensor = sensors[sensor_name]
sensor_type = (
"" if not do_descriptive_names else f"{sensor_data.get('TYPE', '')} "
)
if sib.native_value is not None: # ensure we can pull a value here, otherwise throw out this value
entities.append(sib)
sensor_description = (
"" if not do_descriptive_names else f"{sensor_data.get('DESCR', '')} "
)
text_sunpower = "" if not do_product_names else "SunPower "
text_sunvault = "" if not do_product_names else "SunVault "
text_pvs = "" if not do_product_names else "PVS "
sensor_index = "" if not do_descriptive_names else f"{index + 1} "
sunpower_sensor = SunPowerSensor(
coordinator=coordinator,
my_info=sensor_data,
parent_info=pvs if device_type != PVS_DEVICE_TYPE else None,
id_code=unique_id,
device_type=device_type,
field=sensor["field"],
title=sensor["title"].format(
index=sensor_index,
TYPE=sensor_type,
DESCR=sensor_description,
SUN_POWER=text_sunpower,
SUN_VAULT=text_sunvault,
PVS=text_pvs,
SERIAL=sensor_data.get("SERIAL", "Unknown"),
MODEL=sensor_data.get("MODEL", "Unknown"),
),
unit=sensor["unit"],
icon=sensor["icon"],
device_class=sensor["device"],
state_class=sensor["state"],
)
if sunpower_sensor.native_value is not None:
entities.append(sunpower_sensor)

async_add_entities(entities, True)


class SunPowerPVSBasic(SunPowerPVSEntity, SensorEntity):
"""Representation of SunPower PVS Stat"""

def __init__(self, coordinator, pvs_info, field, title, unit, icon, device_class, state_class):
"""Initialize the sensor."""
super().__init__(coordinator, pvs_info)
self._title = title
self._field = field
self._unit = unit
self._icon = icon
self._my_device_class = device_class
self._my_state_class = state_class

@property
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return self._unit

@property
def device_class(self):
"""Return device class."""
return self._my_device_class

@property
def state_class(self):
"""Return state class."""
return self._my_state_class

@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon

@property
def name(self):
"""Device Name."""
return self._title

@property
def unique_id(self):
"""Device Uniqueid."""
return f"{self.base_unique_id}_pvs_{self._field}"

@property
def native_value(self):
"""Get the current value"""
if self._my_device_class == SensorDeviceClass.POWER_FACTOR:
try:
return float(self.coordinator.data[PVS_DEVICE_TYPE][self.base_unique_id].get(self._field, None)) * 100.0
except ValueError:
pass #sometimes this value might be something like 'unavailable'
return self.coordinator.data[PVS_DEVICE_TYPE][self.base_unique_id].get(self._field, None)


class SunPowerMeterBasic(SunPowerMeterEntity, SensorEntity):
"""Representation of SunPower Meter Stat"""

def __init__(self, coordinator, meter_info, pvs_info, field, title, unit, icon,
device_class, state_class):
"""Initialize the sensor."""
super().__init__(coordinator, meter_info, pvs_info)
self._title = title
self._field = field
self._unit = unit
self._icon = icon
self._my_device_class = device_class
self._my_state_class = state_class

@property
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return self._unit

@property
def device_class(self):
"""Return device class."""
return self._my_device_class

@property
def state_class(self):
"""Return state class."""
return self._my_state_class

@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon

@property
def name(self):
"""Device Name."""
return self._title

@property
def unique_id(self):
"""Device Uniqueid."""
return f"{self.base_unique_id}_pvs_{self._field}"

@property
def native_value(self):
"""Get the current value"""
if self._my_device_class == SensorDeviceClass.POWER_FACTOR:
try:
return float(self.coordinator.data[METER_DEVICE_TYPE][self.base_unique_id].get(self._field, None)) * 100.0
except ValueError:
pass #sometimes this value might be something like 'unavailable'
return self.coordinator.data[METER_DEVICE_TYPE][self.base_unique_id].get(self._field, None)


class SunPowerInverterBasic(SunPowerInverterEntity, SensorEntity):
"""Representation of SunPower Meter Stat"""

def __init__(self, coordinator, inverter_info, pvs_info, field, title, unit, icon,
device_class, state_class):
class SunPowerSensor(SunPowerEntity, SensorEntity):
def __init__(
self,
coordinator,
my_info,
parent_info,
id_code,
device_type,
field,
title,
unit,
icon,
device_class,
state_class,
):
"""Initialize the sensor."""
super().__init__(coordinator, inverter_info, pvs_info)
super().__init__(coordinator, my_info, parent_info)
self._id_code = id_code
self._device_type = device_type
self._title = title
self._field = field
self._unit = unit
@@ -255,15 +153,29 @@ def name(self):

@property
def unique_id(self):
"""Device Uniqueid."""
"""Device Uniqueid.
https://developers.home-assistant.io/docs/entity_registry_index/#unique-id
Should not include the domain, home assistant does that for us
base_unique_id is the serial number of the device (Inverter, PVS, Meter etc)
"_pvs_" just as a divider - in case we start pulling data from some other source
_field is the field within the data that this came from which is a dict so there
is only one.
Updating this format is a breaking change and should be called out if changed in a PR
"""
return f"{self.base_unique_id}_pvs_{self._field}"

@property
def native_value(self):
"""Get the current value"""
if self._my_device_class == SensorDeviceClass.POWER_FACTOR:
try:
return float(self.coordinator.data[INVERTER_DEVICE_TYPE][self.base_unique_id].get(self._field, None)) * 100.0
value = float(
self.coordinator.data[self._device_type][self.base_unique_id].get(
self._field,
None,
),
)
return value * 100.0
except ValueError:
pass #sometimes this value might be something like 'unavailable'
return self.coordinator.data[INVERTER_DEVICE_TYPE][self.base_unique_id].get(self._field, None)
pass # sometimes this value might be something like 'unavailable'
return self.coordinator.data[self._device_type][self.base_unique_id].get(self._field, None)
8 changes: 6 additions & 2 deletions custom_components/sunpower/strings.json
Original file line number Diff line number Diff line change
@@ -5,14 +5,18 @@
"user": {
"data": {
"host": "Host",
"use_descriptive_names": "Use descriptive entity names"
"use_descriptive_names": "Use descriptive entity names",
"use_product_names": "Use products in entity names",
"use_ess": "Use energy storage system",
"PVS_UPDATE_INTERVAL": "Solar data update interval",
"ESS_UPDATE_INTERCAL": "Energy storage update interval"
},
"description": "If 'Use descriptive entity names' is selected, device names\nwill be prepended on all entity names."
}
},
"error": {
"cannot_connect": "Cannot Connect",
"unknown": "Unkown Error"
"unknown": "Unknown Error"
},
"abort": {
"already_configured": "Already Configured"
22 changes: 19 additions & 3 deletions custom_components/sunpower/sunpower.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
""" Basic Sunpower PVS Tool """

import requests
import simplejson


class ConnectionException(Exception):
"""Any failure to connect to sunpower PVS"""


class ParseException(Exception):
"""Any failure to connect to sunpower PVS"""


class SunPowerMonitor:
"""Basic Class to talk to sunpower pvs 5/6 via the management interface 'API'. This is not a public API so it might fail at any time.
if you find this usefull please complain to sunpower and your sunpower dealer that they
"""Basic Class to talk to sunpower pvs 5/6 via the management interface 'API'.
This is not a public API so it might fail at any time.
if you find this useful please complain to sunpower and your sunpower dealer that they
do not have a public API"""

def __init__(self, host):
@@ -28,11 +32,23 @@ def generic_command(self, command):
raise ConnectionException from error
except simplejson.errors.JSONDecodeError as error:
raise ParseException from error

def device_list(self):
"""Get a list of all devices connected to the PVS"""
return self.generic_command("DeviceList")

def energy_storage_system_status(self):
"""Get the status of the energy storage system"""
try:
return requests.get(
"http://{0}/cgi-bin/dl_cgi/energy-storage-system/status".format(self.host),
timeout=120,
).json()
except requests.exceptions.RequestException as error:
raise ConnectionException from error
except simplejson.errors.JSONDecodeError as error:
raise ParseException from error

def network_status(self):
"""Get a list of network interfaces on the PVS"""
return self.generic_command("Get_Comm")
8 changes: 6 additions & 2 deletions custom_components/sunpower/translations/en.json
Original file line number Diff line number Diff line change
@@ -5,13 +5,17 @@
},
"error": {
"cannot_connect": "Cannot Connect",
"unknown": "Unkown Error"
"unknown": "Unknown Error"
},
"step": {
"user": {
"data": {
"host": "Host",
"use_descriptive_names": "Use descriptive entity names"
"use_descriptive_names": "Use descriptive entity names",
"use_product_names": "Use products in entity names",
"use_ess": "Use energy storage system",
"PVS_UPDATE_INTERVAL": "Solar data update interval",
"ESS_UPDATE_INTERCAL": "Energy storage update interval"
},
"description": "If 'Use descriptive entity names' is selected, device names\nwill be prepended on all entity names."
}
728 changes: 728 additions & 0 deletions samples/device_list.json

Large diffs are not rendered by default.

0 comments on commit f453254

Please sign in to comment.