Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to Control Inverter #398

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
custom_components/solis/__pycache__/*

50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,56 @@
# ❗Request for maintainers
As you might have noticed I'm having trouble to spend enough time on maintaining this integration. For the continuity of this integration it would be great if it could be maintained and further developed by a small team of volunteers. Are you interested and do you have coding experience? [Drop me a line](https://github.com/hultenvp/solis-sensor/discussions/376).

# Control Beta Test

This repo includes a beta version of device control using the same API as the SolisCloud app. This opeartes slighty differently depending on your HMI firmware version. This should be detected automatically.

Please report any issues via https://github.com/fboundy/solis-sensor/issues

## Version 4A00 and Earlier

The following controls should update the inverter immediately:

- Energy Storage Control Switch
- Overdischarge SOC
- Force Charge SOC
- Backup SOC

The timed change controls are all sent to the API using one command and so they won't update untill the Update Charge/Discharge button is pressed. The controls included in this are all three sets of the following (where N is slots 1-3)
- Timed Charge Current N
- Timed Charge Start Time N
- Timed Charge End Time N
- Timed Discharge Current N
- Timed Discharge Start Time N
- Timed Discharge End Time N

<b>Note that all three slots are sent at the same time</b>

## Version 4B00 and Later

Six slots are available and include an SOC limit and a voltage (though the purpose of the voltage is not clear). Only the start and end times for each Charge/Discharge slot need top to be sent to the inverter together so the following are updated immediately (where N is slot 1-6):
- Energy Storage Control Switch (fewer available modes than pre-4B00)
- Overdischarge SOC
- Force Charge SOC
- Backup SOC
- Timed Charge Current N
- Timed Charge SOC N
- Timed Charge Voltage N
- Timed Discharge Current N
- Timed Discharge SOC N
- Timed Discharge Voltage N

Each pair of start/end times has an associated button pushfor charge there are 6:

- Timed Charge Start Time N
- Timed Charge End Time N
- Button Update Charge Time N

And discharge:
- Timed Discharge Start Time N
- Timed Discharge End Time N
- Button Update Discharge Time N

# SolisCloud sensor integration
HomeAssistant sensor for SolisCloud portal.
Still questions after the readme? Read the [wiki](https://github.com/hultenvp/solis-sensor/wiki) or look at the [discussions page](https://github.com/hultenvp/solis-sensor/discussions)
Expand Down
41 changes: 28 additions & 13 deletions custom_components/solis/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""The Solis Inverter integration."""

from datetime import datetime, timedelta, timezone
import asyncio
import logging
Expand Down Expand Up @@ -31,7 +32,20 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.SENSOR]
PLATFORMS = [
Platform.SENSOR,
Platform.SELECT,
Platform.NUMBER,
Platform.TIME,
Platform.BUTTON,
]

CONTROL_PLATFORMS = [
Platform.SELECT,
Platform.NUMBER,
Platform.TIME,
Platform.BUTTON,
]


async def async_setup(hass: HomeAssistant, config: ConfigType):
Expand All @@ -57,9 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType):
return True


async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up platform from a ConfigEntry."""

hass.data.setdefault(DOMAIN, {})
Expand All @@ -70,35 +82,38 @@ async def async_setup_entry(
portal_plantid = config[CONF_PLANT_ID]
portal_username = config[CONF_USERNAME]
portal_version = config[CONF_PORTAL_VERSION]
portal_password = config[CONF_PASSWORD]

portal_config: PortalConfig | None = None
if portal_version == "ginlong_v2":
portal_password = config[CONF_PASSWORD]
portal_config = GinlongConfig(
portal_domain, portal_username, portal_password, portal_plantid)
portal_config = GinlongConfig(portal_domain, portal_username, portal_password, portal_plantid)
else:
portal_key_id = config[CONF_KEY_ID]
portal_secret: bytes = bytes(config[CONF_SECRET], 'utf-8')
portal_secret: bytes = bytes(config[CONF_SECRET], "utf-8")
portal_config = SoliscloudConfig(
portal_domain, portal_username, portal_key_id, portal_secret, portal_plantid)
portal_domain, portal_username, portal_key_id, portal_secret, portal_plantid, portal_password
)

# Initialize the Ginlong data service.
service: InverterService = InverterService(portal_config, hass)
hass.data[DOMAIN][entry.entry_id] = service

# Forward the setup to the sensor platform.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
# while not service.discovery_complete:
# asyncio.sleep(1)
_LOGGER.debug("Sensor setup complete")
await hass.config_entries.async_forward_entry_setups(entry, CONTROL_PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""

unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
*[hass.config_entries.async_forward_entry_unload(entry, component) for component in PLATFORMS]
)
)

Expand Down
87 changes: 87 additions & 0 deletions custom_components/solis/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.components.button import ButtonEntity


import asyncio
import logging
from datetime import datetime

from .const import (
DOMAIN,
)

from .service import ServiceSubscriber, InverterService
from .control_const import SolisBaseControlEntity, RETRIES, RETRY_WAIT, ALL_CONTROLS, SolisButtonEntityDescription

_LOGGER = logging.getLogger(__name__)
RETRIES = 100


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities):
"""Setup sensors from a config entry created in the integrations UI."""
# Prepare the sensor entities.
plant_id = config_entry.data["portal_plant_id"]
_LOGGER.debug(f"config_entry.data: {config_entry.data}")
_LOGGER.debug(f"Domain: {DOMAIN}")
service = hass.data[DOMAIN][config_entry.entry_id]

_LOGGER.info(f"Waiting for discovery of controls for plant {plant_id}")
await asyncio.sleep(8)
attempts = 0
while (attempts < RETRIES) and (not service.has_controls):
_LOGGER.debug(f" Attempt {attempts} failed")
await asyncio.sleep(RETRY_WAIT)
attempts += 1

if service.has_controls:
entities = []
_LOGGER.debug(f"Plant ID {plant_id} has controls:")
for inverter_sn in service.controls:
for cid, index, entity, button, intial_value in service.controls[inverter_sn]["button"]:
entities.append(
SolisButtonEntity(
service,
config_entry.data["name"],
inverter_sn,
cid,
entity,
index,
)
)

if len(entities) > 0:
_LOGGER.debug(f"Creating {len(entities)} Button entities")
async_add_entities(entities)
else:
_LOGGER.debug(f"No Button controls found for Plant ID {plant_id}")

else:
_LOGGER.debug(f"No controls found for Plant ID {plant_id}")

return True


class SolisButtonEntity(SolisBaseControlEntity, ServiceSubscriber, ButtonEntity):
def __init__(self, service: InverterService, config_name, inverter_sn, cid, button_info, index):
super().__init__(service, config_name, inverter_sn, cid, button_info)
self._index = index
self._joiner = button_info.joiner
self._entities = service.subscriptions.get(inverter_sn, {}).get(cid, [])
# Subscribe to the service with the cid as the index
# service.subscribe(self, inverter_sn, str(cid))

def do_update(self, value, last_updated):
# When the data from the API changes this method will be called with value as the new value
# return super().do_update(value, last_updated)
pass

async def async_press(self) -> None:
"""Handle the button press."""
for entity in self._entities:
_LOGGER.debug(f"{entity.name:s} {entity.to_string:s} {entity.index}")
# Sort the entities by their index
items = sorted({entity.index: entity.to_string for entity in self._entities}.items())
value = self._joiner.join([x[1] for x in items])
_LOGGER.debug(f"{self._cid} {value}")
await self.write_control_data(value)
66 changes: 33 additions & 33 deletions custom_components/solis/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Config flow for Solis integration."""

import logging

import voluptuous as vol
Expand Down Expand Up @@ -29,6 +30,7 @@
PLATFORMV2 = "ginlong_v2"
SOLISCLOUD = "soliscloud"


class SolisConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Solis."""

Expand All @@ -47,30 +49,30 @@ async def async_step_user(self, user_input=None):
return await self.async_step_credentials_password(user_input)
return await self.async_step_credentials_secret(user_input)

data_schema= {
data_schema = {
vol.Required(CONF_NAME, default=SENSOR_PREFIX): cv.string,
vol.Required(CONF_PORTAL_DOMAIN, default=DEFAULT_DOMAIN): cv.string,
}
data_schema[CONF_PORTAL_VERSION] = selector({
"select": {
"options": [PLATFORMV2, SOLISCLOUD],
data_schema[CONF_PORTAL_VERSION] = selector(
{
"select": {
"options": [PLATFORMV2, SOLISCLOUD],
}
}
})
)

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

async def async_step_credentials_password(self, user_input=None):
"""Handle username/password based credential settings."""
errors: dict[str, str] = {}

if user_input is not None:
url = self._data.get(CONF_PORTAL_DOMAIN)
url = self._data.get(CONF_PORTAL_DOMAIN)
plant_id = user_input.get(CONF_PLANT_ID)
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
if url[:8] != 'https://':
if url[:8] != "https://":
errors["base"] = "invalid_path"
else:
if username and password and plant_id:
Expand All @@ -79,57 +81,55 @@ async def async_step_credentials_password(self, user_input=None):
api = GinlongAPI(config)
if await api.login(async_get_clientsession(self.hass)):
await self.async_set_unique_id(plant_id)
return self.async_create_entry(title=f"Plant {api.plant_name}",
data=self._data)
return self.async_create_entry(title=f"Plant {api.plant_name}", data=self._data)

errors["base"] = "auth"

data_schema= {
vol.Required(CONF_USERNAME , default=None): cv.string,
vol.Required(CONF_PASSWORD , default=''): cv.string,
data_schema = {
vol.Required(CONF_USERNAME, default=None): cv.string,
vol.Required(CONF_PASSWORD, default=""): cv.string,
vol.Required(CONF_PLANT_ID, default=None): cv.positive_int,
}

return self.async_show_form(step_id="credentials_password",
data_schema=vol.Schema(data_schema), errors=errors)
return self.async_show_form(step_id="credentials_password", data_schema=vol.Schema(data_schema), errors=errors)

async def async_step_credentials_secret(self, user_input=None):
"""Handle key_id/secret based credential settings."""
errors: dict[str, str] = {}

if user_input is not None:
url = self._data.get(CONF_PORTAL_DOMAIN)
url = self._data.get(CONF_PORTAL_DOMAIN)
plant_id = user_input.get(CONF_PLANT_ID)
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
key_id = user_input.get(CONF_KEY_ID)
secret: bytes = bytes('', 'utf-8')
secret: bytes = bytes("", "utf-8")
try:
secret = bytes(user_input.get(CONF_SECRET), 'utf-8')
secret = bytes(user_input.get(CONF_SECRET), "utf-8")
except TypeError:
pass
if url[:8] != 'https://':
if url[:8] != "https://":
errors["base"] = "invalid_path"
else:
if username and key_id and secret and plant_id:
self._data.update(user_input)
config = SoliscloudConfig(url, username, key_id, secret, plant_id)
config = SoliscloudConfig(url, username, key_id, secret, plant_id, password)
api = SoliscloudAPI(config)
if await api.login(async_get_clientsession(self.hass)):
await self.async_set_unique_id(plant_id)
return self.async_create_entry(title=f"Station {api.plant_name}",
data=self._data)
return self.async_create_entry(title=f"Station {api.plant_name}", data=self._data)

errors["base"] = "auth"

data_schema={
vol.Required(CONF_USERNAME , default=None): cv.string,
vol.Required(CONF_SECRET , default='00'): cv.string,
vol.Required(CONF_KEY_ID , default=''): cv.string,
data_schema = {
vol.Required(CONF_USERNAME, default=None): cv.string,
vol.Required(CONF_PASSWORD, default=""): cv.string,
vol.Required(CONF_SECRET, default="00"): cv.string,
vol.Required(CONF_KEY_ID, default=""): cv.string,
vol.Required(CONF_PLANT_ID, default=None): cv.string,
}

return self.async_show_form(step_id="credentials_secret",
data_schema=vol.Schema(data_schema), errors=errors)
return self.async_show_form(step_id="credentials_secret", data_schema=vol.Schema(data_schema), errors=errors)

async def async_step_import(self, user_input=None):
"""Import a config entry from configuration.yaml."""
Expand All @@ -141,14 +141,14 @@ async def async_step_import(self, user_input=None):
_LOGGER.warning(_str)
return self.async_abort(reason="already_configured")
url = user_input.get(CONF_PORTAL_DOMAIN)
if url[:4] != 'http':
if url[:4] != "http":
# Fix URL
url = f"https://{url}"
user_input[CONF_PORTAL_DOMAIN] = url

user_input[CONF_PORTAL_VERSION] = PLATFORMV2
has_key_id = user_input.get(CONF_KEY_ID) != ''
has_secret: bytes = bytes(user_input.get(CONF_SECRET), 'utf-8') != b'\x00'
has_key_id = user_input.get(CONF_KEY_ID) != ""
has_secret: bytes = bytes(user_input.get(CONF_SECRET), "utf-8") != b"\x00"
if has_key_id and has_secret:
user_input[CONF_PORTAL_VERSION] = SOLISCLOUD

Expand Down
Loading