Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 56 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

Custom ESPHome external component that runs a **SunSpec Modbus TCP server** (port 502) directly on an ESP32. It reads live inverter data from existing ESPHome sensors and serves it to any SunSpec-compatible energy manager (Victron, SolarEdge, Fronius, etc.). It also accepts power limit commands from SunSpec clients and relays them back to the inverter over RS485 Modbus.

Built for a **Solis single-phase inverter** bridged via an ESP32 (m5stack-atom, ESP-IDF), but the sensor IDs and power limit register are configurable via YAML.
Built for a **Solis inverter** bridged via an ESP32 (m5stack-atom, ESP-IDF), but the sensor IDs and power limit register are configurable via YAML. Supports both single-phase and three-phase inverters.

## Features

- SunSpec Models 1 (Common), 101 (Single-phase inverter), 120 (Nameplate), 123 (Controls)
- SunSpec Models 1 (Common), 101/103 (Single/three-phase inverter), 120 (Nameplate), 123 (Controls)
- Single-phase (Model 101) and three-phase (Model 103) support via `phases: 1|3`
- FC03 read holding registers
- FC06 / FC16 write — power limit (WMaxLimPct) and enable (WMaxLim_Ena)
- Power limit write-back to inverter over RS485 via `modbus_controller`
- Up to 2 simultaneous Modbus TCP clients
- ESP-IDF target (not Arduino)
- ESP-IDF and Arduino frameworks on ESP32

## Installation

Expand All @@ -28,6 +29,8 @@ external_components:

## Configuration

**Single-phase (default):**

```yaml
sunspec:
# Device identification (shown to SunSpec clients)
Expand All @@ -37,7 +40,7 @@ sunspec:
version: "1.0.0"
rated_power: 3000 # watts, max 32767

# Sensor references (ESPHome sensor IDs)
# phases defaults to 1 — serves SunSpec Model 101
ac_power: ac_power # required
ac_voltage: ac_voltage # required
ac_frequency: ac_frequency # required
Expand All @@ -53,6 +56,29 @@ sunspec:
# power_limit_register: 3051 # Solis RS485 register for power limit
```

**Three-phase (Model 103) — experimental, not hardware-tested:**

```yaml
sunspec:
manufacturer: "Solis"
model: "S6-GR3P8K-M"
serial_number: "12345678"
version: "1.0.0"
rated_power: 8000

phases: 3 # serves SunSpec Model 103
ac_voltage_a: ac_voltage_a # required — phase A voltage (V)
ac_voltage_b: ac_voltage_b # required — phase B voltage (V)
ac_voltage_c: ac_voltage_c # required — phase C voltage (V)
ac_current_a: ac_current_a # optional — if omitted, current is derived from power/voltage
ac_current_b: ac_current_b # optional — must configure all three or none
ac_current_c: ac_current_c # optional
ac_power: ac_power # required
ac_frequency: ac_frequency # required
temperature: inverter_temp # required
energy_total: energy_total_wh # optional
```

The `power_limit_number_id` approach routes the limit through an existing `modbus_controller` number entity. Define it alongside your other number entities:

