Skip to content

Commit 3e33c8b

Browse files
rusty-bitclaude
andcommitted
Add advanced settings (SmartLoad, peak shaving, ARC, parallel, meter)
Registers from the Deye MODBUS RTU three-phase protocol V105.1, cross-checked against the ha-solarman deye_p3 and kellerza/sunsynk three-phase maps. Switch/select controls accept a bit mask and write only that field, with a fresh read inside the write lock. Settings that can cut communication or break metering are exposed read-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d4ba614 commit 3e33c8b

8 files changed

Lines changed: 313 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog (personal fork)
22

3+
## 0.1.4
4+
- New: advanced settings from the SolarMAN app (SmartLoad Setting / Advanced Function-1).
5+
- Writable: SmartLoad Setup (133), GEN Connect To Grid Input (189), ARC Fault
6+
Detection (181), Gen/Grid Peak Shaving on/off (178 bit fields) and power (190/191),
7+
Asymmetric Phase Feeding (237).
8+
- Read-only (disabled by default): Parallel, Equipment Mode, Parallel Modbus SN (336),
9+
DRM (178), Backup Delay (209), AC Couple Setup (234), MPPT Scan (341), Grid Check
10+
Source (344), Meter Select (345), CT Ratio (347).
11+
- Not included (no documented register): BMS-stop, Neutral to earth bonding,
12+
EX_MeterCT, Grid Tie Meter2, Low_Noise.
13+
- New: `mask:` on switch/select controls writes only that bit field. The register is
14+
re-read inside the write lock, so flags packed into one register don't clobber each other.
15+
- Change: sensor `mask:` now shifts the field down to bit 0.
16+
317
## 0.1.3 (personal fork of upstream 0.1.1)
418
- Fix: settings (battery currents, SOC limits, work mode, switches, TOU) were never
519
applied. Deye hybrids reject Modbus FC06; all writes now use FC16.

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ Rebadged units on the same platform (Sunsynk, etc.) will generally work too.
113113
| Select | Work Mode (Selling First / Zero Export To Load / Zero Export To CT) |
114114
| Number | Max Sell Power, Max Grid Output Power, Active Power Regulation, Battery Max Charge/Discharge Current, Grid Charge Current, Battery Shutdown/Restart/Low SOC, Battery Capacity, Grid Voltage High/Low limits |
115115
| Time-of-use | 6 slots × Time / Power / SOC / Charge Source (Off / Grid / Generator / Grid+Generator) |
116+
| Advanced (fork) | SmartLoad Setup, GEN Connect To Grid Input, ARC Fault Detection, Gen/Grid Peak Shaving + power, Asymmetric Phase Feeding |
117+
118+
**Advanced settings, read-only** (disabled by default): Parallel, Equipment Mode, Parallel Modbus SN, DRM, Backup Delay, AC Couple Setup, MPPT Scan, Grid Check Source, Meter Select, CT Ratio. These are read-only because a wrong value can cut Modbus communication or break zero-export metering; change them in the SolarMAN app.
116119

117120
> **Disabled-by-default entities.** To keep the device tidy, some entities ship disabled: PV3/PV4, per-phase inverter/load detail, generator/backup, grid protection limits, and all time-of-use slots. Enable any of them under the device's **Entities** list → open the entity → **Settings** → toggle *Enabled*.
118121

‎custom_components/deye_modbus/coordinator.py‎

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ def _unit_kw(method) -> str | None:
5757
return None
5858

5959

