Skip to content
Closed
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
custom_components/solis/__pycache__/*
.vscode/*

5 changes: 3 additions & 2 deletions custom_components/solis/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from .service import InverterService, ServiceSubscriber

_LOGGER = logging.getLogger(__name__)
RETRIES = 100


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities):
Expand All @@ -29,13 +28,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
service = hass.data[DOMAIN][config_entry.entry_id]

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

_LOGGER.debug(f" Attempt {attempts} succeeded")

if service.has_controls:
entities = []
_LOGGER.debug(f"Plant ID {plant_id} has controls:")
Expand Down
57 changes: 34 additions & 23 deletions custom_components/solis/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,26 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Flo
errors = {}

if user_input is not None:
updated_config = {}
for key in self.config_entry.data.keys():
updated_config[key] = self.config_entry.data[key]
updated_config[CONF_CONTROL] = False
for key in (CONF_CONTROL, CONF_PASSWORD, CONF_REFRESH_OK, CONF_REFRESH_NOK):
if key in user_input:
updated_config[key] = user_input[key]
updated_config = dict(self.config_entry.data)

control_section = user_input.get("Control") or {}
new_pw = control_section.get(CONF_PASSWORD)

if new_pw: # only overwrite if user actually typed something
updated_config[CONF_PASSWORD] = new_pw

updated_config[CONF_CONTROL] = control_section.get(CONF_CONTROL, updated_config.get(CONF_CONTROL, False))
updated_config[CONF_REFRESH_OK] = user_input.get(CONF_REFRESH_OK, updated_config.get(CONF_REFRESH_OK, 300))
updated_config[CONF_REFRESH_NOK] = user_input.get(
CONF_REFRESH_NOK, updated_config.get(CONF_REFRESH_NOK, 60)
)

self.hass.config_entries.async_update_entry(
self.config_entry,
data=updated_config,
title=user_input.get(CONF_NAME),
title=self.config_entry.title,
)

data_schema = {
vol.Required(CONF_REFRESH_OK, default=300): cv.positive_int,
vol.Required(CONF_REFRESH_NOK, default=60): cv.positive_int,
Expand Down Expand Up @@ -115,12 +122,12 @@ async def async_step_user(self, user_input=None):
errors: dict[str, str] = {}

if user_input is not None:
self._data = user_input
self._data = dict(user_input)
if user_input.get(CONF_PORTAL_VERSION) is None:
user_input[CONF_PORTAL_VERSION] = PLATFORMV2
if user_input.get(CONF_PORTAL_VERSION) == PLATFORMV2:
return await self.async_step_credentials_password(user_input)
return await self.async_step_credentials_secret(user_input)
self._data[CONF_PORTAL_VERSION] = PLATFORMV2
if self._data[CONF_PORTAL_VERSION] == PLATFORMV2:
return await self.async_step_credentials_password() # no arg
return await self.async_step_credentials_secret() # no arg

data_schema = {
vol.Required(CONF_NAME, default=SENSOR_PREFIX): cv.string,
Expand Down Expand Up @@ -160,7 +167,7 @@ async def async_step_credentials_password(self, user_input=None):

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

Expand All @@ -171,27 +178,32 @@ async def async_step_credentials_password(self, user_input=None):
)

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:
control_section = user_input.get("Control") or {}
password = control_section.get(CONF_PASSWORD) or user_input.get(CONF_PASSWORD)

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")
schedule_ok = user_input.get(CONF_REFRESH_OK)
schedule_nok = user_input.get(CONF_REFRESH_NOK)

# SECRET comes from top-level field
try:
secret = bytes(user_input.get(CONF_SECRET), "utf-8")
except TypeError:
pass
secret = b""

if url[:8] != "https://":
errors["base"] = "invalid_path"
else:
if username and key_id and secret and plant_id:
self._data.update(user_input)
# Merge nested section keys into _data so CONF_PASSWORD is stored
merged = dict(user_input)
merged.update(control_section) # brings CONF_PASSWORD, CONF_CONTROL to top
self._data.update(merged)

config = SoliscloudConfig(url, username, key_id, secret, plant_id, password)
api = SoliscloudAPI(config)
if await api.login(async_get_clientsession(self.hass)):
Expand All @@ -210,10 +222,9 @@ async def async_step_credentials_secret(self, user_input=None):
vol.Schema(
{
vol.Required(CONF_CONTROL, default=False): bool,
vol.Optional(CONF_PASSWORD, default=""): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
}
),
# Whether or not the section is initially collapsed (default = False)
{"collapsed": False},
),
}
Expand Down
47 changes: 30 additions & 17 deletions custom_components/solis/control_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@

from .const import API_NAME, DOMAIN, EMPTY_ATTR, SERIAL

RETRIES = 1000
RETRIES = 100
RETRY_WAIT = 10

_LOGGER = logging.getLogger(__name__)


class SolisBaseControlEntity:
_attr_entity_registry_enabled_default = True

def __init__(self, service, config_name, inverter_sn, cid, info):
self._measured: datetime | None = None
self._entity_type = "control"
Expand All @@ -37,7 +39,7 @@ def __init__(self, service, config_name, inverter_sn, cid, info):

@property
def unique_id(self) -> str:
return f"{self._platform_name}_{self._key}"
return f"{self._platform_name}_{self._inverter_sn}_{self._key}"

@property
def name(self) -> str:
Expand All @@ -58,10 +60,10 @@ def device_info(self) -> DeviceInfo | None:
identifiers={
(
DOMAIN,
f"{self._attributes[SERIAL]}_{DOMAIN, self._attributes[API_NAME]}",
f"{self._attributes[SERIAL]}_{self._attributes[API_NAME]}",
)
},
manufacturer=f"Solis {self._attributes[API_NAME]}",
manufacturer=f"Solis",
name=f"Solis_Inverter_{self._attributes[SERIAL]}",
)

Expand Down Expand Up @@ -1070,19 +1072,30 @@ class SolisButtonEntityDescription(ButtonEntityDescription):
name="Energy Storage Control Switch",
key="energy_storage_control_switch",
option_dict={
1: "Self-Use - No Grid Charging",
3: "Timed Charge/Discharge - No Grid Charging",
17: "Backup/Reserve - No Grid Charging",
33: "Self-Use - No Timed Charge/Discharge",
35: "Self-Use",
37: "Off-Grid Mode",
41: "Battery Awaken",
43: "Battery Awaken + Timed Charge/Discharge",
49: "Backup/Reserve - No Timed Charge/Discharge",
51: "Backup/Reserve",
64: "Feed-in priority - No Grid Charging",
96: "Feed-in priority - No Timed Charge/Discharge",
98: "Feed-in priority",
"35": "Self-Use - Allow Timed Charge/Discharge, Allow Grid Charging",
"3": "Self-Use - Allow Timed Charge/Discharge, No Grid Charging",
"33": "Self-Use - No Timed Charge/Discharge, Allow Grid Charging",
"1": "Self-Use - No Timed Charge/Discharge, No Grid Charging",

"51": "Self-Use with Backup - Allow Timed Charge/Discharge, Allow Grid Charging",
"19": "Self-Use with Backup - Allow Timed Charge/Discharge, No Grid Charging",
"49": "Self-Use with Backup - No Timed Charge/Discharge, Allow Grid Charging",
"17": "Self-Use with Backup - No Timed Charge/Discharge, No Grid Charging",

"43": "Battery Awaken - Allow Timed Charge/Discharge",
"41": "Battery Awaken - No Timed Charge/Discharge",

"98": "Feed-in Priority - Allow Timed Charge/Discharge, Allow Grid Charging",
"66": "Feed-in Priority - Allow Timed Charge/Discharge, No Grid Charging",
"96": "Feed-in Priority - No Timed Charge/Discharge, Allow Grid Charging",
"64": "Feed-in Priority - No Timed Charge/Discharge, No Grid Charging",

"114": "Feed-in Priority with Backup - Allow Timed Charge/Discharge, Allow Grid Charging",
"82": "Feed-in Priority with Backup - Allow Timed Charge/Discharge, No Grid Charging",
"112": "Feed-in Priority with Backup - No Timed Charge/Discharge, Allow Grid Charging",
"80": "Feed-in Priority with Backup - No Timed Charge/Discharge, No Grid Charging",

"37": "Off-Grid",
},
icon="mdi:dip-switch",
)
Expand Down
6 changes: 4 additions & 2 deletions custom_components/solis/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from .service import InverterService, ServiceSubscriber

_LOGGER = logging.getLogger(__name__)
RETRIES = 100
# RETRIES = 100


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities):
Expand All @@ -30,13 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
service = hass.data[DOMAIN][config_entry.entry_id]

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

_LOGGER.debug(f" Attempt {attempts} succeeded")

if service.has_controls:
entities = []
_LOGGER.debug(f"Plant ID {plant_id} has controls:")
Expand Down
4 changes: 3 additions & 1 deletion custom_components/solis/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
service = hass.data[DOMAIN][config_entry.entry_id]

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

_LOGGER.debug(f" Attempt {attempts} succeeded")

if service.has_controls:
entities = []
_LOGGER.debug(f"Plant ID {plant_id} has controls:")
Expand Down
7 changes: 5 additions & 2 deletions custom_components/solis/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ def on_discovered(capabilities, cookie):
class SolisSensor(ServiceSubscriber, SensorEntity):
"""Representation of a Solis sensor."""

_attr_entity_registry_enabled_default = True
_attr_has_entity_name = True

def __init__(
self,
ginlong_service: InverterService,
Expand Down Expand Up @@ -224,10 +227,10 @@ def device_info(self) -> DeviceInfo | None:
identifiers={
(
DOMAIN,
f"{self._attributes[SERIAL]}_{DOMAIN, self._attributes[API_NAME]}",
f"{self._attributes[SERIAL]}_{self._attributes[API_NAME]}",
)
},
manufacturer=f"Solis {self._attributes[API_NAME]}",
manufacturer=f"Solis",
name=f"Solis_Inverter_{self._attributes[SERIAL]}",
# model=config.modelid,
# sw_version=config.swversion,
Expand Down
9 changes: 4 additions & 5 deletions custom_components/solis/soliscloud_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,14 +391,14 @@ async def fetch_inverter_data(self, inverter_serial: str, controls=True) -> Ginl
self._collect_inverter_data(payload)
if inverter_serial not in self._hmi_fb00:
hmi_flag = self._data[HMI_VERSION_ALL]
self._hmi_fb00[inverter_serial] = int(hmi_flag, 16) >= int("4200", 16)
self._hmi_fb00[inverter_serial] = int(hmi_flag, 16) >= int("4b00", 16)
if self._hmi_fb00[inverter_serial]:
_LOGGER.debug(
f"HMI firmware version ({hmi_flag}) >=4200 for Inverter SN {inverter_serial} "
f"HMI firmware version ({hmi_flag}) >=4B00 for Inverter SN {inverter_serial} "
)
else:
_LOGGER.debug(
f"HMI firmware version ({hmi_flag}) <4200 for Inverter SN {inverter_serial} "
f"HMI firmware version ({hmi_flag}) <4B00 for Inverter SN {inverter_serial} "
)

if (self._token != "") and controls:
Expand Down Expand Up @@ -792,8 +792,7 @@ async def _fetch_token(self, username: str, password: str) -> str:
return ""

async def write_control_data(self, device_serial: str, cid: str, value: str):
_LOGGER.debug(f">>> Writing value {value} for cid {cid} to inverter {device_serial}")

_LOGGER.debug(f"Writing value {value} for cid {cid} to inverter {device_serial}")
params = {"inverterSn": str(device_serial), "cid": str(cid), "value": value}
result = await self._post_data_json(CONTROL, params, csrf=True)

Expand Down
5 changes: 3 additions & 2 deletions custom_components/solis/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from .service import InverterService, ServiceSubscriber

_LOGGER = logging.getLogger(__name__)
RETRIES = 100
YEAR = 2024
MONTH = 1
DAY = 1
Expand All @@ -32,13 +31,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn
service = hass.data[DOMAIN][config_entry.entry_id]

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

_LOGGER.debug(f" Attempt {attempts} succeeded")

if service.has_controls:
entities = []
_LOGGER.debug(f"Plant ID {plant_id} has controls:")
Expand Down