Skip to content

Commit 1aa633d

Browse files
TypQxQreedy
andauthored
v.1.1.8 (#194)
* Start of miggration to pymodbus 3.11.1 * modbus.py: Replace pymodbus.payload * modbus.py: Remove comment about BOM in _encode_value * Remove unused import of Endian from pymodbus.constants * Implement pymodbus version compatibility handling and update method calls to use 'device_id' for version 3.10+ * Refactor power calculation logic in SigenergyCalculations class to streamline input validation and remove unused variables * Bump version to 1.1.8 in manifest.json --------- Co-authored-by: Sam Reed <[email protected]>
1 parent cefef1e commit 1aa633d

File tree

3 files changed

+131
-91
lines changed

3 files changed

+131
-91
lines changed

custom_components/sigen/calculated_sensor.py

Lines changed: 19 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -331,82 +331,40 @@ def calculate_plant_consumed_power(
331331

332332
total_ac_charger_power = 0.0
333333
ac_chargers: dict[str, Any] = coordinator_data.get("ac_chargers", {})
334-
# _LOGGER.debug("AC Chargers data: %s", ac_chargers)
335334
for _, ac_charger_data in ac_chargers.items():
336335
ac_power = safe_float(ac_charger_data.get("ac_charger_charging_power"))
337336
if ac_power is not None:
338337
total_ac_charger_power += ac_power
339338

340-
total_dc_charger_power = 0.0
341-
dc_chargers: dict[str, Any] = coordinator_data.get("dc_chargers", {})
342-
# _LOGGER.debug("DC Chargers data: %s", dc_chargers)
343-
for _, dc_charger_data in dc_chargers.items():
344-
dc_power = safe_float(dc_charger_data.get("dc_charger_output_power"))
345-
if dc_power is not None:
346-
total_dc_charger_power += dc_power
347-
348-
# Use the correct calculation for total PV power
349-
pv_power = SigenergyCalculations.calculate_total_pv_power(
350-
None, coordinator_data=coordinator_data
351-
)
352-
339+
plant_power = plant_data.get("plant_active_power")
353340
grid_power = plant_data.get("plant_grid_sensor_active_power")
354-
355-
# Get battery power
356-
battery_power = plant_data.get("plant_ess_power")
341+
third_party_pv_power = plant_data.get("plant_third_party_photovoltaic_power")
357342

358343
# Validate inputs
359-
if pv_power is None or grid_power is None or battery_power is None:
344+
if plant_power is None or grid_power is None or third_party_pv_power is None:
360345
return None
361346

362347
# Validate input types
363-
if not isinstance(pv_power, (int, float)):
364-
_LOGGER.warning(
365-
"[CS][Plant Consumed] PV power is not a number: %s (type: %s)",
366-
pv_power,
367-
type(pv_power).__name__,
368-
)
369-
return None
370-
if not isinstance(grid_power, (int, float)):
371-
_LOGGER.warning(
372-
"[CS][Plant Consumed] Grid power is not a number: %s (type: %s)",
373-
grid_power,
374-
type(grid_power).__name__,
375-
)
376-
return None
377-
if not isinstance(battery_power, (int, float)):
378-
_LOGGER.warning(
379-
"[CS][Plant Consumed] Battery power is not a number: %s (type: %s)",
380-
battery_power,
381-
type(battery_power).__name__,
382-
)
383-
return None
348+
def are_numbers(*values):
349+
for x in values:
350+
if not isinstance(x, (int, float)):
351+
try:
352+
float(x)
353+
except (ValueError, TypeError):
354+
_LOGGER.warning(
355+
"[CS][Plant Consumed] Value is not a number: %s (type: %s)",
356+
x,
357+
type(x).__name__,
358+
)
359+
return False
360+
return True
384361

385-
# Calculate grid import and export power
386-
# Grid power is positive when importing, negative when exporting
387-
grid_import = max(0, grid_power)
388-
grid_export = max(0, -grid_power)
362+
if not are_numbers(grid_power, plant_power, third_party_pv_power):
363+
return None
389364

390365
# Calculate plant consumed power
391-
# Note: battery_power is positive when charging, negative when discharging
392366
try:
393-
# The household consumption should include the power used by the EV chargers.
394-
# The chargers are loads within the household.
395-
consumed_power = pv_power + grid_import - grid_export - battery_power
396-
397-
# Sanity check
398-
if consumed_power < 0:
399-
_LOGGER.debug(
400-
"[CS][Plant Consumed] Calculated power is negative.\n" \
401-
"consumed_power = pv_power + grid_import - grid_export - battery_power:\n" \
402-
"%s kW = %s + %s - %s - %s",
403-
consumed_power,
404-
pv_power,
405-
grid_import,
406-
grid_export,
407-
battery_power
408-
)
409-
# Keep the negative value as it might be valid in some scenarios
367+
consumed_power = max(0, float(plant_power) + float(grid_power) + float(third_party_pv_power) - total_ac_charger_power)
410368

411369
except Exception as ex: # pylint: disable=broad-exception-caught
412370
_LOGGER.error(

custom_components/sigen/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@
2121
"loggers": ["custom_components.sigen"],
2222
"quality_scale": "custom",
2323
"requirements": ["pymodbus>=3.8.3"],
24-
"version": "1.1.7"
24+
"version": "1.1.8"
2525
}

custom_components/sigen/modbus.py

Lines changed: 111 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@
1212
from homeassistant.core import HomeAssistant
1313
from homeassistant.exceptions import HomeAssistantError
1414
from pymodbus.client import AsyncModbusTcpClient
15-
from pymodbus.constants import Endian
1615
from pymodbus.exceptions import ConnectionException, ModbusException
17-
from pymodbus.payload import BinaryPayloadBuilder
1816
from pymodbus.client.mixin import ModbusClientMixin
1917
from pymodbus import __version__ as pymodbus_version
2018

@@ -43,15 +41,91 @@
4341
DC_CHARGER_RUNNING_INFO_REGISTERS,
4442
DC_CHARGER_PARAMETER_REGISTERS,
4543
)
46-
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
47-
from .common import generate_device_id
48-
from .const import DOMAIN
49-
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
50-
from .common import generate_device_id
51-
from .const import DOMAIN
5244

5345
_LOGGER = logging.getLogger(__name__)
5446

47+
# Pymodbus version compatibility handling
48+
def _get_pymodbus_version_info():
49+
"""Get pymodbus version information for compatibility handling."""
50+
try:
51+
# Parse version string like "3.6.8" or "3.10.0"
52+
version_parts = pymodbus_version.split('.')
53+
major = int(version_parts[0])
54+
minor = int(version_parts[1])
55+
return major, minor
56+
except (ValueError, IndexError):
57+
# Fallback to assuming older version if parsing fails
58+
_LOGGER.warning("Could not parse pymodbus version '%s', assuming < 3.10", pymodbus_version)
59+
return 3, 0
60+
61+
# Check if we need to use 'device_id' instead of 'slave'
62+
_PYMODBUS_MAJOR, _PYMODBUS_MINOR = _get_pymodbus_version_info()
63+
_USE_DEVICE_ID = (_PYMODBUS_MAJOR > 3) or (_PYMODBUS_MAJOR == 3 and _PYMODBUS_MINOR >= 10)
64+
65+
def _call_modbus_method_safe(client_method, *args, **kwargs):
66+
"""
67+
Call a pymodbus client method with version-compatible parameter handling.
68+
69+
This function handles the parameter name change from 'slave' to 'device_id'
70+
that occurred in pymodbus 3.10+.
71+
72+
Args:
73+
client_method: The pymodbus client method to call
74+
*args: Positional arguments
75+
**kwargs: Keyword arguments, including 'slave' or 'device_id'
76+
77+
Returns:
78+
The result of the client method call
79+
80+
Raises:
81+
TypeError: If the method call fails due to parameter incompatibility
82+
Other exceptions from the underlying method
83+
"""
84+
if _USE_DEVICE_ID:
85+
# For pymodbus >= 3.10, use 'device_id' parameter
86+
if 'slave' in kwargs:
87+
kwargs['device_id'] = kwargs.pop('slave')
88+
89+
try:
90+
return client_method(*args, **kwargs)
91+
except TypeError as e:
92+
if 'device_id' in str(e):
93+
# If device_id also fails, try falling back to positional args
94+
_LOGGER.debug("device_id parameter failed, trying positional arguments")
95+
# Remove device_id and try with positional args if possible
96+
kwargs_copy = kwargs.copy()
97+
if 'device_id' in kwargs_copy:
98+
device_id = kwargs_copy.pop('device_id')
99+
# Try calling with device_id as positional argument
100+
try:
101+
return client_method(*args, device_id, **kwargs_copy)
102+
except TypeError as inner_e:
103+
# If positional also fails, re-raise original error
104+
raise e from inner_e
105+
else:
106+
raise e
107+
else:
108+
raise e
109+
else:
110+
# For pymodbus < 3.10, use 'slave' parameter
111+
try:
112+
return client_method(*args, **kwargs)
113+
except TypeError as e:
114+
if 'slave' in str(e):
115+
# If slave fails, try falling back to positional args
116+
_LOGGER.debug("slave parameter failed, trying positional arguments")
117+
kwargs_copy = kwargs.copy()
118+
if 'slave' in kwargs_copy:
119+
slave = kwargs_copy.pop('slave')
120+
try:
121+
return client_method(*args, slave, **kwargs_copy)
122+
except TypeError as inner_e:
123+
raise e from inner_e
124+
else:
125+
raise e
126+
else:
127+
raise e
128+
55129
@dataclass
56130
class ModbusConnectionConfig:
57131
"""Configuration for a Modbus connection."""
@@ -295,13 +369,15 @@ async def _probe_single_register(
295369

296370
with _suppress_pymodbus_logging(really_suppress= False if _LOGGER.isEnabledFor(logging.DEBUG) else True):
297371
if register.register_type == RegisterType.READ_ONLY:
298-
result = await client.read_input_registers(
372+
result = await _call_modbus_method_safe(
373+
client.read_input_registers,
299374
address=register.address,
300375
count=register.count,
301376
slave=slave_id
302377
)
303378
elif register.register_type == RegisterType.HOLDING:
304-
result = await client.read_holding_registers(
379+
result = await _call_modbus_method_safe(
380+
client.read_holding_registers,
305381
address=register.address,
306382
count=register.count,
307383
slave=slave_id
@@ -365,7 +441,6 @@ async def async_probe_registers(
365441
_LOGGER.debug("No registers need probing for %s.", device_info_log)
366442
# If no probing is needed, still generate intervals from already known registers
367443
# This handles the case where probing was already done in a previous run.
368-
pass
369444
else:
370445
_LOGGER.debug("Probing %d registers concurrently for %s...", len(tasks), device_info_log)
371446

@@ -503,10 +578,8 @@ async def async_read_registers(
503578

504579
async with self._locks[key]:
505580
with _suppress_pymodbus_logging(really_suppress= False if _LOGGER.isEnabledFor(logging.DEBUG) else True):
506-
result = await client.read_input_registers(
507-
address=address, count=count, slave=slave_id
508-
) if register_type == RegisterType.READ_ONLY \
509-
else await client.read_holding_registers(
581+
result = await _call_modbus_method_safe(
582+
client.read_input_registers if register_type == RegisterType.READ_ONLY else client.read_holding_registers,
510583
address=address, count=count, slave=slave_id
511584
)
512585

@@ -592,13 +665,15 @@ async def async_write_register(
592665
)
593666

594667
if approach["function"] == "write_registers":
595-
result = await client.write_registers(
668+
result = await _call_modbus_method_safe(
669+
client.write_registers,
596670
address=approach["address"],
597671
values=approach["values"],
598672
slave=slave_id
599673
)
600674
else: # write_register
601-
result = await client.write_register(
675+
result = await _call_modbus_method_safe(
676+
client.write_register,
602677
address=approach["address"],
603678
value=approach["value"],
604679
slave=slave_id
@@ -753,36 +828,43 @@ def _encode_value(
753828
) -> List[int]:
754829
"""Encode value to register values based on data type."""
755830
# For simple U16 values like 0 or 1, just return the value directly
756-
# This bypasses potential byte order issues with the BinaryPayloadBuilder
757831
if data_type == DataType.U16 and isinstance(value, int) and 0 <= value <= 255:
758832
_LOGGER.debug("Using direct value encoding for simple U16 value: %s", value)
759833
return [value]
760834

761-
# For other cases, use the BinaryPayloadBuilder
762-
builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
763-
764835
# Apply gain for numeric values
765836
if isinstance(value, (int, float)) and gain != 1 and data_type != DataType.STRING:
766837
value = int(value * gain)
767838

768839
_LOGGER.debug("Encoding value %s with data_type %s", value, data_type)
769840

770841
if data_type == DataType.U16:
771-
builder.add_16bit_uint(int(value))
842+
registers = ModbusClientMixin.convert_to_registers(
843+
value, data_type=ModbusClientMixin.DATATYPE.UINT16
844+
)
772845
elif data_type == DataType.S16:
773-
builder.add_16bit_int(int(value))
846+
registers = ModbusClientMixin.convert_to_registers(
847+
value, data_type=ModbusClientMixin.DATATYPE.INT16
848+
)
774849
elif data_type == DataType.U32:
775-
builder.add_32bit_uint(int(value))
850+
registers = ModbusClientMixin.convert_to_registers(
851+
value, data_type=ModbusClientMixin.DATATYPE.UINT32
852+
)
776853
elif data_type == DataType.S32:
777-
builder.add_32bit_int(int(value))
854+
registers = ModbusClientMixin.convert_to_registers(
855+
value, data_type=ModbusClientMixin.DATATYPE.INT32
856+
)
778857
elif data_type == DataType.U64:
779-
builder.add_64bit_uint(int(value))
858+
registers = ModbusClientMixin.convert_to_registers(
859+
value, data_type=ModbusClientMixin.DATATYPE.UINT64
860+
)
780861
elif data_type == DataType.STRING:
781-
builder.add_string(str(value))
862+
registers = ModbusClientMixin.convert_to_registers(
863+
str(value), data_type=ModbusClientMixin.DATATYPE.STRING
864+
)
782865
else:
783866
raise SigenergyModbusError(f"Unsupported data type: {data_type}")
784867

785-
registers = builder.to_registers()
786868
_LOGGER.debug("Encoded registers: %s", registers)
787869
return registers
788870

@@ -1203,4 +1285,4 @@ async def async_write_parameter(
12031285
except SigenergyModbusError as ex:
12041286
_LOGGER.error("Failed to write %s parameter '%s' (device: %s): %s",
12051287
device_type, register_name, device_identifier or 'plant', ex)
1206-
raise # Re-raise the specific error
1288+
raise # Re-raise the specific error

0 commit comments

Comments
 (0)