Skip to content

Commit f3451c2

Browse files
authored
Merge branch 'master' into fix/deprecated-device-registry-lookup
2 parents 5fc7362 + 55c92b1 commit f3451c2

11 files changed

Lines changed: 128 additions & 10 deletions

‎.github/workflows/stale.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ jobs:
2020
stale-pr-message: "This PR has been automatically marked as stale due to inactivity. It will be closed if no further updates are made."
2121
close-issue-message: "Closing this issue due to inactivity. Feel free to reopen if needed."
2222
close-pr-message: "Closing this PR due to inactivity."
23-
days-before-stale: 21 # Number of days before marking as stale
24-
days-before-close: 7 # Number of days before closing after being marked stale
23+
days-before-stale: 60 # Number of days before marking as stale
24+
days-before-close: 21 # Number of days before closing after being marked stale
2525
exempt-issue-labels: "pinned,security,deprecation" # Issues with these labels won't be marked stale
2626
exempt-pr-labels: "pinned,security,deprecation"
2727
remove-stale-when-updated: true

‎custom_components/solis_modbus/__init__.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@
120120
DISPATCH_SOC_HIGH_REG = 44110
121121
DISPATCH_SCHEDULE_BASE = 44116 # 6 periods x 14 registers
122122
DISPATCH_SCHEDULE_STRIDE = 14
123+
DISPATCH_LIMIT_UNIT = 100
124+
DISPATCH_LIMIT_DEFAULT = 0xFFFF
123125

