Skip to content

Commit 85282ab

Browse files
committed
Validate Modbus write register ranges
1 parent 667106b commit 85282ab

6 files changed

Lines changed: 264 additions & 85 deletions

File tree

custom_components/solax_modbus/__init__.py

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import importlib
77
import json
88
import logging
9+
import struct
910
import time as _mtime
1011
from dataclasses import dataclass, replace
1112
from datetime import timedelta
@@ -76,9 +77,11 @@
7677
REG_HOLDING,
7778
REG_INPUT,
7879
REGISTER_F32,
80+
REGISTER_INT_RANGES,
7981
REGISTER_S16,
8082
REGISTER_S32,
8183
REGISTER_STR,
84+
REGISTER_TYPE_WORDS,
8285
REGISTER_U8H,
8386
REGISTER_U8L,
8487
REGISTER_U16,
@@ -480,6 +483,10 @@ class PendingWrite:
480483
register_data_type: str | None = None
481484

482485

486+
class RegisterEncodingError(HomeAssistantError):
487+
"""Raised when a value cannot be represented by its Modbus register type."""
488+
489+
483490
class SolaXModbusHub:
484491
"""Thread safe wrapper class for pymodbus."""
485492

@@ -1366,11 +1373,17 @@ def _validate_write_response(self, response: Any, *, unit: int, address: int, op
13661373
raise HomeAssistantError(f"{self._name}: {operation} was rejected by device {unit} at register 0x{address:x}: {response}")
13671374
return response
13681375

1369-
def _encode_multi_write_payload(self, payload: list[tuple[Any, Any]]) -> list[int]:
1370-
"""Encode a complete multi-register payload before any data is sent."""
1371-
if not isinstance(payload, list) or not payload:
1372-
raise HomeAssistantError(f"{self._name}: multi-register write requires a non-empty payload")
1373-
1376+
def _encode_write_value(
1377+
self,
1378+
payload: int | float,
1379+
register_data_type: str | None,
1380+
*,
1381+
single_register: bool,
1382+
) -> list[int]:
1383+
"""Validate and encode one value before it reaches the transport."""
1384+
effective_type = register_data_type or (REGISTER_S16 if single_register else None)
1385+
if effective_type is None:
1386+
raise RegisterEncodingError(f"{self._name}: unsupported register data type {register_data_type}")
13741387
data_type_enum = cast(Any, DataType)
13751388
data_types: dict[str, Any] = {
13761389
REGISTER_U16: data_type_enum.UINT16,
@@ -1379,13 +1392,44 @@ def _encode_multi_write_payload(self, payload: list[tuple[Any, Any]]) -> list[in
13791392
REGISTER_F32: data_type_enum.FLOAT32,
13801393
REGISTER_S32: data_type_enum.INT32,
13811394
}
1395+
data_type = data_types.get(effective_type)
1396+
word_count = REGISTER_TYPE_WORDS.get(effective_type)
1397+
if data_type is None or word_count is None:
1398+
raise RegisterEncodingError(f"{self._name}: unsupported register data type {register_data_type}")
1399+
if single_register and word_count != 1:
1400+
raise RegisterEncodingError(
1401+
f"{self._name}: register data type {effective_type} requires {word_count} registers and cannot be written as a single register"
1402+
)
1403+
1404+
try:
1405+
if effective_type == REGISTER_F32:
1406+
value: int | float = float(payload)
1407+
else:
1408+
value = int(payload)
1409+
minimum, maximum = REGISTER_INT_RANGES[effective_type]
1410+
if value < minimum or value > maximum:
1411+
raise RegisterEncodingError(f"{self._name}: value {value} is outside the {effective_type} register range {minimum}..{maximum}")
1412+
registers = cast(list[int], convert_to_registers(value, data_type, self.plugin.order32))
1413+
except RegisterEncodingError:
1414+
raise
1415+
except (OverflowError, TypeError, ValueError, struct.error) as ex:
1416+
raise RegisterEncodingError(f"{self._name}: cannot encode value {payload!r} as {effective_type}: {ex}") from ex
1417+
1418+
if len(registers) != word_count:
1419+
raise RegisterEncodingError(f"{self._name}: encoding {effective_type} produced {len(registers)} registers instead of {word_count}")
1420+
return registers
1421+
1422+
def _encode_multi_write_payload(self, payload: list[tuple[Any, Any]]) -> list[int]:
1423+
"""Encode a complete multi-register payload before any data is sent."""
1424+
if not isinstance(payload, list) or not payload:
1425+
raise HomeAssistantError(f"{self._name}: multi-register write requires a non-empty payload")
1426+
13821427
registers: list[int] = []
13831428
for item in payload:
13841429
try:
13851430
key, value = item
13861431
if key.startswith("_"):
13871432
register_data_type = key
1388-
value = int(value)
13891433
else:
13901434
descr = self.writeLocals[key]
13911435
reverse_options = getattr(descr, "reverse_option_dict", None)
@@ -1399,13 +1443,9 @@ def _encode_multi_write_payload(self, payload: list[tuple[Any, Any]]) -> list[in
13991443
value = descr.scale(value, descr, self.data)
14001444
else:
14011445
value = value * descr.scale
1402-
value = int(value)
14031446
register_data_type = descr.register_data_type
14041447

1405-
data_type = data_types.get(register_data_type)
1406-
if data_type is None:
1407-
raise ValueError(f"unsupported register data type {register_data_type}")
1408-
registers.extend(convert_to_registers(value, data_type, self.plugin.order32))
1448+
registers.extend(self._encode_write_value(value, register_data_type, single_register=False))
14091449
except Exception as ex:
14101450
raise HomeAssistantError(f"{self._name}: cannot encode multi-register write item {item!r}: {ex}") from ex
14111451

@@ -1438,11 +1478,8 @@ async def _async_transport_write(
14381478
)
14391479

14401480
async def async_lowlevel_write_register(self, unit: int, address: int, payload: int, register_data_type: str | None = None) -> Any:
1441-
if register_data_type == REGISTER_U16:
1442-
regs = convert_to_registers(int(payload), DataType.UINT16, self.plugin.order32) # type: ignore[attr-defined]
1443-
else:
1444-
regs = convert_to_registers(int(payload), DataType.INT16, self.plugin.order32) # type: ignore[attr-defined]
14451481
try:
1482+
regs = self._encode_write_value(payload, register_data_type, single_register=True)
14461483
response = await self._async_transport_write(
14471484
unit=unit,
14481485
address=address,
@@ -1479,6 +1516,8 @@ async def async_write_register(self, unit: int, address: int, payload: int, regi
14791516
payload,
14801517
register_data_type=register_data_type,
14811518
)
1519+
except RegisterEncodingError:
1520+
raise
14821521
except HomeAssistantError as ex:
14831522
self.writequeue[(unit, address)] = request
14841523
if self.wakeupButton:
@@ -1515,10 +1554,7 @@ async def async_write_registers_single(
15151554
self, unit: int, address: int, payload: int, register_data_type: str | None = None
15161555
) -> Any: # Needs adapting for register queue
15171556
"""Write registers multi, but write only one register of type 16bit"""
1518-
if register_data_type == REGISTER_U16:
1519-
regs = convert_to_registers(int(payload), DataType.UINT16, self.plugin.order32) # type: ignore[attr-defined]
1520-
else:
1521-
regs = convert_to_registers(int(payload), DataType.INT16, self.plugin.order32) # type: ignore[attr-defined]
1557+
regs = self._encode_write_value(payload, register_data_type, single_register=True)
15221558
return await self._async_transport_write(
15231559
unit=unit,
15241560
address=address,

custom_components/solax_modbus/const.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ class UnitOfReactivePower(StrEnum): # type: ignore[no-redef]
108108
REGISTER_WORDS = "_words" # nr or words must be specified in wordcount
109109
REGISTER_U8L = "_int8L"
110110
REGISTER_U8H = "_int8H"
111+
REGISTER_U16_MAX = 0xFFFF
112+
REGISTER_INT_RANGES: dict[str, tuple[int, int]] = {
113+
REGISTER_U16: (0, REGISTER_U16_MAX),
114+
REGISTER_S16: (-(1 << 15), (1 << 15) - 1),
115+
REGISTER_U32: (0, (1 << 32) - 1),
116+
REGISTER_S32: (-(1 << 31), (1 << 31) - 1),
117+
}
118+
REGISTER_TYPE_WORDS: dict[str, int] = {
119+
REGISTER_U16: 1,
120+
REGISTER_S16: 1,
121+
REGISTER_U32: 2,
122+
REGISTER_S32: 2,
123+
REGISTER_F32: 2,
124+
}
111125
WRITE_SINGLE_MODBUS = 1 # use write_single_modbus command
112126
WRITE_MULTISINGLE_MODBUS = 2 # use write_mutiple modbus command for single register
113127
WRITE_DATA_LOCAL = 3 # write only to local data storage (not persistent)

custom_components/solax_modbus/plugin_solis.py

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
REGISTER_S16,
3030
REGISTER_S32,
3131
REGISTER_U16,
32+
REGISTER_U16_MAX,
3233
REGISTER_U32,
3334
REGISTER_WORDS,
3435
SLEEPMODE_LASTAWAKE,
@@ -350,18 +351,7 @@ def value_function_pv4_power(initval: int, descr: Any, datadict: dict[str, Any])
350351

351352
# ================================= Number Declarations ============================================================
352353

353-
MAX_CURRENTS: list[tuple[str, int | float]] = [
354-
("0602", 62.5), # 3kW 48v
355-
("0102", 62.5), # 3kW 48v AC Only?
356-
("110F", 62.5), # 3.6kW 48v
357-
("160F3", 100), # 5kW 48v
358-
("160F4", 60), # 3.6kW 48v
359-
("160F5", 62.5), # 3.6kW 48v
360-
("1031", 100), # 5kW 48v
361-
("134F", 100), # 5kW 48v
362-
("6031", 100), # 6kW 48v
363-
("110C", 25), # 10kW HV
364-
]
354+
U16_CURRENT_MAX = REGISTER_U16_MAX * 0.1
365355

366356
NUMBER_TYPES = [
367357
###
@@ -862,13 +852,13 @@ def value_function_pv4_power(initval: int, descr: Any, datadict: dict[str, Any])
862852
register=43116,
863853
fmt="f",
864854
native_min_value=0,
865-
native_max_value=20,
855+
native_max_value=U16_CURRENT_MAX,
866856
native_step=1,
867857
scale=0.1,
868858
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
869859
device_class=NumberDeviceClass.CURRENT,
870860
allowedtypes=HYBRID,
871-
max_exceptions=MAX_CURRENTS,
861+
register_data_type=REGISTER_U16,
872862
entity_category=EntityCategory.CONFIG,
873863
),
874864
SolisModbusNumberEntityDescription(
@@ -877,13 +867,13 @@ def value_function_pv4_power(initval: int, descr: Any, datadict: dict[str, Any])
877867
register=43117,
878868
fmt="f",
879869
native_min_value=0,
880-
native_max_value=20,
870+
native_max_value=U16_CURRENT_MAX,
881871
native_step=1,
882872
scale=0.1,
883873
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
884874
device_class=NumberDeviceClass.CURRENT,
885875
allowedtypes=HYBRID,
886-
max_exceptions=MAX_CURRENTS,
876+
register_data_type=REGISTER_U16,
887877
entity_category=EntityCategory.CONFIG,
888878
),
889879
SolisModbusNumberEntityDescription(
@@ -892,13 +882,13 @@ def value_function_pv4_power(initval: int, descr: Any, datadict: dict[str, Any])
892882
register=43118,
893883
fmt="f",
894884
native_min_value=0,
895-
native_max_value=20,
885+
native_max_value=U16_CURRENT_MAX,
896886
native_step=1,
897887
scale=0.1,
898888
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
899889
device_class=NumberDeviceClass.CURRENT,
900890
allowedtypes=HYBRID,
901-
max_exceptions=MAX_CURRENTS,
891+
register_data_type=REGISTER_U16,
902892
entity_category=EntityCategory.CONFIG,
903893
),
904894
SolisModbusNumberEntityDescription(
@@ -907,13 +897,13 @@ def value_function_pv4_power(initval: int, descr: Any, datadict: dict[str, Any])
907897
register=43141,
908898
fmt="f",
909899
native_min_value=0,
910-
native_max_value=20,
900+
native_max_value=U16_CURRENT_MAX,
911901
native_step=1,
912902
scale=0.1,
913903
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
914904
device_class=NumberDeviceClass.CURRENT,
915905
allowedtypes=HYBRID,
916-
max_exceptions=MAX_CURRENTS,
906+
register_data_type=REGISTER_U16,
917907
entity_category=EntityCategory.CONFIG,
918908
),
919909
SolisModbusNumberEntityDescription(
@@ -922,13 +912,13 @@ def value_function_pv4_power(initval: int, descr: Any, datadict: dict[str, Any])
922912
register=43142,
923913
fmt="f",
924914
native_min_value=0,
925-
native_max_value=20,
915+
native_max_value=U16_CURRENT_MAX,
926916
native_step=1,
927917
scale=0.1,
928918
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
929919
device_class=NumberDeviceClass.CURRENT,
930920
allowedtypes=HYBRID,
931-
max_exceptions=MAX_CURRENTS,
921+
register_data_type=REGISTER_U16,
932922
entity_category=EntityCategory.CONFIG,
933923
),
934924
SolisModbusNumberEntityDescription(

0 commit comments

Comments
 (0)