Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ef6ce72
Add a class for EV charger
WillCodeForCats Apr 2, 2024
73fba84
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats Apr 23, 2024
1cd0039
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats Aug 31, 2024
0a83191
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats Apr 24, 2026
7212f35
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats Apr 26, 2026
7838619
Charger -> EVSE
WillCodeForCats Apr 26, 2026
a857eeb
Update SolarEdgeEVSE to current methods
WillCodeForCats Apr 26, 2026
8e9a972
Add evse detection regex
WillCodeForCats Apr 26, 2026
5872194
Raise DeviceIsEVSE if model matches known evse
WillCodeForCats Apr 26, 2026
1153742
Handle DeviceIsEVSE exception during init
WillCodeForCats Apr 26, 2026
6c013a9
Add version entity for evse devices
WillCodeForCats Apr 26, 2026
ea767b5
Use ID prefix E for EVSE devices
WillCodeForCats Apr 26, 2026
397f1fe
Add EVSE devices to diagnostics
WillCodeForCats Apr 26, 2026
a13713d
Try state address for Keba P30
WillCodeForCats Apr 27, 2026
e133559
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats May 3, 2026
f51eda1
Bump version for pre-release
WillCodeForCats May 4, 2026
bdd6d32
Missing calls to read data for evses
WillCodeForCats May 4, 2026
ae6c008
Skip meter and battery detection if DeviceIsEVSE
WillCodeForCats May 4, 2026
d05ff76
Debug ident for evse should be E not C
WillCodeForCats May 4, 2026
2e5c131
Format with ruff
WillCodeForCats May 4, 2026
12aaa39
Bump version for pre-release
WillCodeForCats May 4, 2026
48dd0da
Fix incorrect use of update()
WillCodeForCats May 4, 2026
cd5b094
Add read for cable state
WillCodeForCats May 4, 2026
3974e63
Add read for type and features
WillCodeForCats May 4, 2026
c3f3ce5
Add debug output for decoded_model
WillCodeForCats May 4, 2026
d16002a
Update error message for illegal address
WillCodeForCats May 4, 2026
944098a
Bump version for pre-release
WillCodeForCats May 4, 2026
60e2d54
Blind read sunspec ranges to see if we can see any differences
WillCodeForCats May 5, 2026
4209c93
Bump version for pre-release
WillCodeForCats May 5, 2026
847b6a3
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats May 5, 2026
2a3def0
Replace OrderedDict with dict
WillCodeForCats May 5, 2026
a7ba919
Change debug in modbus read to unit instead of I
WillCodeForCats May 5, 2026
634836f
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats May 5, 2026
6d3681e
Also apply #979 to evse class
WillCodeForCats May 5, 2026
e84348b
Bump version for pre-release
WillCodeForCats May 5, 2026
025a6cf
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats May 17, 2026
140e180
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats May 17, 2026
16fe17d
Blind read at 50000
WillCodeForCats May 17, 2026
b11c02c
Bump version for pre-release
WillCodeForCats May 17, 2026
4be39d5
Merge branch 'main' into 565-ev-charger-support
WillCodeForCats May 17, 2026
da45ac1
Blind read at 0
WillCodeForCats May 17, 2026
9d69272
Bump version for pre-release
WillCodeForCats May 17, 2026
e882d87
Remove evse_data reads
WillCodeForCats May 17, 2026
24b93b6
Remove unused sensor SolarEdgeEvseState
WillCodeForCats May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions custom_components/solaredge_modbus_multi/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
re.IGNORECASE,
)

DETECT_EVSE_REGEX = re.compile(
r"^(?:SE-EV-SA)", # Add additional prefixes with |OTHER-PREFIX
re.IGNORECASE,
)

STATUS_VENDOR4_VERSION = "3.20.0" # solaredge firmware version
INVERTED_POWER_VERSION = "2026.2.0" # home assistant core version