```yaml
Expand All @@ -78,16 +104,23 @@ number:

| Key | Required | Description |
|-----|----------|-------------|
| `manufacturer` | yes | Manufacturer string (max 16 chars) |
| `model` | yes | Model string (max 16 chars) |
| `serial_number` | yes | Serial number string (max 16 chars) |
| `version` | yes | Firmware version string (max 8 chars) |
| `manufacturer` | yes | Manufacturer string (max 32 chars) |
| `model` | yes | Model string (max 32 chars) |
| `serial_number` | yes | Serial number string (max 32 chars) |
| `version` | yes | Firmware version string (max 16 chars) |
| `rated_power` | yes | Inverter rated power in watts (int, max 32767) |
| `phases` | no | `1` (default) or `3` — selects Model 101 or 103 |
| `ac_power` | yes | ESPHome sensor ID for AC power (W) |
| `ac_voltage` | yes | ESPHome sensor ID for AC voltage (V) |
| `ac_frequency` | yes | ESPHome sensor ID for AC frequency (Hz) |
| `temperature` | yes | ESPHome sensor ID for inverter temperature (°C) |
| `ac_current` | no | ESPHome sensor ID for AC current (A) |
| `ac_voltage` | phases=1 | ESPHome sensor ID for AC voltage (V) |
| `ac_current` | no | ESPHome sensor ID for AC current (A) — single-phase only |
| `ac_voltage_a` | phases=3 | Phase A voltage sensor (V) |
| `ac_voltage_b` | phases=3 | Phase B voltage sensor (V) |
| `ac_voltage_c` | phases=3 | Phase C voltage sensor (V) |
| `ac_current_a` | no | Phase A current sensor (A) — all three or none |
| `ac_current_b` | no | Phase B current sensor (A) — all three or none |
| `ac_current_c` | no | Phase C current sensor (A) — all three or none |
| `energy_total` | no | ESPHome sensor ID for lifetime energy (Wh) |
| `power_limit_number_id` | no | ESPHome `number` entity ID to route power limit through (recommended) |
| `modbus_controller_id` | no | ID of your `modbus_controller` component (required when using `power_limit_register`) |
Expand All @@ -98,11 +131,21 @@ number:
| Range | Model | Content |
|-------|-------|---------|
| 40000–40069 | 1 | Common block (manufacturer, model, serial, version) |
| 40070–40121 | 101 | Single-phase inverter (power, voltage, current, frequency, temperature, energy, state) |
| 40070–40121 | 101 or 103 | Inverter block — Model 101 (single-phase) or 103 (three-phase) |
| 40122–40149 | 120 | Nameplate (DER type, rated power) |
| 40150–40175 | 123 | Controls (WMaxLimPct @ 40155, WMaxLim_Ena @ 40159) |
| 40176–40179 | — | End marker |

**Three-phase register assignments (Model 103):**

| Register | Content |
|----------|---------|
| 40072 | Total AC current (SF=-2) |
| 40073–40075 | Phase A/B/C current (SF=-2) |
| 40080–40082 | Phase A/B/C voltage VAN/VBN/VCN (SF=-1) |

If `ac_current_a/b/c` sensors are not configured, per-phase currents are derived from `ac_power / 3 / V_phase`.

## Power Limiting

Write `WMaxLimPct` (register 40155, range 0–100) and `WMaxLim_Ena` (register 40159, `1` = enabled):
Expand All @@ -121,8 +164,8 @@ On ESP32 reboot, `WMaxLim_Ena` defaults to `0` and `WMaxLimPct` defaults to `100

## Scope / Limitations

- Single-phase only (Model 101)
- One inverter instance per ESP32
- No persistent energy counter across reboots
- No WMaxLimPct auto-revert timer (field present but always 0)
- No three-phase support (Model 103)
- Three-phase support (Model 103) is **not hardware-tested** — use with caution and report issues
- Three-phase derived currents assume unity power factor per phase
89 changes: 81 additions & 8 deletions components/sunspec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
CONF_MODBUS_CONTROLLER_ID = "modbus_controller_id"
CONF_POWER_LIMIT_REGISTER = "power_limit_register"
CONF_POWER_LIMIT_NUMBER_ID = "power_limit_number_id"
CONF_PHASES = "phases"
CONF_AC_VOLTAGE_A = "ac_voltage_a"
CONF_AC_VOLTAGE_B = "ac_voltage_b"
CONF_AC_VOLTAGE_C = "ac_voltage_c"
CONF_AC_CURRENT_A = "ac_current_a"
CONF_AC_CURRENT_B = "ac_current_b"
CONF_AC_CURRENT_C = "ac_current_c"


def _validate_power_limit(config):
Expand All @@ -37,6 +44,42 @@ def _validate_power_limit(config):
return config


def _validate_phases(config):
phases = config.get(CONF_PHASES, 1)
if phases == 1:
if CONF_AC_VOLTAGE not in config:
raise cv.Invalid(
f"'{CONF_AC_VOLTAGE}' is required when phases: 1"
)
for key in (CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C,
CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C):
if key in config:
raise cv.Invalid(
f"'{key}' is not allowed when phases: 1"
)
else: # phases == 3
if CONF_AC_VOLTAGE in config:
raise cv.Invalid(
f"'{CONF_AC_VOLTAGE}' is not allowed when phases: 3; use ac_voltage_a"
)
if CONF_AC_CURRENT in config:
raise cv.Invalid(
f"'{CONF_AC_CURRENT}' is not allowed when phases: 3; use ac_current_a/b/c"
)
for key in (CONF_AC_VOLTAGE_A, CONF_AC_VOLTAGE_B, CONF_AC_VOLTAGE_C):
if key not in config:
raise cv.Invalid(
f"'{key}' is required when phases: 3"
)
present = [k for k in (CONF_AC_CURRENT_A, CONF_AC_CURRENT_B, CONF_AC_CURRENT_C)
if k in config]
if present and len(present) != 3:
raise cv.Invalid(
"ac_current_a, ac_current_b, and ac_current_c must all be configured or none"
)
return config