60+
def parse_mask(value: Any) -> int | None:
61+
"""Accept a mask given as int or as a string such as "0x30" / "0b1100"."""
62+
if value is None:
63+
return None
64+
return int(str(value), 0) & 0xFFFFFFFF
65+
66+
67+
def mask_shift(mask: int) -> int:
68+
"""Bit position of the lowest set bit, used to align a field with its mask."""
69+
return (mask & -mask).bit_length() - 1 if mask else 0
70+
71+
6072
@dataclass
6173
class RegisterDef:
6274
"""One decoded value read from the inverter."""
@@ -74,7 +86,7 @@ class RegisterDef:
7486
signed: bool = False
7587
word_order: str = "high_low" # 32-bit word order: high_low | low_high
7688
options: dict[int, str] | None = None # enum mapping for sensors (0 -> "text")
77-
mask: int | None = None # bitmask applied to raw before sign/scale
89+
mask: int | str | None = None # bit field: (raw & mask) >> lowest mask bit
7890
icon: str | None = None
7991
enabled_default: bool = True # entity_registry_enabled_default
8092
precision: int | None = None # suggested_display_precision (decimals)
@@ -112,6 +124,8 @@ def __init__(
112124
self._polled = [r for r in registers if not r.compute]
113125
# address -> (raw word, expiry) for values we just wrote
114126
self._pending: dict[int, tuple[int, float]] = {}
127+
for r in registers:
128+
r.mask = parse_mask(r.mask)
115129

116130
def _addr(self, addr: int) -> int:
117131
return int(addr) - self._addr_off if self._addr_off else int(addr)
@@ -222,7 +236,7 @@ def _decode(self, r: RegisterDef, raw, start) -> Any:
222236
if r.count == 1:
223237
v = chunk[0]
224238
if r.mask is not None:
225-
v &= r.mask
239+
v = (v & r.mask) >> mask_shift(r.mask)
226240
if r.signed and v >= 0x8000:
227241
v -= 0x10000
228242
return (v - r.offset) * r.scale
@@ -233,7 +247,7 @@ def _decode(self, r: RegisterDef, raw, start) -> Any:
233247
high, low = chunk[0], chunk[1]
234248
v = (high << 16) | low
235249
if r.mask is not None:
236-
v &= r.mask
250+
v = (v & r.mask) >> mask_shift(r.mask)
237251
if r.signed and v >= 0x80000000:
238252
v -= 0x100000000
239253
return (v - r.offset) * r.scale
@@ -262,26 +276,58 @@ async def _read_holding(self, address, count):
262276
async def write_multiple_registers(self, address: int, values: list[int]) -> bool:
263277
"""Write with FC16 - the only write function Deye inverters accept."""
264278
vals = [int(v) & 0xFFFF for v in values]
265-
a = self._addr(address)
279+
async with self._lock:
280+
ok = await self._write_locked(address, vals)
281+
if ok:
282+
await self._after_write(address, vals)
283+
return ok
284+
285+
async def write_register_bits(self, address: int, mask: int, value: int) -> bool:
286+
"""Change only the bits under `mask` in one register, keeping the rest.
287+
288+
`value` is already shifted into position. The register is read fresh from
289+
the inverter inside the lock, so several fields packed into one register
290+
(e.g. 178, peak-shaving flags) cannot overwrite each other.
291+
"""
292+
mask &= 0xFFFF
266293
async with self._lock:
267294
try:
268-
client = await self._ensure_client()
269-
method = client.write_registers
270-
rr = await method(a, vals, **self._unit_kwargs(method))
295+
raw = await self._read_holding(address, 1)
271296
except Exception as err: # noqa: BLE001
272-
_LOGGER.error("Write %s to register %s failed: %s", vals, address, err)
273-
await self._reset_client()
274-
return False
275-
if rr is None or (getattr(rr, "isError", None) and rr.isError()):
276-
_LOGGER.error("Inverter rejected write %s to register %s: %s", vals, address, rr)
297+
raw = None
298+
_LOGGER.debug("Read before bit write of %s raised %s", address, err)
299+
if not raw:
300+
_LOGGER.error("Cannot change bits 0x%04X of register %s: current value unreadable", mask, address)
277301
return False
278-
expiry = time.monotonic() + PENDING_WRITE_SECONDS
279-
for i, v in enumerate(vals):
280-
self._pending[address + i] = (v, expiry)
302+
new = (int(raw[0]) & ~mask & 0xFFFF) | (int(value) & mask)
303+
ok = await self._write_locked(address, [new])
304+
if ok:
305+
await self._after_write(address, [new])
306+
return ok
307+
308+
async def _write_locked(self, address: int, vals: list[int]) -> bool:
309+
"""FC16 write; caller must hold self._lock."""
310+
a = self._addr(address)
311+
try:
312+
client = await self._ensure_client()
313+
method = client.write_registers
314+
rr = await method(a, vals, **self._unit_kwargs(method))
315+
except Exception as err: # noqa: BLE001
316+
_LOGGER.error("Write %s to register %s failed: %s", vals, address, err)
317+
await self._reset_client()
318+
return False
319+
if rr is None or (getattr(rr, "isError", None) and rr.isError()):
320+
_LOGGER.error("Inverter rejected write %s to register %s: %s", vals, address, rr)
321+
return False
322+
expiry = time.monotonic() + PENDING_WRITE_SECONDS
323+
for i, v in enumerate(vals):
324+
self._pending[address + i] = (v, expiry)
281325
_LOGGER.debug("Wrote %s to register %s", vals, address)
326+
return True
327+
328+
async def _after_write(self, address: int, vals: list[int]) -> None:
282329
self._patch_data(address, vals)
283330
await self.async_request_refresh()
284-
return True
285331

286332
async def write_single_register(self, address: int, value: int) -> bool:
287333
# Deliberately FC16 with one value, not FC06.

‎custom_components/deye_modbus/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@
1616
"requirements": [
1717
"pymodbus>=3.5.0"
1818
],
19-
"version": "0.1.3"
19+
"version": "0.1.4"
2020
}