Expand Down
11 changes: 11 additions & 0 deletions custom_components/solaredge_modbus_multi/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
REDACT_INVERTER = {"identifiers", "C_SerialNumber", "serial_number"}
REDACT_METER = {"identifiers", "C_SerialNumber", "serial_number", "via_device"}
REDACT_BATTERY = {"identifiers", "B_SerialNumber", "serial_number", "via_device"}
REDACT_EVSE = {"identifiers", "C_SerialNumber", "serial_number"}


def format_values(format_input) -> Any:
Expand Down Expand Up @@ -88,4 +89,14 @@ async def async_get_config_entry_diagnostics(
}
data.update(async_redact_data(battery, REDACT_BATTERY))

for evse in hub.evses:
evse: dict[str, Any] = {
f"evse_unit_id_{evse.evse_unit_id}": {
"device_info": evse.device_info,
"common": evse.decoded_common,
"model": format_values(evse.decoded_model),
}
}
data.update(async_redact_data(evse, REDACT_EVSE))

return data
232 changes: 224 additions & 8 deletions custom_components/solaredge_modbus_multi/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

from .const import (
BATTERY_REG_BASE,
DETECT_EVSE_REGEX,
DOMAIN,
METER_REG_BASE,
PYMODBUS_REQUIRED_VERSION,
Expand Down Expand Up @@ -68,6 +69,12 @@ class DeviceInitFailed(SolarEdgeException):
pass


class DeviceIsEVSE(SolarEdgeException):
"""Raised when an inverter device matches a EVSE model"""

pass


class ModbusReadError(SolarEdgeException):
"""Raised when a modbus read fails (generic)"""

Expand Down Expand Up @@ -186,6 +193,7 @@ def __init__(
self.inverters = []
self.meters = []
self.batteries = []
self.evses = []
self.inverter_common = {}
self.mmppt_common = {}
self.has_write = None
Expand Down Expand Up @@ -283,6 +291,17 @@ async def _async_init_solaredge(self) -> None:
_LOGGER.error(f"Inverter at {self.hub_host} ID {inverter_unit_id}: {e}")
raise HubInitFailed(f"{e}")

except DeviceIsEVSE as e:
_LOGGER.debug(
f"Device model matches EVSE at {self.hub_host} ID {inverter_unit_id}: {e}"
)
new_evse = SolarEdgeEVSE(inverter_unit_id, self)
await new_evse.init_device()
self.evses.append(new_evse)

# Skip meter and battery detection if DeviceIsEVSE
continue

if self._detect_meters:
for meter_id in METER_REG_BASE:
try:
Expand Down Expand Up @@ -357,6 +376,8 @@ async def _async_init_solaredge(self) -> None:
await meter.read_modbus_data()
for battery in self.batteries:
await battery.read_modbus_data()
for evse in self.evses:
await evse.read_modbus_data()

timestamp = dt.now()
for inverter in self.inverters:
Expand Down Expand Up @@ -447,6 +468,8 @@ async def async_refresh_modbus_data(self) -> bool:
await meter.read_modbus_data()
for battery in self.batteries:
await battery.read_modbus_data()
for evse in self.evses:
await evse.read_modbus_data()

except ModbusReadError as e:
self.disconnect()
Expand Down Expand Up @@ -551,7 +574,7 @@ async def modbus_read_holding_registers(self, unit, address, rcount):
sig = inspect.signature(self._client.read_holding_registers)

_LOGGER.debug(
f"I{self._rr_unit}: modbus_read_holding_registers "
f"unit={self._rr_unit}: modbus_read_holding_registers "
f"address={self._rr_address} count={self._rr_count}"
)

Expand All @@ -564,38 +587,40 @@ async def modbus_read_holding_registers(self, unit, address, rcount):
address=self._rr_address, count=self._rr_count, slave=self._rr_unit
)

_LOGGER.debug(f"I{self._rr_unit}: result is error: {result.isError()} ")
_LOGGER.debug(f"unit={self._rr_unit}: result is error: {result.isError()} ")

if result.isError():
_LOGGER.debug(f"I{self._rr_unit}: error result: {type(result)} ")
_LOGGER.debug(f"unit={self._rr_unit}: error result: {type(result)} ")

if type(result) is ModbusIOException:
raise ModbusIOError(result)

if type(result) is ExceptionResponse:
if result.exception_code == ModbusExceptions.IllegalAddress:
_LOGGER.debug(f"I{unit} Read IllegalAddress: {result}")
_LOGGER.debug(f"unit={self._rr_unit} Read IllegalAddress: {result}")
raise ModbusIllegalAddress(result)

if result.exception_code == ModbusExceptions.IllegalFunction:
_LOGGER.debug(f"I{unit} Read IllegalFunction: {result}")
_LOGGER.debug(
f"unit={self._rr_unit} Read IllegalFunction: {result}"
)
raise ModbusIllegalFunction(result)

if result.exception_code == ModbusExceptions.IllegalValue:
_LOGGER.debug(f"I{unit} Read IllegalValue: {result}")
_LOGGER.debug(f"unit={self._rr_unit} Read IllegalValue: {result}")
raise ModbusIllegalValue(result)

raise ModbusReadError(result)

_LOGGER.debug(
f"I{self._rr_unit}: Registers received={len(result.registers)} "
f"unit={self._rr_unit}: Registers received={len(result.registers)} "
f"requested={self._rr_count} address={self._rr_address} "
f"result={result}"
)

if len(result.registers) != rcount:
raise ModbusReadError(
f"I{self._rr_unit}: Registers received != requested : "
f"unit={self._rr_unit}: Registers received != requested : "
f"{len(result.registers)} != {self._rr_count} at {self._rr_address}"
)

Expand Down Expand Up @@ -961,6 +986,9 @@ async def init_device(self) -> None:
f"ID {self.inverter_unit_id} is not a SunSpec inverter."
)