CONFIG_SCHEMA = cv.All(
cv.only_on_esp32,
cv.Schema(
Expand All @@ -48,9 +91,19 @@ def _validate_power_limit(config):
cv.Required(CONF_SERIAL_NUMBER): cv.All(cv.string, cv.Length(max=32)),
cv.Required(CONF_VERSION): cv.All(cv.string, cv.Length(max=16)),
cv.Required(CONF_RATED_POWER): cv.All(cv.positive_int, cv.Range(max=32767)),
cv.Required(CONF_AC_POWER): cv.use_id(sensor.Sensor),
cv.Required(CONF_AC_VOLTAGE): cv.use_id(sensor.Sensor),
cv.Optional(CONF_PHASES, default=1): cv.one_of(1, 3, int=True),
# 1-phase sensors
cv.Optional(CONF_AC_VOLTAGE): cv.use_id(sensor.Sensor),
cv.Optional(CONF_AC_CURRENT): cv.use_id(sensor.Sensor),
# 3-phase sensors
cv.Optional(CONF_AC_VOLTAGE_A): cv.use_id(sensor.Sensor),
cv.Optional(CONF_AC_VOLTAGE_B): cv.use_id(sensor.Sensor),
cv.Optional(CONF_AC_VOLTAGE_C): cv.use_id(sensor.Sensor),
cv.Optional(CONF_AC_CURRENT_A): cv.use_id(sensor.Sensor),
cv.Optional(CONF_AC_CURRENT_B): cv.use_id(sensor.Sensor),
cv.Optional(CONF_AC_CURRENT_C): cv.use_id(sensor.Sensor),
# shared sensors
cv.Required(CONF_AC_POWER): cv.use_id(sensor.Sensor),
cv.Required(CONF_AC_FREQUENCY): cv.use_id(sensor.Sensor),
cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor),
cv.Optional(CONF_ENERGY_TOTAL): cv.use_id(sensor.Sensor),
Expand All @@ -62,11 +115,13 @@ def _validate_power_limit(config):
}
).extend(cv.COMPONENT_SCHEMA),
_validate_power_limit,
_validate_phases,
)


async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
three_phase = config[CONF_PHASES] == 3
var = cg.new_Pvariable(config[CONF_ID], three_phase)
await cg.register_component(var, config)

cg.add(var.set_manufacturer(config[CONF_MANUFACTURER]))
Expand All @@ -75,19 +130,37 @@ async def to_code(config):
cg.add(var.set_version(config[CONF_VERSION]))
cg.add(var.set_rated_power(config[CONF_RATED_POWER]))

if three_phase:
for conf_key, setter in [
(CONF_AC_VOLTAGE_A, "set_ac_voltage"),
(CONF_AC_VOLTAGE_B, "set_ac_voltage_b"),
(CONF_AC_VOLTAGE_C, "set_ac_voltage_c"),
]:
sens = await cg.get_variable(config[conf_key])
cg.add(getattr(var, setter)(sens))
if CONF_AC_CURRENT_A in config:
for conf_key, setter in [
(CONF_AC_CURRENT_A, "set_ac_current_a"),
(CONF_AC_CURRENT_B, "set_ac_current_b"),
(CONF_AC_CURRENT_C, "set_ac_current_c"),
]:
sens = await cg.get_variable(config[conf_key])
cg.add(getattr(var, setter)(sens))
else:
sens = await cg.get_variable(config[CONF_AC_VOLTAGE])
cg.add(var.set_ac_voltage(sens))
if CONF_AC_CURRENT in config:
sens = await cg.get_variable(config[CONF_AC_CURRENT])
cg.add(var.set_ac_current(sens))

for conf_key, setter in [
(CONF_AC_POWER, "set_ac_power"),
(CONF_AC_VOLTAGE, "set_ac_voltage"),
(CONF_AC_FREQUENCY, "set_ac_frequency"),
(CONF_TEMPERATURE, "set_temperature"),
]:
sens = await cg.get_variable(config[conf_key])
cg.add(getattr(var, setter)(sens))

if CONF_AC_CURRENT in config:
sens = await cg.get_variable(config[CONF_AC_CURRENT])
cg.add(var.set_ac_current(sens))

if CONF_ENERGY_TOTAL in config:
sens = await cg.get_variable(config[CONF_ENERGY_TOTAL])
cg.add(var.set_energy_total(sens))
Expand Down
Loading