From 433f70449adf2d6f9aca73d24088a2715777ca0e Mon Sep 17 00:00:00 2001 From: Dimitriy Ryazantcev Date: Sun, 7 Jun 2026 19:03:38 +0300 Subject: [PATCH] feat: Add EV Charger control | Deye 3P MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds EV Charger configuration and monitoring to the Deye 3-phase inverter profile (deye_p3.yaml), with supporting read-modify-write fixes in the select and number entity platforms. Profile (deye_p3.yaml) — new group "EV Charger": - EV Charger Power Mode (register 0x0103, bits[1:0]): Solar Only / Free Work - EV Charger Inverter Connection Port (register 0x0103, bits[5:4]): Grid Port / Load Port - EV Charger Off-Grid SOC (register 0x0103, hi-byte): minimum battery SOC threshold for off-grid charging, read-modify-write via mask + divide - EV Charger Maximum Power (register 0x0104): inverter-side power cap (0–22000 W) - EV Charger Requested Power Limit (register 0x02C5): live power limit sent to the EVSE by the inverter, update_interval: 5 Platform fixes: - select.py: read-modify-write for `mask` (top-level, not display.mask) — reads current register, clears masked bits, ORs in new value before writing; triggers coordinator refresh after masked writes - number.py: same read-modify-write pattern for number entities with `mask` and `divide` (used by Off-Grid SOC); fixes value_int overflow check (>= 0xFFFF → <= 0xFFFF) - entity.py: trigger async_request_refresh after write when entity has a mask, so dependent sensors update immediately Co-Authored-By: Claude Sonnet 4.6 --- custom_components/solarman/entity.py | 3 +- .../inverter_definitions/deye_p3.yaml | 74 +++++++++++++++++++ custom_components/solarman/number.py | 11 ++- custom_components/solarman/select.py | 12 ++- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/custom_components/solarman/entity.py b/custom_components/solarman/entity.py index 62fb86c6..23368e72 100644 --- a/custom_components/solarman/entity.py +++ b/custom_components/solarman/entity.py @@ -170,4 +170,5 @@ async def write(self, value, state = None) -> None: self.set_state(state, value) self.async_write_ha_state() #await self.entity_description.update_fn(self.coordinator., int(value)) - #await self.coordinator.async_request_refresh() + if getattr(self, "mask", None) is not None: + await self.coordinator.async_request_refresh() diff --git a/custom_components/solarman/inverter_definitions/deye_p3.yaml b/custom_components/solarman/inverter_definitions/deye_p3.yaml index 34832c51..c44bd14c 100644 --- a/custom_components/solarman/inverter_definitions/deye_p3.yaml +++ b/custom_components/solarman/inverter_definitions/deye_p3.yaml @@ -3150,6 +3150,80 @@ parameters: rule: 1 registers: [0x02DA] icon: "mdi:solar-power-variant" + + - group: EV Charger + update_interval: 300 + items: + - name: "EV Charger Power Mode" + description: "Solar Only: charging uses surplus solar power and starts when battery SOC reaches 99%; charging stops below 95% SOC. Free Work: charging can use both inverter and grid power." + platform: select + rule: 1 + registers: [0x0103] + mask: 0x0003 + icon: "mdi:ev-station" + lookup: + - key: 0x0001 + value: "Solar Only" + - key: 0x0002 + value: "Free Work" + + - name: "EV Charger Inverter Connection Port" + description: "Physical inverter port used by the EV charger. Affects load management, power balancing, and off-grid operation." + platform: select + rule: 1 + registers: [0x0103] + mask: 0x0030 + icon: "mdi:power-plug" + lookup: + - key: 0x0010 + value: "Grid Port" + - key: 0x0020 + value: "Load Port" + + - name: "EV Charger Off-Grid SOC" + description: "Minimum battery SOC required to allow EV charging in off-grid mode. Charging stops when SOC falls below this threshold." + platform: number + state_class: measurement + uom: "%" + rule: 1 + mask: 0xFF00 + divide: 256 + registers: [0x0103] + icon: "mdi:battery-charging" + configurable: + mode: box + min: 0 + max: 100 + + - name: "EV Charger Maximum Power" + description: "Maximum EV charging power allowed by the inverter. The inverter will not request a charging limit above this value. Minimum charging power is approximately ~1.4 kW single-phase or ~4.1 kW three-phase." + platform: number + class: power + state_class: measurement + uom: "W" + scale: 1 + rule: 1 + registers: [0x0104] + icon: "mdi:ev-station" + configurable: + min: 0 + max: 22000 + step: 1 + mode: box + range: + min: 0 + max: 22000 + + - name: "EV Charger Requested Power Limit" + description: "Charging power limit currently requested by the inverter and sent to the EV charger. Dynamically adjusted based on available solar power in Solar Only mode, or set to the configured maximum in Free Work mode." + update_interval: 5 + class: power + state_class: measurement + uom: "W" + scale: 1 + rule: 1 + registers: [0x02C5] + icon: "mdi:ev-station" - group: Battery 1 via_device: "inverter" diff --git a/custom_components/solarman/number.py b/custom_components/solarman/number.py index dc5ecc0b..475e7114 100644 --- a/custom_components/solarman/number.py +++ b/custom_components/solarman/number.py @@ -43,6 +43,9 @@ def __init__(self, coordinator, sensor): if "offset" in sensor: self.offset = get_number(sensor["offset"]) + self.mask = sensor.get("mask") + self.divide = sensor.get("divide") + if "configurable" in sensor and (configurable := sensor["configurable"]): if "mode" in configurable: self._attr_mode = configurable["mode"] @@ -65,4 +68,10 @@ async def async_set_native_value(self, value: float) -> None: value_int = int(value if self.scale is None else value / self.scale) if self.offset is not None: value_int += self.offset - await self.write(value_int if value_int < 0xFFFF else 0xFFFF, get_number(value)) + if self.divide is not None: + value_int *= self.divide + if self.mask is not None: + current = await self.coordinator.device.execute(self.code_read, self.register, count = 1) + current_raw = current[0] if current else 0 + value_int = (current_raw & ~self.mask) | (value_int & self.mask) + await self.write(value_int if value_int <= 0xFFFF else 0xFFFF, get_number(value)) diff --git a/custom_components/solarman/select.py b/custom_components/solarman/select.py index 9d04894b..4e6d1eed 100644 --- a/custom_components/solarman/select.py +++ b/custom_components/solarman/select.py @@ -114,7 +114,8 @@ class SolarmanSelectEntity(SolarmanWritableEntity, SelectEntity): def __init__(self, coordinator, sensor): SolarmanWritableEntity.__init__(self, coordinator, sensor) - self.mask = display.get("mask") if (display := sensor.get("display")) else None + display = sensor.get("display") + self.mask = (display.get("mask") if display else None) or sensor.get("mask") if "lookup" in sensor: self.dictionary = sensor["lookup"] @@ -126,7 +127,7 @@ def get_key(self, value: str): if self.dictionary: for o in self.dictionary: if o["value"] == value and (key := from_bit_index(o["bit"]) if "bit" in o else o["key"]) is not None: - return (key if not "mode" in o else (self._attr_value | key)) if not self.mask else (self._attr_value & (0xFFFFFFFF - self.mask) | key) + return key if self.mask else (key if not "mode" in o else (self._attr_value | key)) return self.options.index(value) @@ -141,4 +142,9 @@ def current_option(self): async def async_select_option(self, option: str): """Change the selected option.""" - await self.write(self.get_key(option), option) + key = self.get_key(option) + if self.mask is not None: + current = await self.coordinator.device.execute(self.code_read, self.register, count = 1) + current_raw = current[0] if current else 0 + key = (current_raw & ~self.mask) | (key & self.mask) + await self.write(key, option)