124126
# mode -> (44105 value, sign applied to power_watts); modes 3/4: +export/-import
125127
DISPATCH_MODES = {
@@ -143,6 +145,8 @@
143145
vol.Optional("disable_discharge"): vol.Coerce(bool),
144146
vol.Optional("soc_min"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
145147
vol.Optional("soc_max"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
148+
vol.Optional("import_limit_watts"): vol.All(vol.Coerce(int), vol.Range(min=0, max=240000)),
149+
vol.Optional("export_limit_watts"): vol.All(vol.Coerce(int), vol.Range(min=0, max=240000)),
146150
vol.Optional("failsafe_minutes", default=30): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)),
147151
vol.Optional("host"): vol.Coerce(str),
148152
vol.Optional("slave", default=1): vol.Coerce(int),
@@ -190,6 +194,24 @@ def _s32_words(value: int) -> list[int]:
190194
return [(raw >> 16) & 0xFFFF, raw & 0xFFFF]
191195

192196

197+
def _dispatch_system_limits(import_limit_watts, export_limit_watts) -> tuple[int, int, int]:
198+
"""Build 44102/44103/44104. Omitted = switch off + 0xFFFF (inverter default)."""
199+
200+
def raw(watts) -> int:
201+
return (int(watts) + DISPATCH_LIMIT_UNIT // 2) // DISPATCH_LIMIT_UNIT
202+
203+
switches = 0
204+
import_raw = DISPATCH_LIMIT_DEFAULT
205+
export_raw = DISPATCH_LIMIT_DEFAULT
206+
if import_limit_watts is not None:
207+
switches |= 0b01
208+
import_raw = raw(import_limit_watts)
209+
if export_limit_watts is not None:
210+
switches |= 0b10
211+
export_raw = raw(export_limit_watts)
212+
return switches, import_raw, export_raw
213+
214+
193215
async def async_remove_config_entry_device(hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry) -> bool:
194216
"""Remove a config entry from a device."""
195217
return True
@@ -354,15 +376,16 @@ async def service_dispatch(call: ServiceCall) -> None:
354376
function_value = _dispatch_function_value(call.data.get("pv_shutdown"), call.data.get("allow_grid_charge"), call.data.get("disable_discharge"))
355377
soc_low = int(call.data["soc_min"]) if call.data.get("soc_min") is not None else 0
356378
soc_high = int(call.data["soc_max"]) if call.data.get("soc_max") is not None else 100
379+
switches, import_raw, export_raw = _dispatch_system_limits(call.data.get("import_limit_watts"), call.data.get("export_limit_watts"))
357380

358381
# The dispatch block must be written as contiguous chunks (Ver3.4 doc):
359382
# scattered single-register writes get silently dropped/re-initialized,
360383
# especially under write-queue contention. Two atomic FC16 blocks:
361-
# global 44100-44104 = master, failsafe, no system caps (0xFFFF = default)
384+
# global 44100-44104 = master, failsafe, limit switch, import/export caps
362385
# realtime 44105-44112 = mode, power(S32), function, SOC window
363386
# Global first so dispatch is active before the realtime block lands
364387
# (the function field is re-initialized unless the master is already on).
365-
await controller.async_write_holding_registers(DISPATCH_MASTER_REG, [1, int(call.data.get("failsafe_minutes", 30)), 0, 0xFFFF, 0xFFFF])
388+
await controller.async_write_holding_registers(DISPATCH_MASTER_REG, [1, int(call.data.get("failsafe_minutes", 30)), switches, import_raw, export_raw])
366389
await controller.async_write_holding_registers(DISPATCH_MODE_REG, [mode_value, *_s32_words(power_raw), function_value, soc_low, soc_high, 0, 0])
367390

368391
async def service_dispatch_stop(call: ServiceCall) -> None:

‎custom_components/solis_modbus/client_manager.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
_LOGGER = logging.getLogger(__name__)
1010

11+
# Solis RS485_MODBUS protocol (Hybrid Inverter V3.1 / PV Grid-Connected Inverter V19),
12+
# section 3.2 "Inter-frame interval requirement": "More than 300ms communications frame
13+
# interval is required." Applies to every request on the link, read or write. 310ms gives
14+
# a small margin above the documented floor (see issue #480).
15+
MIN_INTER_FRAME_MS = 310
16+
1117

1218
class ModbusClientManager:
1319
_instance = None
@@ -102,7 +108,7 @@ async def inter_frame_wait(self, connection_id: str, is_write: bool = False) ->
102108
"""Minimum spacing between Modbus operations on one TCP/serial link, across all controllers sharing it."""
103109
if connection_id not in self._clients:
104110
return
105-
delay_ms = 100 if is_write else 50
111+
delay_ms = MIN_INTER_FRAME_MS
106112
entry = self._clients[connection_id]
107113
current_time = time.perf_counter()
108114
last = float(entry.get("last_modbus_request", 0.0))

‎custom_components/solis_modbus/helpers.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,8 @@ def mark_platform_entities_unavailable_for_base_sensors(hass: HomeAssistant, dis
208208
base = getattr(ent, "base_sensor", None)
209209
if base is not None and base in disabled_set:
210210
ent._attr_available = False
211-
ent.schedule_update_ha_state()
211+
if ent.hass is not None:
212+
ent.schedule_update_ha_state()
212213

213214

214215
def get_poll_profile(config_entry: ConfigEntry) -> str:

‎custom_components/solis_modbus/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@
1414
"requirements": [
1515
"pymodbus>=3.11.1"
1616
],
17-
"version": "4.2.3"
17+
"version": "4.2.6"
1818
}

‎custom_components/solis_modbus/services.yaml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,26 @@ solis_dispatch:
261261
max: 100
262262
unit_of_measurement: "%"
263263
mode: box
264+
import_limit_watts:
265+
name: Import limit
266+
description: System power-draw (grid import) upper limit. Omit to leave the inverter default; 0 is a valid cap
267+
selector:
268+
number:
269+
min: 0
270+
max: 240000
271+
step: 100
272+
unit_of_measurement: W
273+
mode: box
274+
export_limit_watts:
275+
name: Export limit
276+
description: System feed-in (grid export) upper limit. Omit to leave the inverter default; 0 is a valid cap
277+
selector:
278+
number:
279+
min: 0
280+
max: 240000
281+
step: 100
282+
unit_of_measurement: W
283+
mode: box
264284
failsafe_minutes:
265285
name: Failsafe interval
266286
description: The inverter reverts to normal operation by itself if no dispatch write arrives within this window

‎tests/known_sensors_host.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"solis_modbus_1.2.3.4_43110_8",
1717
"solis_modbus_1.2.3.4_43110_9",
1818
"solis_modbus_1.2.3.4_43110_select",
19+
"solis_modbus_1.2.3.4_43111_1",
1920
"solis_modbus_1.2.3.4_43132_select",
2021
"solis_modbus_1.2.3.4_43135_1",
2122
"solis_modbus_1.2.3.4_43135_2",
@@ -82,6 +83,8 @@
8283
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_coupling_total_power_generation",
8384
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_coupling_year_power_generation",
8485
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_coupling_yesterday_power_generation",
86+
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_grid_port_active_power_phase_a",
87+
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_grid_port_active_power_phase_b",
8588
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_grid_port_power",
8689
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_grid_port_total_energy_consumption",
8790
"solis_modbus_1.2.3.4_solis_modbus_inverter_ac_grid_port_total_energy_fed_in",
@@ -104,6 +107,7 @@
104107
"solis_modbus_1.2.3.4_solis_modbus_inverter_backup_month_energy",
105108
"solis_modbus_1.2.3.4_solis_modbus_inverter_backup_output_enabled_mirror",
106109
"solis_modbus_1.2.3.4_solis_modbus_inverter_backup_soc",
110+
"solis_modbus_1.2.3.4_solis_modbus_inverter_backup_supply_value",
107111
"solis_modbus_1.2.3.4_solis_modbus_inverter_backup_today_energy",
108112
"solis_modbus_1.2.3.4_solis_modbus_inverter_backup_total_energy",
109113
"solis_modbus_1.2.3.4_solis_modbus_inverter_backup_voltage_reference",

‎tests/known_sensors_identification.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"solis_modbus_ID_SNAPSHOT_43110_8",
1717
"solis_modbus_ID_SNAPSHOT_43110_9",
1818
"solis_modbus_ID_SNAPSHOT_43110_select",
19+
"solis_modbus_ID_SNAPSHOT_43111_1",
1920
"solis_modbus_ID_SNAPSHOT_43132_select",
2021
"solis_modbus_ID_SNAPSHOT_43135_1",
2122
"solis_modbus_ID_SNAPSHOT_43135_2",
@@ -82,6 +83,8 @@
8283
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_coupling_total_power_generation",
8384
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_coupling_year_power_generation",
8485
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_coupling_yesterday_power_generation",
86+
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_grid_port_active_power_phase_a",
87+
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_grid_port_active_power_phase_b",
8588
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_grid_port_power",
8689
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_grid_port_total_energy_consumption",
8790
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_ac_grid_port_total_energy_fed_in",
@@ -104,6 +107,7 @@
104107
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_backup_month_energy",
105108
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_backup_output_enabled_mirror",
106109
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_backup_soc",
110+
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_backup_supply_value",
107111
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_backup_today_energy",
108112
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_backup_total_energy",
109113
"solis_modbus_ID_SNAPSHOT_solis_modbus_inverter_backup_voltage_reference",

‎tests/known_sensors_serial.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"solis_modbus_SN_SNAPSHOT_43110_8",
1717
"solis_modbus_SN_SNAPSHOT_43110_9",
1818
"solis_modbus_SN_SNAPSHOT_43110_select",
19+
"solis_modbus_SN_SNAPSHOT_43111_1",
1920
"solis_modbus_SN_SNAPSHOT_43132_select",
2021
"solis_modbus_SN_SNAPSHOT_43135_1",
2122
"solis_modbus_SN_SNAPSHOT_43135_2",
@@ -82,6 +83,8 @@
8283
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_coupling_total_power_generation",
8384
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_coupling_year_power_generation",
8485
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_coupling_yesterday_power_generation",
86+
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_grid_port_active_power_phase_a",
87+
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_grid_port_active_power_phase_b",
8588
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_grid_port_power",
8689
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_grid_port_total_energy_consumption",
8790
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_ac_grid_port_total_energy_fed_in",
@@ -104,6 +107,7 @@
104107
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_backup_month_energy",
105108
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_backup_output_enabled_mirror",
106109
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_backup_soc",
110+
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_backup_supply_value",
107111
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_backup_today_energy",
108112
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_backup_total_energy",
109113
"solis_modbus_SN_SNAPSHOT_solis_modbus_inverter_backup_voltage_reference",

‎tests/test_client_manager.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import unittest
22
from unittest import IsolatedAsyncioTestCase
3-
from unittest.mock import MagicMock, patch
3+
from unittest.mock import AsyncMock, MagicMock, patch
44

5-
from custom_components.solis_modbus.client_manager import ModbusClientManager
5+
from custom_components.solis_modbus.client_manager import MIN_INTER_FRAME_MS, ModbusClientManager
66

77

88
class TestModbusClientManager(unittest.TestCase):
@@ -91,6 +91,27 @@ async def test_inter_frame_wait_updates_shared_timestamp(self, mock_client_cls):
9191
await self.manager.inter_frame_wait("1.2.3.4:502", is_write=False)
9292
self.assertGreater(self.manager.get_last_modbus_request("1.2.3.4:502"), 0.0)
9393

94+
@patch("custom_components.solis_modbus.client_manager.AsyncModbusTcpClient")
95+
@patch("custom_components.solis_modbus.client_manager.time.perf_counter")
96+
@patch("custom_components.solis_modbus.client_manager.asyncio.sleep", new_callable=AsyncMock)
97+
async def test_inter_frame_wait_enforces_min_interval_for_reads_and_writes(self, mock_sleep, mock_perf_counter, mock_client_cls):
98+
mock_client_cls.return_value = MagicMock()
99+
# Two operations back-to-back on the same connection: each should independently
100+
# compute a wait based on MIN_INTER_FRAME_MS, regardless of is_write.
101+
# Call sequence: [read: current_time, read: recorded last] [write: current_time, write: recorded last]
102+
mock_perf_counter.side_effect = [0.0, 0.31, 0.32, 0.62]
103+
self.manager.get_tcp_client("1.2.3.4", 502)
104+
105+
await self.manager.inter_frame_wait("1.2.3.4:502", is_write=False)
106+
await self.manager.inter_frame_wait("1.2.3.4:502", is_write=True)
107+
108+
expected_delay_s = MIN_INTER_FRAME_MS / 1000
109+
self.assertEqual(2, mock_sleep.call_count)
110+
# Read: 0.0 elapsed since last (0.0) -> full MIN_INTER_FRAME_MS wait.
111+
self.assertAlmostEqual(expected_delay_s, mock_sleep.call_args_list[0].args[0])
112+
# Write: 10ms elapsed since last (0.31) -> remaining wait is MIN_INTER_FRAME_MS - 10ms.
113+
self.assertAlmostEqual(expected_delay_s - 0.01, mock_sleep.call_args_list[1].args[0])
114+
94115

95116
if __name__ == "__main__":
96117
unittest.main()

0 commit comments

Comments
 (0)