if DETECT_EVSE_REGEX.match(self.decoded_common["C_Model"]):
raise DeviceIsEVSE(f"Model {self.decoded_common['C_Model']}")

if (
self.decoded_common["C_SunSpec_ID"] == SunSpecNotImpl.UINT32
or self.decoded_common["C_SunSpec_DID"] == SunSpecNotImpl.UINT16
Expand Down Expand Up @@ -2653,3 +2681,191 @@ def battery_energy_reset_cycles(self) -> int:
@property
def last_update(self) -> datetime.datetime | None:
return self._last_update_timestamp


class SolarEdgeEVSE:
"""Class that defines a SolarEdge EVSE."""

def __init__(self, device_id: int, hub: SolarEdgeModbusMultiHub) -> None:
self.evse_unit_id = device_id
self.hub = hub
self.decoded_common = {}
self.decoded_model = {}
self.has_parent = False

async def init_device(self) -> None:
"""Set up data about the device from modbus."""

try:
evse_data = await self.hub.modbus_read_holding_registers(
unit=self.evse_unit_id, address=40000, rcount=69
)

self.decoded_common = dict(
[
(
"C_SunSpec_ID",
ModbusClientMixin.convert_from_registers(
evse_data.registers[0:2],
data_type=ModbusClientMixin.DATATYPE.UINT32,
),
)
]
)

uint16_fields = [
"C_SunSpec_DID",
"C_SunSpec_Length",
"C_Device_address",
]
uint16_data = evse_data.registers[2:4] + [evse_data.registers[68]]
self.decoded_common.update(
dict(
zip(
uint16_fields,
ModbusClientMixin.convert_from_registers(
uint16_data,
data_type=ModbusClientMixin.DATATYPE.UINT16,
),
)
)
)

self.decoded_common.update(
dict(
[
(
"C_Manufacturer", # string(32)
int_list_to_string(
ModbusClientMixin.convert_from_registers(
evse_data.registers[4:20],
data_type=ModbusClientMixin.DATATYPE.UINT16,
)
),
),
(
"C_Model", # string(32)
int_list_to_string(
ModbusClientMixin.convert_from_registers(
evse_data.registers[20:36],
data_type=ModbusClientMixin.DATATYPE.UINT16,
)
),
),
(
"C_Option", # string(16)
int_list_to_string(
ModbusClientMixin.convert_from_registers(
evse_data.registers[36:44],
data_type=ModbusClientMixin.DATATYPE.UINT16,
)
),
),
(
"C_Version", # string(16)
int_list_to_string(
ModbusClientMixin.convert_from_registers(
evse_data.registers[44:52],
data_type=ModbusClientMixin.DATATYPE.UINT16,
)
),
),
(
"C_SerialNumber", # string(32)
int_list_to_string(
ModbusClientMixin.convert_from_registers(
evse_data.registers[52:68],
data_type=ModbusClientMixin.DATATYPE.UINT16,
)
),
),
]
)
)

for name, value in iter(self.decoded_common.items()):
_LOGGER.debug(
(
f"E{self.evse_unit_id}: "
f"{name} {hex(value) if isinstance(value, int) else value}"
f"{type(value)}"
),
)

except ModbusIOError:
raise DeviceInvalid(f"No response from evse ID {self.evse_unit_id}")

except ModbusIllegalAddress:
raise DeviceInvalid(f"ID {self.evse_unit_id} is not SunSpec.")

if (
self.decoded_common["C_SunSpec_ID"] == SunSpecNotImpl.UINT32
or self.decoded_common["C_SunSpec_DID"] == SunSpecNotImpl.UINT16
or self.decoded_common["C_SunSpec_ID"] != 0x53756E53
or self.decoded_common["C_SunSpec_DID"] != 0x0001
or self.decoded_common["C_SunSpec_Length"] != 65
):
raise DeviceInvalid(f"ID {self.evse_unit_id} is not SunSpec.")

self.manufacturer = self.decoded_common["C_Manufacturer"]
self.model = self.decoded_common["C_Model"]
self.option = self.decoded_common["C_Option"]
self.serial = self.decoded_common["C_SerialNumber"]
self.device_address = self.decoded_common["C_Device_address"]
self.name = f"{self.hub.hub_id.capitalize()} E{self.evse_unit_id}"
self.uid_base = f"{self.model}_{self.serial}"

async def read_modbus_data(self) -> None:
"""Read and update dynamic modbus registers."""

try:
evse_data = await self.hub.modbus_read_holding_registers(
unit=self.evse_unit_id, address=40044, rcount=16
)

self.decoded_common["C_Version"] = int_list_to_string(
ModbusClientMixin.convert_from_registers(
evse_data.registers[0:8],
data_type=ModbusClientMixin.DATATYPE.UINT16,
)
)

for name, value in iter(self.decoded_model.items()):
if isinstance(value, float):
display_value = float_to_hex(value)
else:
display_value = hex(value) if isinstance(value, int) else value
_LOGGER.debug(
f"E{self.evse_unit_id}: {name} {display_value} {type(value)}"
)

except ModbusIllegalAddress:
_LOGGER.error(f"E{self.evse_unit_id}: EVSE register(s) NOT available")

except ModbusIOError:
raise ModbusReadError(f"No response from EVSE ID {self.evse_unit_id}")

@property
def online(self) -> bool:
"""Device is online."""
return self.hub.online

@property
def fw_version(self) -> str | None:
if "C_Version" in self.decoded_common:
return self.decoded_common["C_Version"]

return None

@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
identifiers={(DOMAIN, self.uid_base)},
name=self.name,
manufacturer=self.manufacturer,
model=self.model,
serial_number=self.serial,
sw_version=self.fw_version,
hw_version=self.option,
)
2 changes: 1 addition & 1 deletion custom_components/solaredge_modbus_multi/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"issue_tracker": "https://github.com/WillCodeForCats/solaredge-modbus-multi/issues",
"loggers": ["custom_components.solaredge_modbus_multi"],
"requirements": ["pymodbus>=3.8.3", "awesomeversion>=25.5.0"],
"version": "3.3.0-pre.2"
"version": "3.3.0-evse.6"
}
3 changes: 3 additions & 0 deletions custom_components/solaredge_modbus_multi/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ async def async_setup_entry(
entities.append(SolarEdgeBatterySOE(battery, config_entry, coordinator))
entities.append(SolarEdgeBatteryStatus(battery, config_entry, coordinator))

for evse in hub.evses:
entities.append(Version(evse, config_entry, coordinator))

if entities:
async_add_entities(entities)

Expand Down
Loading