Skip to content

Commit 064e009

Browse files
authored
Merge pull request #461 from Nino6689/feat/poll-profiles
Add extreme poll profile for tight control loops
2 parents 5e6945c + 0dd70ef commit 064e009

18 files changed

Lines changed: 690 additions & 59 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ Whilst the solis inverters do provide total sensors for today, yesterday, month
5858
- **Storage Mode select** (renamed from "Work Mode", issue #413): mode changes now clear all conflicting mode bits — switching Self-Use ↔ Peak Shaving ↔ Feed-in writes exactly the values SolisCloud uses (grid-charge and wakeup bits are preserved). New *Reserve / Backup* options. The raw register value is exposed as a state attribute for debugging.
5959
- **Dual-meter support** (issue #425): enable *"Second smart meter installed"* to poll the Meter 2 block (33300-33337) on "Grid + PV Inverter" installs — per-phase V/A/W, total power, PF, frequency and lifetime import/export counters.
6060
- **Direct-meter grid energy for string inverters** (issue #410): grid import/export lifetime counters read from the attached meter (registers 3283-3286), for S5-GR3P-style installs without an EPM.
61-
- **Read-only mode completed** (issue #149): with *"Essential sensors only"* enabled, control entities (numbers/switches/selects/times) are no longer created.
61+
- **Read-only mode completed** (issue #149): under either reduced **poll profile**, control entities (numbers/switches/selects/times) are no longer created.
62+
- **Poll profiles** (issue #457): the *"Essential sensors only"* toggle is now a **Poll profile** select — *Full*, *Essential* or *Extreme*. Extreme polls only the live meter/CT and PV groups (plus the one-off identity groups), which is few enough Modbus frames that the fast-interval floor drops from 10s to **2s** — for tight control loops such as export limiting against grid voltage rise (issue #451). An opt-in toggle adds the battery/load group (SOC, household load, battery and grid-port power) for battery-aware automations. Existing `essential_only` settings migrate automatically. Entities outside the chosen profile are **not created**, rather than created and left unavailable.
63+
Extreme mode is an optimisation, **not** a protection or compliance mechanism: a Home Assistant control loop cannot satisfy G100's requirement that an export limitation scheme hold its limit through component or communications failure.
6264
- **Diagnostics**: download a redacted support dump from the integration's menu.
6365
- **Repairs**: a repair issue is raised when the datalogger is unreachable for a sustained period, and auto-clears on reconnect.
6466
- New AC-grid-port lifetime energy sensors (registers 33186-33189, protocol Ver3.4).

custom_components/solis_modbus/__init__.py

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
CONF_CONNECTION_TYPE,
1919
CONF_INVERTER_SERIAL,
2020
CONF_PARITY,
21+
CONF_POLL_PROFILE,
2122
CONF_SERIAL_PORT,
2223
CONF_SLAVE,
2324
CONF_STOPBITS,
@@ -29,15 +30,23 @@
2930
DEFAULT_STOPBITS,
3031
DOMAIN,
3132
MODBUS_ILLEGAL_DATA_ADDRESS,
33+
POLL_PROFILE_ESSENTIAL,
34+
POLL_PROFILE_EXTREME,
35+
POLL_PROFILE_FULL,
3236
)
3337
from .data.solis_config import SOLIS_INVERTERS, InverterConfig, InverterType, inverter_options_from_config
3438
from .data_retrieval import DataRetrieval
3539
from .helpers import (
3640
combine_u32,
3741
combine_u32_le,
42+
derived_sensor_is_supported,
43+
extreme_includes_battery,
3844
get_controller,
45+
get_poll_profile,
46+
group_in_poll_profile,
3947
iter_controllers,
4048
iter_platform_entities,
49+
registers_declared_by,
4150
set_controller,
4251
split_s32,
4352
unique_id_generator,
@@ -554,28 +563,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
554563
# call async_unload_entry when async_setup_entry raises).
555564
try:
556565
controller._sensor_groups = []
557-
essential_only = config.get("essential_only", False)
558-
skipped_essential = 0
566+
poll_profile = get_poll_profile(entry)
567+
include_battery = extreme_includes_battery(entry)
568+
569+
# A profile that matches nothing would set the entry up with no sensors at
570+
# all, which reads as a broken integration. Extreme currently only maps the
571+
# hybrid groups, so fall back rather than silently produce an empty entry.
572+
if poll_profile == POLL_PROFILE_EXTREME and not any(group.get("extreme") for group in sensors):
573+
_LOGGER.warning(
574+
"Extreme poll profile is not mapped for this inverter type yet; falling back to essential-only polling",
575+
)
576+
poll_profile = POLL_PROFILE_ESSENTIAL
577+
578+
skipped_by_profile = 0
579+
selected_groups = []
559580
for group in sensors:
560581
feature_requirement = group.get("feature_requirement", [])
561582
if feature_requirement and not any(feature in inverter_config.features for feature in feature_requirement):
562583
group_name = group.get("name", group.get("register_start", "Unnamed"))
563584
_LOGGER.warning(f"Skipping sensor group '{group_name}' due to missing required features: {feature_requirement}")
564585
continue
565586

566-
if essential_only and not group.get("essential", False):
567-
skipped_essential += 1
587+
if not group_in_poll_profile(group, poll_profile, include_battery):
588+
skipped_by_profile += 1
568589
continue
569590

591+
selected_groups.append(group)
570592
controller._sensor_groups.append(SolisSensorGroup(hass=hass, definition=group, controller=controller, identification=identification))
571593

572-
if essential_only:
594+
if poll_profile != POLL_PROFILE_FULL:
573595
_LOGGER.info(
574-
"Essential-only polling enabled: %d sensor group(s) skipped, %d remaining (reduces datalogger load)",
575-
skipped_essential,
596+
"Poll profile '%s' active: %d sensor group(s) skipped, %d remaining (reduces datalogger load)",
597+
poll_profile,
598+
skipped_by_profile,
576599
len(controller._sensor_groups),
577600
)
578601

602+
# Derived sensors are computed from registers other groups poll, so a
603+
# reduced profile has to filter them too — otherwise e.g. Power Factor
604+
# (33079-33082) survives into extreme mode and never receives a value.
605+
polled_registers = registers_declared_by(selected_groups)
606+
known_registers = registers_declared_by(sensors)
607+
supported_derived = [entity for entity in sensors_derived if derived_sensor_is_supported(entity, polled_registers, known_registers)]
608+
609+
if poll_profile != POLL_PROFILE_FULL and len(supported_derived) != len(sensors_derived):
610+
_LOGGER.info(
611+
"Poll profile '%s': %d derived sensor(s) skipped, their source registers are not polled",
612+
poll_profile,
613+
len(sensors_derived) - len(supported_derived),
614+
)
615+
579616
controller._derived_sensors = [
580617
SolisBaseSensor(
581618
hass=hass,
@@ -592,7 +629,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
592629
category=entity.get("category", None),
593630
unique_id=unique_id_generator(controller, entity.get("unique", "reserve")),
594631
)
595-
for entity in sensors_derived
632+
for entity in supported_derived
596633
]
597634

598635
set_controller(hass, controller, entry)
@@ -910,10 +947,43 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
910947
await async_migrate_dict_unique_ids(hass, config_entry)
911948
hass.config_entries.async_update_entry(config_entry, version=4)
912949

950+
if config_entry.version == 4:
951+
_migrate_essential_only_to_poll_profile(hass, config_entry)
952+
hass.config_entries.async_update_entry(config_entry, version=5)
953+
913954
_LOGGER.info("Migration to version %s successful", config_entry.version)
914955
return True
915956

916957

958+
def _migrate_essential_only_to_poll_profile(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
959+
"""Fold the `essential_only` boolean into the `poll_profile` select (#457).
960+
961+
Both data and options are rewritten: setup merges {**data, **options}, so a
962+
leftover `essential_only` in options would shadow a migrated data key and
963+
quietly keep the old behaviour.
964+
"""
965+
merged = {**config_entry.data, **config_entry.options}
966+
existing = merged.get(CONF_POLL_PROFILE)
967+
had_legacy_key = "essential_only" in config_entry.data or "essential_only" in config_entry.options
968+
969+
if existing is not None and not had_legacy_key:
970+
return
971+
972+
if existing is not None:
973+
# Already on a profile, but a stale boolean is still present. Keep the
974+
# chosen profile and drop the dead key rather than letting it linger.
975+
profile = existing
976+
else:
977+
profile = POLL_PROFILE_ESSENTIAL if merged.get("essential_only", False) else POLL_PROFILE_FULL
978+
979+
data = {k: v for k, v in config_entry.data.items() if k != "essential_only"}
980+
options = {k: v for k, v in config_entry.options.items() if k != "essential_only"}
981+
data[CONF_POLL_PROFILE] = profile
982+
983+
hass.config_entries.async_update_entry(config_entry, data=data, options=options)
984+
_LOGGER.info("Migrated essential_only=%s to poll_profile='%s'", merged.get("essential_only", False), profile)
985+
986+
917987
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
918988
"""Unload a Modbus config entry."""
919989
_LOGGER.debug("init async_unload_entry")

custom_components/solis_modbus/config_flow.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
CONF_BAUDRATE,
1212
CONF_BYTESIZE,
1313
CONF_CONNECTION_TYPE,
14+
CONF_EXTREME_INCLUDE_BATTERY,
1415
CONF_INVERTER_SERIAL,
1516
CONF_PARITY,
17+
CONF_POLL_PROFILE,
1618
CONF_SERIAL_PORT,
1719
CONF_STOPBITS,
1820
CONN_TYPE_SERIAL,
@@ -22,6 +24,11 @@
2224
DEFAULT_PARITY,
2325
DEFAULT_STOPBITS,
2426
DOMAIN,
27+
POLL_INTERVAL_FAST_MIN,
28+
POLL_INTERVAL_FAST_MIN_EXTREME,
29+
POLL_PROFILE_EXTREME,
30+
POLL_PROFILE_FULL,
31+
POLL_PROFILES,
2532
)
2633
from .data.enums import InverterType
2734
from .data.solis_config import CONNECTION_METHOD, SOLIS_INVERTERS, InverterConfig, inverter_options_from_config
@@ -42,10 +49,14 @@
4249
vol.Required(CONF_CONNECTION_TYPE, default=CONN_TYPE_TCP): vol.In(CONNECTION_TYPES),
4350
vol.Required(CONF_INVERTER_SERIAL): str,
4451
vol.Required("slave", default=1): int,
45-
vol.Optional("poll_interval_fast", default=10): vol.All(int, vol.Range(min=10)),
52+
# Floor is the extreme-profile minimum; _validate_poll_interval enforces the
53+
# stricter default floor, which voluptuous can't do since the profile is
54+
# chosen on this same form.
55+
vol.Optional("poll_interval_fast", default=POLL_INTERVAL_FAST_MIN): vol.All(int, vol.Range(min=POLL_INTERVAL_FAST_MIN_EXTREME)),
4656
vol.Optional("poll_interval_normal", default=15): vol.All(int, vol.Range(min=15)),
4757
vol.Optional("poll_interval_slow", default=30): vol.All(int, vol.Range(min=30)),
48-
vol.Required("essential_only", default=False): bool,
58+
vol.Required(CONF_POLL_PROFILE, default=POLL_PROFILE_FULL): vol.In(POLL_PROFILES),
59+
vol.Required(CONF_EXTREME_INCLUDE_BATTERY, default=False): bool,
4960
vol.Required("model", default=list(SOLIS_MODELS.keys())[0]): vol.In(SOLIS_MODELS),
5061
# Boolean options (Yes/No toggle)
5162
vol.Required("has_v2", default=True): bool,
@@ -79,10 +90,11 @@
7990

8091
OPTIONS_SCHEMA = vol.Schema(
8192
{
82-
vol.Required("poll_interval_fast"): vol.All(int, vol.Range(min=10)),
93+
vol.Required("poll_interval_fast"): vol.All(int, vol.Range(min=POLL_INTERVAL_FAST_MIN_EXTREME)),
8394
vol.Required("poll_interval_normal"): vol.All(int, vol.Range(min=15)),
8495
vol.Required("poll_interval_slow"): vol.All(int, vol.Range(min=30)),
85-
vol.Required("essential_only", default=False): bool,
96+
vol.Required(CONF_POLL_PROFILE, default=POLL_PROFILE_FULL): vol.In(POLL_PROFILES),
97+
vol.Required(CONF_EXTREME_INCLUDE_BATTERY, default=False): bool,
8698
vol.Required("model"): vol.In(SOLIS_MODELS),
8799
vol.Required("connection", default=list(CONNECTION_METHOD.keys())[0]): vol.In(CONNECTION_METHOD),
88100
# Boolean options (Yes/No toggle)
@@ -99,6 +111,21 @@
99111
)
100112

101113

114+
def _validate_poll_interval(config: dict) -> str | None:
115+
"""Enforce the fast-poll floor that applies to the chosen profile.
116+
117+
The schemas only declare the extreme floor, because the profile is picked on
118+
the same form and voluptuous can't vary a field's range by another field's
119+
value. Returns an error key, or None when the interval is acceptable.
120+
"""
121+
profile = config.get(CONF_POLL_PROFILE, POLL_PROFILE_FULL)
122+
floor = POLL_INTERVAL_FAST_MIN_EXTREME if profile == POLL_PROFILE_EXTREME else POLL_INTERVAL_FAST_MIN
123+
interval = config.get("poll_interval_fast")
124+
if interval is not None and int(interval) < floor:
125+
return "poll_interval_below_floor"
126+
return None
127+
128+
102129
async def _probe_tcp_port(host: str, port: int, timeout: float = 5.0) -> tuple[bool, str | None]:
103130
"""Check that something is actually accepting TCP connections on host:port.
104131
@@ -137,7 +164,7 @@ def clean_identification(iden: str | None) -> str | None:
137164
class ModbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
138165
"""Modbus configuration flow."""
139166

140-
VERSION = 4
167+
VERSION = 5
141168
MINOR_VERSION = 0
142169

143170
def __init__(self):
@@ -211,6 +238,8 @@ async def async_step_reconfigure(self, user_input=None):
211238
elif any(other.unique_id == serial and other.entry_id != entry.entry_id for other in self.hass.config_entries.async_entries(DOMAIN)):
212239
# Another entry already manages this inverter
213240
return self.async_abort(reason="already_configured")
241+
elif interval_error := _validate_poll_interval(data):
242+
errors["base"] = interval_error
214243
else:
215244
valid, err_key = await self._validate_config(data)
216245
if valid:
@@ -243,7 +272,8 @@ async def _create_entry_from_input(self, data):
243272
data[CONF_INVERTER_SERIAL] = str(data[CONF_INVERTER_SERIAL]).upper()
244273

245274
# 2. Validate Connection
246-
valid, err_key = await self._validate_config(data)
275+
interval_error = _validate_poll_interval(data)
276+
valid, err_key = (False, interval_error) if interval_error else await self._validate_config(data)
247277
if not valid:
248278
errors["base"] = err_key or "cannot_connect"
249279

@@ -353,7 +383,10 @@ async def async_step_init(self, user_input=None):
353383

354384
if user_input is not None:
355385
merged = {**self.config_entry.options, **user_input}
356-
return self.async_create_entry(title="", data=merged)
386+
interval_error = _validate_poll_interval(merged)
387+
if interval_error is None:
388+
return self.async_create_entry(title="", data=merged)
389+
errors["base"] = interval_error
357390

358391
current = {**self.config_entry.data, **self.config_entry.options}
359392
return self.async_show_form(

custom_components/solis_modbus/const.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,30 @@
3737
# Modbus exception code 2: the slave has no register at the requested address.
3838
# Distinguishes "you asked for the wrong thing" from "the read failed".
3939
MODBUS_ILLEGAL_DATA_ADDRESS = 2
40+
41+
# Poll profiles (issue #457): how much of the register map gets polled.
42+
#
43+
# FULL — every group the inverter's features allow.
44+
# ESSENTIAL — the core power-flow groups only (issue #412), no 43xxx settings
45+
# groups, so writable entities are not created.
46+
# EXTREME — live meter/CT + PV only, for tight control loops (issue #451).
47+
# Small enough that a 2s fast interval is realistic: each group is
48+
# one Modbus frame and the Solis spec requires >300ms between frames,
49+
# so a full ~11-group pass can never be that fast.
50+
POLL_PROFILE_FULL = "full"
51+
POLL_PROFILE_ESSENTIAL = "essential"
52+
POLL_PROFILE_EXTREME = "extreme"
53+
54+
CONF_POLL_PROFILE = "poll_profile"
55+
CONF_EXTREME_INCLUDE_BATTERY = "extreme_include_battery"
56+
57+
POLL_PROFILES = {
58+
POLL_PROFILE_FULL: "Full (all sensors)",
59+
POLL_PROFILE_ESSENTIAL: "Essential only (reduce datalogger load)",
60+
POLL_PROFILE_EXTREME: "Extreme (meter/CT + PV live only, for control loops)",
61+
}
62+
63+
# Minimum fast-poll interval. The default floor protects the bus for everyone;
64+
# extreme mode polls few enough frames that a tighter loop is safe.
65+
POLL_INTERVAL_FAST_MIN = 10
66+
POLL_INTERVAL_FAST_MIN_EXTREME = 2

0 commit comments

Comments
 (0)