‎custom_components/deye_modbus/maps/sun_3ph_hybrid.yaml‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,98 @@ sensors:
787787
state_class: total_increasing
788788

789789

790+
# ---------------------------------------------------------------- advanced settings (read-only)
791+
# Installer settings shown under "Advanced Function" in the SolarMAN app.
792+
# Exposed read-only on purpose: a wrong value here can cut Modbus
793+
# communication (Modbus SN / parallel role), break zero-export metering
794+
# (CT ratio / meter), or is marked "engineer only" in the protocol.
795+
# Source: Deye MODBUS RTU three-phase protocol V105.1 (2023-10-06).
796+
- name: Parallel
797+
unique_id: adv_parallel
798+
register_type: holding
799+
address: 336
800+
mask: 0x0001
801+
options: {0: Disable, 1: Enable}
802+
icon: mdi:link-variant
803+
enabled_default: false
804+
805+
- name: Equipment Mode
806+
unique_id: adv_equipment_mode
807+
register_type: holding
808+
address: 336
809+
mask: 0x0002
810+
options: {0: Slave, 1: Master}
811+
icon: mdi:account-supervisor
812+
enabled_default: false
813+
814+
- name: Parallel Modbus SN
815+
unique_id: adv_modbus_sn
816+
register_type: holding
817+
address: 336
818+
mask: 0xFC00
819+
icon: mdi:numeric
820+
enabled_default: false
821+
822+
- name: DRM
823+
unique_id: adv_drm
824+
register_type: holding
825+
address: 178
826+
mask: 0x3000
827+
options: {2: Disable, 3: Enable}
828+
icon: mdi:shield-check
829+
enabled_default: false
830+
831+
- name: Backup Delay
832+
unique_id: adv_backup_delay
833+
register_type: holding
834+
address: 209
835+
unit_of_measurement: s
836+
icon: mdi:timer-outline
837+
enabled_default: false
838+
839+
- name: CT Ratio
840+
unique_id: adv_ct_ratio
841+
register_type: holding
842+
address: 347
843+
icon: mdi:current-ac
844+
enabled_default: false
845+
846+
- name: Grid Check Source
847+
unique_id: adv_grid_check_source
848+
register_type: holding
849+
address: 344
850+
mask: 0x0001
851+
options: {0: CT, 1: Meter}
852+
icon: mdi:meter-electric
853+
enabled_default: false
854+
855+
- name: Meter Select
856+
unique_id: adv_meter_select
857+
register_type: holding
858+
address: 345
859+
options: {0: None, 1: Eastron, 2: CHNT}
860+
icon: mdi:meter-electric-outline
861+
enabled_default: false
862+
863+
- name: MPPT Scan
864+
unique_id: adv_mppt_scan
865+
register_type: holding
866+
address: 341
867+
mask: 0x0004
868+
options: {0: Disable, 1: Enable}
869+
icon: mdi:solar-panel
870+
enabled_default: false
871+
872+
# Protocol V105 only documents 0 = disable / 1 = enable; newer firmware
873+
# stores the coupling side, so other values are shown as the raw number.
874+
- name: AC Couple Setup
875+
unique_id: adv_ac_couple
876+
register_type: holding
877+
address: 234
878+
options: {0: Disable}
879+
icon: mdi:transmission-tower
880+
enabled_default: false
881+
790882
# =====================================================================
791883
# Writable controls (holding registers 60-499, written with FC06/FC16).
792884
# Each control gets a hidden read-back sensor injected automatically so the
@@ -1367,3 +1459,101 @@ controls:
13671459
value: 2
13681460
- label: Grid + Generator
13691461
value: 3
1462+
1463+
# ------------------------------------------------------------- advanced functions
1464+
# SolarMAN app: SmartLoad Setting / Advanced Function-1.
1465+
# Source: Deye MODBUS RTU three-phase protocol V105.1, cross-checked against
1466+
# the ha-solarman (deye_p3) and kellerza/sunsynk three-phase definitions.
1467+
- type: select
1468+
name: SmartLoad Setup
1469+
unique_id: smartload_setup
1470+
register_type: holding
1471+
address: 133
1472+
icon: mdi:lightning-bolt-circle
1473+
entity_category: config
1474+
options:
1475+
- label: Generator Input
1476+
value: 0
1477+
- label: SmartLoad Output
1478+
value: 1
1479+
- label: Micro Inverter Input
1480+
value: 2
1481+
1482+
- type: switch
1483+
name: GEN Connect To Grid Input
1484+
unique_id: gen_to_grid_input
1485+
register_type: holding
1486+
address: 189
1487+
on_value: 1
1488+
off_value: 0
1489+
icon: mdi:generator-stationary
1490+
entity_category: config
1491+
1492+
- type: switch
1493+
name: ARC Fault Detection
1494+
unique_id: arc_fault_mode
1495+
register_type: holding
1496+
address: 181
1497+
on_value: 1
1498+
off_value: 0
1499+
icon: mdi:flash-alert
1500+
entity_category: config
1501+
1502+
# Register 178 packs 2-bit flags: 10 = disable, 11 = enable (00/01 = no change).
1503+
- type: switch
1504+
name: Gen Peak Shaving
1505+
unique_id: gen_peak_shaving
1506+
register_type: holding
1507+
address: 178
1508+
mask: 0x000C
1509+
on_value: 3
1510+
off_value: 2
1511+
icon: mdi:generator-stationary
1512+
entity_category: config
1513+
1514+
- type: number
1515+
name: Gen Peak Shaving Power
1516+
unique_id: gen_peak_shaving_power
1517+
register_type: holding
1518+
address: 190
1519+
min: 0
1520+
max: 16000
1521+
step: 100
1522+
unit_of_measurement: W
1523+
mode: box
1524+
write_factor: 1
1525+
entity_category: config
1526+
1527+
- type: switch
1528+
name: Grid Peak Shaving
1529+
unique_id: grid_peak_shaving
1530+
register_type: holding
1531+
address: 178
1532+
mask: 0x0030
1533+
on_value: 3
1534+
off_value: 2
1535+
icon: mdi:transmission-tower
1536+
entity_category: config
1537+
1538+
- type: number
1539+
name: Grid Peak Shaving Power
1540+
unique_id: grid_peak_shaving_power
1541+
register_type: holding
1542+
address: 191
1543+
min: 0
1544+
max: 16000
1545+
step: 100
1546+
unit_of_measurement: W
1547+
mode: box
1548+
write_factor: 1
1549+
entity_category: config
1550+
1551+
- type: switch
1552+
name: Asymmetric Phase Feeding
1553+
unique_id: asymmetric_phase_feeding
1554+
register_type: holding
1555+
address: 237
1556+
on_value: 1
1557+
off_value: 0
1558+
icon: mdi:sine-wave
1559+
entity_category: config

0 commit comments

Comments
 (0)