Control an AECC-platform balcony battery from Home Assistant, locally, over RS485.
These all-in-one batteries are sold under many names β Sunpura, Lunergy, AEG Solarcube, Voltdeer, AFERIY, AccuMate, JET GreenARK, Oscal, Fossibot, Humsienk. Underneath they run the same platform.
Normally you configure them through the vendor's phone app, which talks to their cloud. This component talks to the battery directly instead.
- Live readings in Home Assistant with no configuration: state of charge, battery power, grid power, load
- Every setting the battery has, including ones the app asks for a password
- A power setpoint, so it replaces the vendor's integration rather than sitting alongside it
- Zero-export control β hold your grid connection at a target, for places where feeding in is not allowed
- A configuration backup you can download and put back
You need an ESP32 and one RS485 adapter for the inverter. Add a second adapter only if you want zero-export control, which needs a meter on its own bus. A plain ESP32 is the safe choice: it has three serial ports.
Buy auto-direction adapters, the kind with only VCC, GND, TXD and RXD. The component has no transmit-enable pin, so a breakout that brings out DE/RE will not transmit.
Use a normal patch cable into the battery, then break out only two conductors at your end, with a keystone jack or by cutting the cable:
RJ45 pin 7 β A
RJ45 pin 8 β B
Do not connect the other six. The socket carries more than one RS485 bus, and one of them is the live link between the inverter and its battery pack.
Power the adapters from 3.3 V, not 5 V. Many of them pass their supply voltage straight through to the TTL side, and a 5 V RXD output is above what an ESP32 pin is rated for. If you are not sure, measure RXD against ground before connecting it: RS485 idles high, so it will read either 3.3 V or 5 V and tell you.
β οΈ Check the pinout in your own manual. The pins above are from a HumsienkHSBPSS2K5W7K68WHEU. Other brands on this platform are probably the same, but a wrong guess here lands on the battery's own BMS link.
The bundled CT meter is a second bus, on its own adapter:
RJ45 pin 2 or 4 β A
RJ45 pin 3 or 5 β B
It bridges each net across two pins internally, which is why a straight patch cable between the meter and the battery shorts the two together.
β οΈ If you wire a meter, isolate one of the two buses. The meter and the battery are powered separately. Two plain adapters sharing the ESP32's ground tie their electrical references together, which RS485 will not tolerate for long.
external_components:
- source:
type: git
url: https://github.com/makstech/esphome-aecc
ref: main
uart:
- id: bus_inverter
tx_pin: GPIO32
rx_pin: GPIO33
baud_rate: 9600
aecc:
id: nova
uart_id: bus_inverterThat is the whole configuration. It gives you the battery's readings, every setting it has, and a setpoint to drive it with β enough to replace the vendor's own integration.
These appear on their own. A reading is created when the thing it describes is configured, so a setup without a meter has no meter readings.
Readings
| Key | Name | What it is |
|---|---|---|
soc_sensor |
Battery SOC | State of charge, % |
battery_power_sensor |
Battery power | Positive when discharging |
grid_power_sensor |
Grid power | At the battery's own connection, positive when exporting |
backup_load_sensor |
Backup load | Load on the battery's own socket |
setpoint_sensor |
Setpoint | What the battery has been told to do |
ac_charge_power_sensor |
AC charge power | Drawn from AC to charge the battery, including from AC-coupled PV; zero while discharging |
pv_power_sensor |
PV power | Into the battery's PV port, positive while producing |
Energy β always, for Home Assistant's Energy dashboard
| Key | Name | What it is |
|---|---|---|
energy_charged_sensor |
Energy charged | Energy into the battery, kWh, integrated on the device from battery power every 5 s and kept across reboots |
energy_discharged_sensor |
Energy discharged | Energy out of the battery, the same way |
Control diagnostics β with control:, and the meter ones also with meter:
| Key | Name | What it is |
|---|---|---|
meter_power_sensor |
Meter grid power | What your meter is reading right now |
meter_power_filtered_sensor |
Meter grid power, filtered | The same after smoothing, which is what the loop acts on |
commanded_power_sensor |
Commanded power | What the loop is asking the battery for |
meter_age_sensor |
Meter sample age | Seconds since the last good meter reading |
loop_rate_sensor |
Control loop rate | How often the loop is actually running, Hz |
Health
| Key | Name | On means | Needs |
|---|---|---|---|
control_effective_sensor |
Setpoint honoured | The battery is obeying the setpoint | control: |
meter_ok_sensor |
Meter OK | The meter is answering with fresh readings | meter: + control: |
ems_ready_sensor |
Scheduler ready | The battery's scheduler is set up for local control | datalogger: + control: |
Setpoint honoured goes off while the mode is Off. The meter is read in every mode, so it doubles as a grid power reading even when nothing is regulated.
To rename or reconfigure one, name its key under aecc::
aecc:
soc_sensor: Charge level
meter_age_sensor:
name: Meter age
interval: 30stests/all-keys.yaml is generated from the component's schema and
names every key it accepts. CI fails when the schema gains a key that it, or this README,
does not name; the options outside these tables are in All options.
Battery settings β also under aecc:, see below
Other β a Back up configuration button appears with backup:, and the
aecc.write_register action writes any address.
A Battery mode dropdown decides what the component does, and it remembers your choice across reboots:
| Mode | What it does |
|---|---|
| Off | Rests the battery. The component then writes nothing, so the vendor app, the cloud or anything else on the bus can take over |
| Zero export | Holds your grid connection at a target, charging rather than exporting |
| Manual | Holds the battery at a power you set, positive discharging |
Off is the default. Switching to it from Zero export or Manual turns the battery's energy
manager off and sets its priorities to Photovoltaic priority with PV-only charging, once:
the battery rests, the house runs on PV and then the grid, and the pack charges from PV
surplus alone. After that the component writes nothing, so it will not fight you while you
drive the battery some other way. Without a datalogger: it only stops writing.
Zero export only appears in the list once you have given the component a meter:; Manual
is there from the start, driven by the Battery power setpoint. Manual at 0 W parks the
battery idle while the ESP32 stays in control.
A datalogger: block also gives you a Work mode dropdown, for handing the battery back
to its built-in self-consumption automation while in Off. Choosing either work mode turns
the energy manager back on. Name it with work_mode_select: to change the label.
| Battery mode | Work mode | What runs the battery |
|---|---|---|
| Off | Self-consumption | The battery's own automation |
| Off, after Zero export or Manual | unchanged | Nothing: the energy manager is off and the battery rests |
| Off | Custom | Nothing; it holds the resting slot, a slow charge |
| Zero export or Manual | Custom | This component |
Self-consumption can only be selected while the battery mode is Off, because the other two modes need Custom and put it back. Note that the battery's automation rewrites the schedule slot and the SOC limits to suit itself, and it is free to export β so it is not a substitute for zero export where feeding in is not allowed.
uart:
- id: bus_inverter
tx_pin: GPIO32
rx_pin: GPIO33
baud_rate: 9600
- id: bus_meter # only with a meter
tx_pin: GPIO25
rx_pin: GPIO26
baud_rate: 9600
aecc:
id: nova
uart_id: bus_inverter
meter:
type: rs071 # the CT meter that came in the box
uart_id: bus_meter
datalogger:
host: 192.0.2.10 # the battery's own WiFi module, on your network
control:
setpoint_number: Battery power # the Manual target; negative charges
grid_target: 60 # watts to keep importing, in Zero export
max_discharge: 600
max_charge: 2400
min_soc: 15%
max_soc: 90%min_soc and max_soc apply in Manual as well, so a bad setpoint cannot discharge past
your reserve, and so do max_discharge and max_charge β a Manual setpoint beyond them
is held at the limit and logged.
The datalogger: block is not optional in practice. Without it the battery ignores the
control loop and keeps running whatever the vendor app last scheduled, while every
control entity still appears in Home Assistant. Commissioning
explains why.
Picking a mode is what starts any of this; a freshly flashed node sits idle.
Set max_discharge near your usual household draw. If the load suddenly drops, the
battery takes about a second to wind down, and how much it pushes out during that second
is roughly the difference between this number and your baseline.
grid_target is how much you keep importing rather than sitting exactly on zero. The
Home Assistant control stops at zero, so an optimiser cannot ask the battery to export by
accident. Give grid_target_number a negative min_value where exporting is wanted and
permitted.
Name the loop's own parameters and they become controls too, starting from the values you set above. The mode dropdown is there whether you name it or not; naming it only changes the label:
control:
grid_target: 60
max_discharge: 600
mode_select: Battery mode
grid_target_number: Grid target
max_discharge_number: Max discharge
max_charge_number: Max charge
min_soc_number: Reserve
max_soc_number: Charge ceilingThese remember their last value across a reboot, so something like EMHASS can change them by the hour:
| What you want | grid target | max discharge | max charge |
|---|---|---|---|
| Soak up your own solar | 0 | 0 | full |
| Charge from cheap grid at 2 kW | 2000 | 0 | full |
| Run the house off the battery | 0 | full | 0 |
| Save the battery for later | 0 | 0 | 0 |
Every setting the battery has is already a control β the register, the range, the step and the unit are known, so nothing has to be listed.
The ones you might change appear in Home Assistant under Configuration: the grid export
limit, max charge current, energy saving, the buzzer, and the state-of-charge thresholds.
The rest are commissioning settings β pack chemistry, grid nominals, BMS protocol β and
stay off the dashboard. They still exist: YAML, automations and aecc.write_register all
reach them.
To put them all in Home Assistant:
aecc:
expose_all_settings: trueOr move a single one either way with ESPHome's own internal:, which wins over that:
aecc:
grid_standard_select:
internal: false # this one on the dashboard
buzzer_mute_switch:
internal: true # this one off itName one to change its label, or to override a default:
aecc:
id: nova
uart_id: bus_inverter
buzzer_mute_switch: Beeper
soc_shutdown_number:
name: Shut down at
min_value: 5
max_value: 50The suffix says what kind of control you get: _number for a value, _switch for an
on/off setting, _select for a list of choices.
| Suffix | Available for |
|---|---|
_number |
grid_export_limit, output_voltage, output_frequency, max_charge_current, pv_max_charge_current, mains_max_charge_current, saturation_current, cv_voltage, float_voltage, cv_charge_time, cv_return_voltage, battery_to_mains_voltage, mains_to_battery_voltage, undervoltage_alarm, low_voltage_shutdown, eod_voltage, shutdown_delay, eod_clear_voltage, soc_low_alarm, soc_shutdown, soc_full, soc_inverter_to_mains, soc_mains_to_inverter |
_switch |
device_power, eco_mode, buzzer_mute, battery_activation, mixing_priority, external_ct_host, anti_islanding, bms_function, independent_pack |
_select |
utility_range, on_grid_mode, grid_standard, inverter_mode, charge_priority, battery_type, parallel_mode, bms_protocol |
Some do more than they sound like. device_power switches the inverter off.
anti_islanding off stops it disconnecting from a dead grid. bms_function off drops the
pack's BMS link. grid_export_limit caps on-grid output, which includes power serving
your own load, so setting it to zero stops the battery supplying the house rather than
stopping export. And grid_standard swaps the inverter's whole set of grid voltage
and frequency trip limits in one write.
These are the battery's own settings, so they are read back from it at boot rather than remembered here β change one in the vendor app and the entity follows.
A few settings have no key on purpose β the enums whose value lists were never worked out,
where a bare index would be a worse control than none. Those and anything else are still
reachable by address, and docs/REGISTERS.md lists every register
with the key that reaches it, where there is one:
registers:
- name: Parallel mode
address: 0xA02C
max_value: 7Give it a name: for it to appear in Home Assistant.
The battery ignores the component until its own scheduler is set up, and the vendor app undoes that whenever its AI mode is on. Give the component the battery's address and it handles this for you, checking every minute and putting it back if it drifts:
aecc:
datalogger:
host: 192.0.2.10 # the battery's own WiFi module, on your network
resting_power: -50
resting_power_number: Resting power # there by default; name it to relabelhost: also takes a hostname or an mDNS name, so a battery on DHCP does not need a
reservation:
datalogger:
host: humsienk-control.localA Datalogger address text and a Datalogger switch come with the block. The first
changes the address without reflashing; the second stops the component using the
datalogger, for when the vendor app or another integration needs it. Name them with
host_text: and enable_switch: to change the labels.
host: can be left out entirely, which is the point of the text entity β flash the node,
then type the address into Home Assistant once you know it:
aecc:
id: nova
uart_id: bus_inverter
datalogger:The battery only accepts commands while its own scheduler is running a non-zero value. Set that value to zero and it ignores the ESP32 completely.
That same value is also what the battery falls back to a few seconds after the ESP32 goes quiet. So one number does two jobs, and zero breaks both of them.
A small charging value is the quietest non-zero option, and charging can never push
anything into the grid. Too small counts as zero, though: the battery treats -10 as
nothing at all. The default -50 is about the smallest it listens to. To park the battery
idle, use Manual at 0 W rather than a smaller resting value. Zero export rounds a smaller
charge up to 50 W for the same reason, so a small PV surplus does not leak out.
The datalogger also pushes that value into the battery every few seconds, over whatever the ESP32 last commanded. So between control ticks the ESP32 checks its setpoint every 100 ms and puts it back when a push has overwritten it, then ignores the meter for a moment: the blip that follows is the push, not the house. Export larger than the push could have caused is still corrected at once. In Zero export the component also turns the scheduler's custom mode off, so the push carries the battery's own zero-import target instead of the resting value, and that is what the battery keeps doing if the ESP32 stops. Manual turns it back on.
The Setpoint honoured sensor turns off if the battery stops obeying. Put it on a
dashboard or an automation to know when that happens. It reports on the control loop, so
it needs control: as well.
Take a copy before you change anything on a new battery.
web_server:
version: 3
aecc:
backup:
url: /aecc/backupcurl http://your-node.local/aecc/backup # starts it
sleep 60
curl http://your-node.local/aecc/backup -o backup.txt # again, for the fileThe first call only starts the walk; at 9600 baud it takes the best part of a minute.
Or press the Back up configuration button and fetch it afterwards. Putting it back:
curl --data-binary @backup.txt http://your-node.local/aecc/restore
curl -X POST -d '' http://your-node.local/aecc/restore # for the reportRestore writes the settings and the energy manager's own values, skips anything already correct, and stops if the battery starts refusing. It does not switch the battery on, so on a completely blank unit that last step is still yours.
For tuning the loop, trace: records what it sees, one sample per control tick, and
serves the last recording as CSV:
aecc:
trace:
export_trigger: 30 # optional: export above 30 W records the moment by itselfPress Record trace, do whatever you want to see (step the Manual setpoint, switch a
load on), then fetch http://<device>/aecc/trace once the recording ends. A recording
starts with the five seconds before it was asked for, at negative timestamps. With
export_trigger, export above that many watts records those five seconds and five more,
at most once a minute, so it is there to fetch after the fact.
Each row has a timestamp in milliseconds from the trigger, the meter reading, the
inverter's battery, grid-port and backup power, the setpoint register as the inverter holds
it, the loop's command, the mode (0 Off, 1 Zero export, 2 Manual), the PV port's
power and 1 where the datalogger pushed its own setpoint since the row before. A
setpoint that differs from the command is something else writing to the battery. It needs
web_server: and control:.
Everything the component accepts besides the entity tables and the battery settings, with its default.
aecc:
| Key | Default | What it does |
|---|---|---|
uart_id |
required | The bus wired to the inverter's RJ45 pins 7/8 |
unit |
1 |
The inverter's Modbus address |
expose_all_settings |
false |
Also shows the commissioning settings, see Battery settings |
expose_tuning |
false |
Also shows the control loop's tuning controls |
work_mode_select |
Work mode |
The Work mode dropdown; needs datalogger: |
registers |
none | Settings by address, see Battery settings |
meter, control, datalogger, backup, trace |
The blocks below |
meter: β without it there is no Zero export
| Key | Default | What it does |
|---|---|---|
type |
required | rs071, the CT meter that came in the box |
uart_id |
required | The meter's own bus |
unit |
1 |
The meter's Modbus address |
register |
12 |
The float32 register holding grid power, positive when importing |
reply_window |
120ms |
How long to wait for an answer, up to 1 s |
control:
| Key | Default | What it does |
|---|---|---|
rate |
4Hz |
How often the loop runs; the meter refreshes about every 250 ms |
filter_window |
750ms |
The loop acts on the median meter reading over this long |
grid_target |
60 |
Watts to keep importing in Zero export |
max_discharge |
600 |
Watts, in Zero export and Manual |
max_charge |
2400 |
Watts, in Zero export and Manual |
min_soc |
15% |
The reserve: no discharging below it |
max_soc |
90% |
The ceiling: no charging above it |
charge_taper |
soc and max_charge points the charge limit follows as the pack fills: a straight line between points, the last one held above it, no limit below the first. Under a high charge current the highest cell reaches the BMS's full voltage early and the BMS resets the pack to 100 %. It limits PV absorption too, so a surplus above it near the top needs curtailing |
|
ramp_up |
0.35 |
The share of an import error closed per half second while raising discharge. A move toward export is corrected in full at once |
stale_after |
5s |
With no meter reading for this long, the loop commands 0 |
law |
predictive |
How the error becomes a command. classic adds a share of it to the last command each tick; predictive adds it to what a model of the inverter says the battery is delivering, so it does not ask twice for power already on its way |
predictive_gain |
0.7 |
The share of an import error the predictive law corrects per tick; a move toward export is corrected whole |
actuator_lag |
400ms |
The predictive model: how fast the inverter follows a new setpoint |
meter_delay |
300ms |
The predictive model: how late the meter shows it. Too long double-counts and oscillates |
rise_delay |
2000ms |
How long import has to persist before zero export covers it, so a brief load pulse does not leave a spike of export when it stops. Export is corrected at once |
pv_feed_forward |
true |
With the predictive law, a rise on the battery's PV port is absorbed as the inverter reports it, before the meter shows it as export. A fall is left to the meter |
mode_select |
Battery mode |
The mode dropdown |
setpoint_number |
Battery power |
The Manual target in watts, positive discharging |
grid_target_number |
Grid target |
grid_target as a control, starting from the configured value |
max_discharge_number |
Max discharge |
The same for max_discharge |
max_charge_number |
Max charge |
The same for max_charge |
min_soc_number |
Reserve |
The same for min_soc |
max_soc_number |
Charge ceiling |
The same for max_soc |
law_select |
Control law |
law as a dropdown, starting from the configured value; shown with expose_tuning |
rate_number |
Loop rate |
rate as a control, for tuning live; shown with expose_tuning |
filter_window_number |
Filter window |
The same for filter_window |
predictive_gain_number |
Predictive gain |
The same for predictive_gain |
meter_delay_number |
Meter delay |
The same for meter_delay |
rise_delay_number |
Rise delay |
The same for rise_delay |
datalogger:
| Key | Default | What it does |
|---|---|---|
host |
empty | Address, hostname or mDNS name of the battery's WiFi module. Empty leaves it to the text entity |
port |
8080 |
Its TCP port |
resting_power |
-50 |
Watts the schedule slot holds, which the battery falls back to when the loop stops. Must be negative, see below |
reconcile_interval |
60s |
How often the scheduler settings are checked and put back; every 5 s until they first hold |
host_text |
Datalogger address |
Changes the address without reflashing |
enable_switch |
Datalogger |
Stops all traffic to the datalogger, freeing it for the vendor app |
resting_power_number |
Resting power |
resting_power as a control. A change is written to the slot at once, in Off too, since the slot is what the battery runs there |
backup:
| Key | Default | What it does |
|---|---|---|
url |
/aecc/backup |
Where the backup is served |
restore_url |
/aecc/restore |
Where a backup is posted to restore it |
button |
Back up configuration |
Takes a backup |
trace: β see Traces
| Key | Default | What it does |
|---|---|---|
url |
/aecc/trace |
Where the last trace is served |
duration |
30s |
How long a recording from the button runs, up to 2 minutes |
export_trigger |
Export above this many watts records five seconds either side of it, at most once a minute | |
button |
Record trace |
Starts a recording |
Numbers show as a number box unless their unit is a percentage; set mode: on one to
override.
docs/REGISTERS.mdβ every address the component knows aboutdocs/PROTOCOL.mdβ for writing your own tooling
Writing settings on a grid-connected battery can make it export, which needs permission in many places. Know your local rules.
Not affiliated with any of the brands listed.