Guest User

SolarEdge Power Limit

a guest
May 1st, 2026
76
0
Never
3
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 19.51 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. SolarEdge Modbus TCP helper for reading power-control settings and curtailing production.
  4.  
  5. Default target: 192.168.XXX.XXX:1502, unit/slave id 1.
  6.  
  7. Typical usage:
  8. python3 solaredge_control.py status
  9. python3 solaredge_control.py standby --yes # set Active Power Limit to 0%
  10. python3 solaredge_control.py production --yes # set Active Power Limit to 100%
  11. python3 solaredge_control.py limit 25 --yes
  12. python3 solaredge_control.py power # show L_AC_VA
  13.  
  14. Status modes:
  15. status reads reliable control registers plus live apparent AC power
  16. status --full probes optional/export registers too; this may cause timeout messages on some firmware
  17. probe tests a few candidate register/count combinations for troubleshooting
  18.  
  19. Notes:
  20. - 0xF001 Active Power Limit is a dynamic command. It is not the same as SetApp Maintenance > Standby Mode.
  21. - Setting 0xF001 to 0% is standby-like PV curtailment; setting it to 100% resumes normal production limit.
  22. - Dynamic limits may be lost after inverter restart, so check status after a reboot.
  23. """
  24.  
  25. from __future__ import annotations
  26.  
  27. import argparse
  28. import logging
  29. import math
  30. import struct
  31. import sys
  32. import time
  33. from dataclasses import dataclass
  34. from typing import Callable, Optional, Sequence
  35.  
  36. # Keep pymodbus from printing scary warnings for optional registers that simply do not exist/respond.
  37. logging.basicConfig(level=logging.WARNING)
  38. for logger_name in ("pymodbus", "pymodbus.logging", "pymodbus.transaction", "pymodbus.client"):
  39. logging.getLogger(logger_name).setLevel(logging.CRITICAL)
  40.  
  41. try:
  42. from pymodbus.client import ModbusTcpClient
  43. except ImportError as exc: # pragma: no cover
  44. print("pymodbus is not installed. Install it with: pip install pymodbus", file=sys.stderr)
  45. raise SystemExit(2) from exc
  46.  
  47.  
  48. DEFAULT_HOST = "192.168.XXX.XXX"
  49. DEFAULT_PORT = 1502
  50. DEFAULT_UNIT = 1
  51.  
  52. REG_APPARENT_POWER_VA = 40087 # L_AC_VA, apparent AC power, signed int16, unit VA
  53.  
  54. # SolarEdge Power Control registers. Use the addresses directly, e.g. 0xF001 == 61441.
  55. REG_RRCR_STATE = 0xF000
  56. REG_ACTIVE_POWER_LIMIT = 0xF001
  57. REG_COSPHI_DYNAMIC = 0xF002
  58. REG_COMMIT_POWER_CONTROL = 0xF100
  59.  
  60. # Firmware/examples differ around these two settings, so v3 treats them as optional/fallback probes.
  61. REG_REACTIVE_POWER_CONFIG_A = 0xF103
  62. REG_REACTIVE_POWER_CONFIG_B = 0xF104
  63. REG_ADVANCED_POWER_CONTROL = 0xF142
  64.  
  65. REG_ENH_DYNAMIC_CONTROL = 0xF300
  66. REG_MAX_ACTIVE_POWER = 0xF304
  67. REG_DYNAMIC_ACTIVE_POWER_LIMIT = 0xF322
  68.  
  69. REG_EXPORT_MODE_E = 0xE000
  70. REG_EXPORT_LIMIT_MODE_E = 0xE001
  71. REG_EXPORT_SITE_LIMIT_E = 0xE002
  72. REG_EXPORT_MODE_F = 0xF700
  73. REG_EXPORT_LIMIT_MODE_F = 0xF701
  74. REG_EXPORT_SITE_LIMIT_F = 0xF702
  75.  
  76.  
  77. @dataclass(frozen=True)
  78. class RegisterValue:
  79. name: str
  80. address: int
  81. value: object
  82. unit: str = ""
  83. note: str = ""
  84.  
  85.  
  86. class SolarEdgeModbus:
  87. def __init__(self, host: str, port: int, unit: int, timeout: float = 3.0, retries: int = 0) -> None:
  88. self.host = host
  89. self.port = port
  90. self.unit = unit
  91. # Different pymodbus versions accept slightly different constructor args.
  92. try:
  93. self.client = ModbusTcpClient(host=host, port=port, timeout=timeout, retries=retries)
  94. except TypeError:
  95. self.client = ModbusTcpClient(host=host, port=port, timeout=timeout)
  96.  
  97. def __enter__(self) -> "SolarEdgeModbus":
  98. if not self.client.connect():
  99. raise RuntimeError(f"Could not connect to {self.host}:{self.port}")
  100. return self
  101.  
  102. def __exit__(self, exc_type, exc, tb) -> None:
  103. self.client.close()
  104.  
  105. def _read_holding(self, address: int, count: int):
  106. try:
  107. return self.client.read_holding_registers(address=address, count=count, slave=self.unit)
  108. except TypeError:
  109. return self.client.read_holding_registers(address=address, count=count, unit=self.unit)
  110.  
  111. def _write_register(self, address: int, value: int):
  112. try:
  113. return self.client.write_register(address=address, value=value, slave=self.unit)
  114. except TypeError:
  115. return self.client.write_register(address=address, value=value, unit=self.unit)
  116.  
  117. def _write_registers(self, address: int, values: Sequence[int]):
  118. try:
  119. return self.client.write_registers(address=address, values=list(values), slave=self.unit)
  120. except TypeError:
  121. return self.client.write_registers(address=address, values=list(values), unit=self.unit)
  122.  
  123. @staticmethod
  124. def _ensure_ok(result, action: str) -> None:
  125. if result is None:
  126. raise RuntimeError(f"{action}: no response")
  127. if hasattr(result, "isError") and result.isError():
  128. raise RuntimeError(f"{action}: Modbus error: {result}")
  129.  
  130. def read_regs(self, address: int, count: int) -> list[int]:
  131. result = self._read_holding(address, count)
  132. self._ensure_ok(result, f"read 0x{address:04X}/{count}")
  133. return list(result.registers)
  134.  
  135. def write_u16(self, address: int, value: int) -> None:
  136. result = self._write_register(address, value & 0xFFFF)
  137. self._ensure_ok(result, f"write 0x{address:04X}")
  138.  
  139. def write_u32(self, address: int, value: int) -> None:
  140. data = struct.pack(">I", value & 0xFFFFFFFF)
  141. regs = list(struct.unpack(">HH", data))
  142. result = self._write_registers(address=address, values=regs)
  143. self._ensure_ok(result, f"write 0x{address:04X}")
  144.  
  145. def write_float32(self, address: int, value: float) -> None:
  146. data = struct.pack(">f", float(value))
  147. regs = list(struct.unpack(">HH", data))
  148. result = self._write_registers(address=address, values=regs)
  149. self._ensure_ok(result, f"write 0x{address:04X}")
  150.  
  151. @staticmethod
  152. def decode_u16(regs: list[int]) -> int:
  153. return regs[0]
  154.  
  155. @staticmethod
  156. def decode_i16(regs: list[int]) -> int:
  157. value = regs[0]
  158. return value - 0x10000 if value & 0x8000 else value
  159.  
  160. @staticmethod
  161. def decode_u32(regs: list[int]) -> int:
  162. return struct.unpack(">I", struct.pack(">HH", regs[0], regs[1]))[0]
  163.  
  164. @staticmethod
  165. def decode_i32(regs: list[int]) -> int:
  166. return struct.unpack(">i", struct.pack(">HH", regs[0], regs[1]))[0]
  167.  
  168. @staticmethod
  169. def decode_float32(regs: list[int]) -> float:
  170. return struct.unpack(">f", struct.pack(">HH", regs[0], regs[1]))[0]
  171.  
  172. @staticmethod
  173. def decode_first_u16(regs: list[int]) -> int:
  174. # Useful for inverters that only answer F142 when two registers are requested.
  175. return regs[0]
  176.  
  177. def try_read(
  178. self,
  179. name: str,
  180. address: int,
  181. count: int,
  182. decoder: Callable[[list[int]], object],
  183. unit: str = "",
  184. note: str = "",
  185. show_error: bool = False,
  186. ) -> RegisterValue:
  187. try:
  188. regs = self.read_regs(address, count)
  189. return RegisterValue(name=name, address=address, value=decoder(regs), unit=unit, note=note)
  190. except Exception as exc:
  191. msg = f"unavailable ({exc})" if show_error else "unavailable"
  192. return RegisterValue(name=name, address=address, value=msg, note=note)
  193.  
  194. def try_read_candidates(
  195. self,
  196. name: str,
  197. candidates: Sequence[tuple[int, int, Callable[[list[int]], object], str]],
  198. show_error: bool = False,
  199. ) -> RegisterValue:
  200. errors: list[str] = []
  201. for address, count, decoder, note in candidates:
  202. try:
  203. regs = self.read_regs(address, count)
  204. return RegisterValue(name=name, address=address, value=decoder(regs), note=note)
  205. except Exception as exc:
  206. errors.append(f"0x{address:04X}/{count}: {exc}")
  207. msg = "unavailable"
  208. if show_error and errors:
  209. msg = "unavailable (" + "; ".join(errors) + ")"
  210. return RegisterValue(name=name, address=candidates[0][0], value=msg, note="tried fallback addresses")
  211.  
  212. def read_status(self, full: bool = False, show_errors: bool = False) -> list[RegisterValue]:
  213. # Start with registers that your inverter has already answered reliably.
  214. values: list[RegisterValue] = [
  215. self.try_read("Apparent Power (L_AC_VA)", REG_APPARENT_POWER_VA, 1, self.decode_i16, "VA", "", show_error=show_errors),
  216. self.try_read("RRCR state", REG_RRCR_STATE, 1, self.decode_u16, show_error=show_errors),
  217. self.try_read("Active Power Limit", REG_ACTIVE_POWER_LIMIT, 1, self.decode_u16, "%", show_error=show_errors),
  218. self.try_read("Commit Power Control status", REG_COMMIT_POWER_CONTROL, 1, self.decode_i16, show_error=show_errors),
  219. self.try_read("Enhanced Dynamic Power Control", REG_ENH_DYNAMIC_CONTROL, 1, self.decode_u16, show_error=show_errors),
  220. self.try_read("Dynamic Active Power Limit", REG_DYNAMIC_ACTIVE_POWER_LIMIT, 2, self.decode_float32, "%", show_error=show_errors),
  221. ]
  222.  
  223. # These are useful settings, but some firmware rejects one-register reads here.
  224. # v3 tries the variants that are most likely to respond based on your previous output.
  225. values.extend([
  226. self.try_read_candidates(
  227. "ReactivePwrConfig",
  228. [
  229. (REG_REACTIVE_POWER_CONFIG_B, 1, self.decode_u16, "fallback 0xF104/u16"),
  230. (REG_REACTIVE_POWER_CONFIG_B, 2, self.decode_i32, "fallback 0xF104/i32"),
  231. ],
  232. show_error=show_errors,
  233. ),
  234. self.try_read_candidates(
  235. "AdvancedPwrControlEn",
  236. [
  237. (REG_ADVANCED_POWER_CONTROL, 2, self.decode_first_u16, "read 2 regs, show first word"),
  238. (REG_ADVANCED_POWER_CONTROL, 1, self.decode_u16, "read 1 reg"),
  239. ],
  240. show_error=show_errors,
  241. ),
  242. ])
  243.  
  244. if full:
  245. values.extend([
  246. self.try_read("Max Active Power", REG_MAX_ACTIVE_POWER, 2, self.decode_float32, "W", show_error=show_errors),
  247. self.try_read("Export Control Mode E000", REG_EXPORT_MODE_E, 1, self.decode_u16, show_error=show_errors),
  248. self.try_read("Export Limit Mode E001", REG_EXPORT_LIMIT_MODE_E, 1, self.decode_u16, show_error=show_errors),
  249. self.try_read("Export Site Limit E002", REG_EXPORT_SITE_LIMIT_E, 2, self.decode_float32, "W", show_error=show_errors),
  250. self.try_read("Export Control Mode F700", REG_EXPORT_MODE_F, 1, self.decode_u16, show_error=show_errors),
  251. self.try_read("Export Limit Mode F701", REG_EXPORT_LIMIT_MODE_F, 1, self.decode_u16, show_error=show_errors),
  252. self.try_read("Export Site Limit F702", REG_EXPORT_SITE_LIMIT_F, 2, self.decode_float32, "W", show_error=show_errors),
  253. ])
  254. return values
  255.  
  256. def read_operating_limit(self) -> int:
  257. return self.decode_u16(self.read_regs(REG_ACTIVE_POWER_LIMIT, 1))
  258.  
  259. def read_apparent_power_va(self) -> int:
  260. # Register at 40087, decoded as signed 16-bit.
  261. return self.decode_i16(self.read_regs(REG_APPARENT_POWER_VA, 1))
  262.  
  263. def set_active_power_limit(self, percent: int) -> None:
  264. if not 0 <= percent <= 100:
  265. raise ValueError("Active power limit must be between 0 and 100 percent")
  266. self.write_u16(REG_ACTIVE_POWER_LIMIT, percent)
  267.  
  268. def enable_basic_power_control(self, reactive_register: int = REG_REACTIVE_POWER_CONFIG_A) -> int:
  269. """Experimental: enable static power-control settings and commit."""
  270. self.write_u16(REG_ADVANCED_POWER_CONTROL, 1)
  271. self.write_u32(reactive_register, 4)
  272. self.write_u16(REG_COMMIT_POWER_CONTROL, 1)
  273. time.sleep(10)
  274. return self.decode_i16(self.read_regs(REG_COMMIT_POWER_CONTROL, 1))
  275.  
  276. def probe(self, show_errors: bool = False) -> list[RegisterValue]:
  277. tests = [
  278. ("40087 Apparent Power L_AC_VA i16", REG_APPARENT_POWER_VA, 1, self.decode_i16),
  279. ("F001 Active Power Limit u16", REG_ACTIVE_POWER_LIMIT, 1, self.decode_u16),
  280. ("F103 Reactive candidate i32", REG_REACTIVE_POWER_CONFIG_A, 2, self.decode_i32),
  281. ("F104 Reactive candidate u16", REG_REACTIVE_POWER_CONFIG_B, 1, self.decode_u16),
  282. ("F104 Reactive candidate i32", REG_REACTIVE_POWER_CONFIG_B, 2, self.decode_i32),
  283. ("F142 Advanced candidate u16", REG_ADVANCED_POWER_CONTROL, 1, self.decode_u16),
  284. ("F142 Advanced candidate first-of-2", REG_ADVANCED_POWER_CONTROL, 2, self.decode_first_u16),
  285. ("F300 Enhanced Dynamic Control u16", REG_ENH_DYNAMIC_CONTROL, 1, self.decode_u16),
  286. ("F322 Dynamic Active Limit float32", REG_DYNAMIC_ACTIVE_POWER_LIMIT, 2, self.decode_float32),
  287. ]
  288. return [
  289. self.try_read(name, address, count, decoder, show_error=show_errors)
  290. for name, address, count, decoder in tests
  291. ]
  292.  
  293.  
  294. def format_value(value: object) -> str:
  295. if isinstance(value, float):
  296. if math.isnan(value):
  297. return "nan"
  298. if abs(value) < 1000:
  299. return f"{value:.3f}"
  300. return f"{value:.1f}"
  301. return str(value)
  302.  
  303.  
  304. def print_values(title: str, values: list[RegisterValue]) -> None:
  305. print(f"\n{title}")
  306. print("-" * 88)
  307. for item in values:
  308. suffix = f" {item.unit}" if item.unit else ""
  309. note = f" [{item.note}]" if item.note else ""
  310. print(f"0x{item.address:04X} {item.name:<38} {format_value(item.value)}{suffix}{note}")
  311. print("-" * 88)
  312.  
  313.  
  314. def print_interpretation(full: bool = False) -> None:
  315. print("Interpretation:")
  316. print(" Active Power Limit 0% = standby-like curtailment / no PV production")
  317. print(" Active Power Limit 100% = normal production limit")
  318. print(" Apparent Power is the live L_AC_VA value")
  319. print(" If standby/production works, F103/F104/F142 do not need to be changed.")
  320. if not full:
  321. print(" Use 'status --full' only when you want to probe optional/export registers.")
  322.  
  323.  
  324. def require_yes(args: argparse.Namespace, action: str) -> None:
  325. if not getattr(args, "yes", False):
  326. raise SystemExit(
  327. f"Refusing to {action} without --yes. Re-run with --yes after checking this is allowed for your installation."
  328. )
  329.  
  330.  
  331. def build_parser() -> argparse.ArgumentParser:
  332. parser = argparse.ArgumentParser(description="Read and control SolarEdge inverter power-control settings over Modbus TCP")
  333. parser.add_argument("--host", default=DEFAULT_HOST, help=f"Inverter IP address, default {DEFAULT_HOST}")
  334. parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Modbus TCP port, default {DEFAULT_PORT}")
  335. parser.add_argument("--unit", type=int, default=DEFAULT_UNIT, help=f"Modbus unit/slave id, default {DEFAULT_UNIT}")
  336. parser.add_argument("--timeout", type=float, default=3.0, help="Modbus timeout in seconds, default 3")
  337. parser.add_argument("--retries", type=int, default=0, help="Modbus retry count where supported by pymodbus, default 0")
  338. parser.add_argument("--yes", action="store_true", help="Actually perform write operations")
  339.  
  340. def add_yes(subparser: argparse.ArgumentParser) -> None:
  341. subparser.add_argument("--yes", action="store_true", default=argparse.SUPPRESS, help="Actually perform write operations")
  342.  
  343. sub = parser.add_subparsers(dest="command", required=True)
  344.  
  345. p_status = sub.add_parser("status", help="Read current control/status registers")
  346. p_status.add_argument("--full", action="store_true", help="Also probe optional/export registers")
  347. p_status.add_argument("--show-errors", action="store_true", help="Show full Modbus errors for unavailable registers")
  348.  
  349. p_probe = sub.add_parser("probe", help="Test candidate register/count combinations")
  350. p_probe.add_argument("--show-errors", action="store_true", help="Show full Modbus errors for unavailable registers")
  351.  
  352. sub.add_parser("power", help="Read live Apparent Power / L_AC_VA ")
  353.  
  354. p_limit = sub.add_parser("limit", help="Set Active Power Limit percentage, 0..100")
  355. p_limit.add_argument("percent", type=int)
  356. add_yes(p_limit)
  357.  
  358. p_standby = sub.add_parser("standby", help="Alias for: limit 0")
  359. add_yes(p_standby)
  360.  
  361. p_production = sub.add_parser("production", help="Alias for: limit 100")
  362. add_yes(p_production)
  363.  
  364. p_enable = sub.add_parser("enable-control", help="Experimental: set AdvancedPwrControlEn=1 and ReactivePwrConfig=4, then commit")
  365. p_enable.add_argument("--reactive-register", choices=["F103", "F104"], default="F103", help="ReactivePwrConfig base register to write, default F103")
  366. add_yes(p_enable)
  367.  
  368. return parser
  369.  
  370.  
  371. def print_after_write(se: SolarEdgeModbus) -> None:
  372. try:
  373. limit = se.read_operating_limit()
  374. print(f"Read-back Active Power Limit: {limit}%")
  375. except Exception as exc:
  376. print(f"Write was sent, but read-back failed: {exc}")
  377.  
  378.  
  379. def main(argv: Optional[list[str]] = None) -> int:
  380. args = build_parser().parse_args(argv)
  381.  
  382. try:
  383. with SolarEdgeModbus(args.host, args.port, args.unit, timeout=args.timeout, retries=args.retries) as se:
  384. if args.command == "status":
  385. values = se.read_status(full=args.full, show_errors=args.show_errors)
  386. title = "SolarEdge Modbus power-control status"
  387. if args.full:
  388. title += " (full probe)"
  389. print_values(title, values)
  390. print_interpretation(full=args.full)
  391. return 0
  392.  
  393. if args.command == "probe":
  394. print_values("SolarEdge Modbus register probe", se.probe(show_errors=args.show_errors))
  395. return 0
  396.  
  397. if args.command == "power":
  398. value = se.read_apparent_power_va()
  399. print(f"⚡ Apparent Power (L_AC_VA): {value} VA")
  400. return 0
  401.  
  402. if args.command == "limit":
  403. require_yes(args, f"set Active Power Limit to {args.percent}%")
  404. se.set_active_power_limit(args.percent)
  405. print(f"Set Active Power Limit to {args.percent}%")
  406. print_after_write(se)
  407. return 0
  408.  
  409. if args.command == "standby":
  410. require_yes(args, "set standby-like curtailment")
  411. se.set_active_power_limit(0)
  412. print("Set Active Power Limit to 0% (standby-like curtailment)")
  413. print_after_write(se)
  414. return 0
  415.  
  416. if args.command == "production":
  417. require_yes(args, "resume production")
  418. se.set_active_power_limit(100)
  419. print("Set Active Power Limit to 100% (normal production limit)")
  420. print_after_write(se)
  421. return 0
  422.  
  423. if args.command == "enable-control":
  424. require_yes(args, "enable and commit power control")
  425. reactive_register = REG_REACTIVE_POWER_CONFIG_A if args.reactive_register == "F103" else REG_REACTIVE_POWER_CONFIG_B
  426. status = se.enable_basic_power_control(reactive_register=reactive_register)
  427. print(f"Commit Power Control status after commit: {status}")
  428. return 0
  429.  
  430. except KeyboardInterrupt:
  431. print("Interrupted", file=sys.stderr)
  432. return 130
  433. except Exception as exc:
  434. print(f"Error: {exc}", file=sys.stderr)
  435. return 1
  436.  
  437. return 1
  438.  
  439.  
  440. if __name__ == "__main__":
  441. raise SystemExit(main())
Advertisement
Comments
  • User was banned
  • User was banned
  • User was banned
Add Comment
Please, Sign In to add comment