Advanced
Everything you need to drive EOS Connect from other software — the HTTP routes it really serves, the MQTT topics it really publishes, and how it fits alongside Home Assistant, EVCC and openHAB.
REST API
EOS Connect serves its dashboard and its API from the same Flask app on
eos_connect_web_port (default 8081). Every example below
assumes http://localhost:8081; substitute your host.
There is no authentication. Anything that can reach the port can
read your configuration — including stored API tokens via
/api/backup/export — and can take over battery control. Keep the
port on a trusted network. Do not port-forward it.
Reading state
The dashboard is a plain client of these routes, so anything the dashboard shows you can have too.
| Method & path | What it returns |
|---|---|
GET /json/current_controls.json |
The live snapshot: control state, battery, EVCC, inverter telemetry,
currency, optimizer state, PV day totals. api_version is
0.0.5. |
GET /json/price_info.json |
Price forecast metadata: forecast_start_index,
forecast_type, forecast_source. Sent
no-cache. |
GET /json/optimize_request.json |
The exact payload sent to the optimizer on the last run. |
GET /json/optimize_response.json |
The optimizer's answer to it — the schedule everything else is derived from. |
GET /json/test/<filename> |
Static fixtures for UI development. Only names ending
.test.json are served; anything else is refused with 400
or 403. |
The two optimize documents change shape with eos.source, so read them
rather than assume. With the built-in local_evopt solver the request has
the top-level keys strategy, grid, batteries,
time_series, eta_c, eta_d, and the response has
status, objective_value, batteries,
grid_import, grid_export and the overshoot arrays. With
eos_server the response is the Akkudoktor EOS shape instead:
ac_charge, dc_charge, discharge_allowed,
result.
The one route most integrations need is the control snapshot:
curl -s http://localhost:8081/json/current_controls.json | jq '.current_states'
Its current_states object carries
current_ac_charge_demand and current_ac_charge_power (W),
current_dc_charge_demand, current_discharge_allowed,
inverter_mode (the human-readable label) alongside
inverter_mode_num (the integer in the table below), plus
override_active and override_end_time. Sibling objects hold
battery (SOC, usable capacity, dynamic charge limit, temperature, stored
energy), evcc, inverter, localization and
pv_forecast.
Controlling the battery
POST /controls/mode_override is the only control endpoint.
It overrides the optimizer's decision for a fixed window, then hands control back
automatically.
The request body must contain all three keys. Omit any one and the
server answers 400 with
{"error": "Invalid payload"}.
| Key | Type | Accepted values |
|---|---|---|
mode |
integer | -2 to 2. See the table below. |
duration |
string | "HH:MM" — not a number of minutes. Must be
greater than zero; the dashboard and the MQTT selector both cap it at
"12:00". |
grid_charge_power |
float | Kilowatts, minimum 0.5. Only meaningful for modes
0 and 2. The inverter layer clamps the
resulting power to inverter.max_grid_charge_rate. |
mode |
Meaning | Effect |
|---|---|---|
-2 |
Back to auto | Clears the override immediately and returns control to the optimizer.
duration and grid_charge_power are still
required in the payload, but are ignored. |
0 |
Charge from grid | Charges the battery from the grid at
grid_charge_power. |
1 |
Avoid discharge | Holds the battery. House load is covered by PV and the grid. |
2 |
Discharge allowed | The battery may discharge to cover load. |
Charge from the grid at 3 kW for two hours:
curl -X POST http://localhost:8081/controls/mode_override \
-H "Content-Type: application/json" \
-d '{"mode": 0, "duration": "02:00", "grid_charge_power": 3.0}'
Give control back to the optimizer:
curl -X POST http://localhost:8081/controls/mode_override \
-H "Content-Type: application/json" \
-d '{"mode": -2, "duration": "00:30", "grid_charge_power": 0.5}'
A successful call returns exactly:
{"status": "success", "message": "Mode override applied"}
The response says nothing about what was applied. To confirm the override took
effect, read current_states.override_active and
current_states.override_end_time back from
/json/current_controls.json.
Configuration API
The settings UI is a client of this blueprint, mounted at /api/config.
Settings are addressed by dot-notation keys — the same keys listed in the
configuration reference.
| Method & path | Purpose |
|---|---|
GET /api/config/schema |
Every field with its type, default, section, disclosure level, validation rules and dependencies. The machine-readable source of truth. |
GET /api/config/ |
All current values, nested by section, with passwords masked. |
GET /api/config/section/<section> |
One section. 404 for an unknown section name. |
PUT /api/config/ |
Partial update from a flat dot-notation object. |
POST /api/config/validate |
Same body as the PUT, but writes nothing. Returns
{"valid": true, "errors": []} or 422. |
GET /api/config/restart-required |
{"fields": [...]} — changed settings still waiting
for a restart. |
GET /api/config/export |
Settings only, as a flat dot-notation object. |
POST /api/config/import |
Merges a flat settings object back in. Non-setting datasets in the
payload are reported as ignored_datasets, not
restored. |
GET /api/config/wizard-status |
{"pending": ..., "completed": ..., "migrated": ...}. |
POST /api/config/wizard-complete |
Marks the setup wizard done. |
POST /api/config/test-timeseries |
Pre-flight check for a time-series source. |
POST /api/config/test-entity |
Pre-flight check for one sensor entity or item. |
curl -X PUT http://localhost:8081/api/config/ \
-H "Content-Type: application/json" \
-d '{"battery.min_soc_percentage": 10, "price.feed_in_price": 0.08}'
A successful PUT returns success, updated (the
keys written), restart_required, hot_reloaded and
warnings. Two failure modes are worth handling separately: invalid values
come back as 422 with an errors array, while an unmet
blocking dependency comes back as 200 with
{"success": false, "unmet_dependencies": [...]} and
nothing written. Do not treat 200 as success without
checking the success field.
Backup and restore
The /api/backup blueprint moves a whole install, not just settings. It
knows two datasets: settings and pv_yield_history.
| Method & path | Purpose |
|---|---|
GET /api/backup/info |
What a backup taken now would contain: dataset names, a settings
count, PV history row count with oldest and newest timestamps,
retention_days, and
"contains_secrets": true. |
GET /api/backup/export |
One flat JSON document, tagged with _format,
_version, _exported_at and
_datasets. |
POST /api/backup/import |
Restores such a document, or previews the restore. |
All three accept ?include= with a comma-separated dataset list. Omitting it
means everything; passing it empty means nothing. The import also takes
mode=replace (the default — stored settings end up matching the file
exactly) or mode=merge, history_mode=as_is (default) or
seed, and dry_run=1.
# Take a full backup
curl -s http://localhost:8081/api/backup/export -o eos-connect-backup.json
# See what restoring it would change, without writing anything
curl -X POST "http://localhost:8081/api/backup/import?dry_run=1" \
-H "Content-Type: application/json" \
--data-binary @eos-connect-backup.json
The export is never masked — a redacted backup could not restore. Your Tibber token, Home Assistant token, MQTT password and inverter password are all in that file in plain text. Treat it accordingly.
Logs
EOS Connect keeps a ring buffer of log records in memory and a second, smaller one for alerts. These routes read and clear those buffers; the log file is untouched either way.
| Method & path | Purpose |
|---|---|
GET /logs |
Recent records. Query: level, limit
(default 100), since (ISO timestamp). |
GET /logs/alerts |
Warnings and errors only, also grouped by level with counts. Query:
startup_only, since,
limit. |
GET /logs/stats |
Buffer usage: current size, max size and percentage for both buffers. |
POST /logs/clear |
Empties the main buffer. |
POST /logs/alerts/clear |
Empties only the alert buffer. |
Each record is {"timestamp": ..., "level": ..., "message": ..., "module": ...}.
startup_only=true is the useful one for health checks: it limits alerts to
those raised since the current process started, so yesterday's transient network error
does not keep firing.
curl -s "http://localhost:8081/logs/alerts?startup_only=true" | jq '.alert_counts'
Status
| Method & path | Purpose |
|---|---|
GET /api/update/status |
Wraps an update_status object with
enabled, is_ha_addon,
current_version, is_develop_branch,
update_available, latest_version,
last_check_time, last_check_success,
last_error and
next_check_in_seconds. |
GET /api/pv_autoscaling/status |
PV auto-scaler state under pv_autoscaling: computed scale
factors per time frame, today's partial yield, aggregated history,
time-frame bounds, and the current forecast array both scaled and
raw. |
Update checking is for Docker and bare-metal installs. Under the Home Assistant add-on
the supervisor owns updates, so enabled is false and
is_ha_addon is true. Both routes are served with
Cache-Control: no-store and report
"api_version": "0.0.1".
MQTT
Enable MQTT with mqtt.enabled, then set mqtt.broker,
mqtt.port, and mqtt.user / mqtt.password if your
broker needs them. mqtt.tls switches on TLS. All of these require a
restart.
The base topic is eos_connect and is not configurable. Everything is
published retained, so a fresh subscriber gets the current picture immediately, and a
Last Will publishes offline to eos_connect/status if the
process dies.
Published topics
Topic (under eos_connect/) |
Unit | Meaning |
|---|---|---|
status |
— | online / offline |
control/overall_state |
— | Current mode as an integer, -2 to 6 |
control/eos_ac_charge_demand |
W | Grid charge power the optimizer is asking for |
control/eos_dc_charge_demand |
W | PV charge power the optimizer is asking for |
control/eos_discharge_allowed |
— | Whether discharge is currently permitted |
control/eos_homeappliance_released |
— | Home-appliance window open |
control/eos_homeappliance_start_hour |
hour | Hour the appliance should start |
control/override_active |
— | Whether a manual override is in force |
control/override_end_time |
ISO 8601 | When the override expires |
control/override_remain_time |
HH:MM |
Override duration |
control/override_charge_power |
W | Override charge power |
optimization/last_run,
optimization/next_run |
ISO 8601 | Optimizer run timestamps |
optimization/state |
— | Optimizer state text |
battery/soc |
% | State of charge |
battery/remaining_energy |
Wh | Energy left in the battery |
battery/dyn_max_charge_power |
W | Charge limit after the SOC curve and temperature protection |
battery/soc_min, battery/soc_max |
% | Configured SOC window |
inverter/special/temperature_* |
°C | Inverter, AC module, DC module and battery module temperatures |
inverter/special/fan_control_01,
_02 |
% | Fan duty cycles |
system/current_version,
system/latest_version |
— | Version strings |
system/update_available,
system/update_check_enabled |
— | true / false |
The inverter/special/ topics only carry values when the configured inverter
supports extended monitoring; on other hardware they stay empty.
Command topics
Five topics accept commands. Each is the state topic with /set appended.
| Topic | Payload |
|---|---|
eos_connect/control/overall_state/set |
Integer mode: -2 auto, 0 charge from grid,
1 avoid discharge, 2 discharge allowed |
eos_connect/control/override_remain_time/set |
HH:MM in half-hour steps, 00:30 to
12:00 |
eos_connect/control/override_charge_power/set |
Watts, 0–10000 in steps of 100. Note the unit differs from the REST endpoint, which takes kW. |
eos_connect/battery/soc_min/set |
Percent, 0–100 |
eos_connect/battery/soc_max/set |
Percent, 0–100 |
Retained commands are ignored on purpose. A retained message is
redelivered on every reconnect, which used to let a stale value silently overwrite
your configured SOC limits. Publish commands with retain off —
which is what Home Assistant and normal clients do anyway.
Set duration and power first, then the mode — the mode command is what actually starts the override window:
mosquitto_pub -h broker -t eos_connect/control/override_remain_time/set -m "02:00"
mosquitto_pub -h broker -t eos_connect/control/override_charge_power/set -m "3000"
mosquitto_pub -h broker -t eos_connect/control/overall_state/set -m "0"
# Back to automatic
mosquitto_pub -h broker -t eos_connect/control/overall_state/set -m "-2"
Home Assistant auto-discovery
With mqtt.ha_mqtt_auto_discovery enabled (the default), EOS Connect
publishes a retained discovery config for every topic above on connect, under
<prefix>/<component>/eos_connect/eos_connect_<topic>/config.
The prefix comes from mqtt.ha_mqtt_auto_discovery_prefix and defaults to
homeassistant. Battery SOC, for instance, arrives as
homeassistant/sensor/eos_connect/eos_connect_battery_soc/config.
Everything lands under one device called EOS Connect, so you get the
whole set in one place without writing a line of YAML: sensors, binary sensors, two
select entities for mode and duration, and three number
entities for charge power and the SOC limits. Diagnostic entities are tagged as such
and stay out of the main card.
Integrations
Home Assistant
Home Assistant can sit on either side of EOS Connect, and usually sits on both.
As a data source, set data_source.type to
homeassistant with data_source.url and a long-lived
data_source.access_token. EOS Connect then reads household load, battery
SOC and anything else you point it at through the HA REST API. Sensor entity ids go in
keys like load.load_sensor and
load.car_charge_load_sensor.
As a control target, set inverter.type to
homeassistant and give it the three scripts or switches it should call:
inverter.charge_from_grid, inverter.avoid_discharge and
inverter.discharge_allowed. That covers inverters EOS Connect has no
native driver for, as long as HA can already control them.
As a dashboard, MQTT auto-discovery gives you the full entity set described above with no configuration.
EVCC
EVCC is the deepest integration, because a car charger and a house battery competing for the same cheap hour is exactly the problem EOS Connect exists to solve.
Point evcc.url at your EVCC instance and it becomes available in three
roles. It can serve as a PV forecast source
(pv_forecast_source.source: evcc), as a price source for
both grid price (price.source: evcc) and feed-in tariff
(feed_in.source: evcc), and as a charging-state monitor
that tells EOS Connect when the car is drawing power so the optimizer can plan around
it rather than fight it.
Setting inverter.type to evcc turns on EVCC's external battery
control: EOS Connect drives the battery through
/api/batterymode/hold, /normal and /charge, and
releases it with a DELETE when the override ends. Use this when EVCC
already owns your battery — it keeps both systems from issuing contradictory
commands.
EOS Connect reads the loadpoint charging mode to decide whether the car may draw down
the battery. It supports both EVCC's legacy mode names (pv,
minpv) and the scheme introduced in EVCC 0.316.0
(smart combined with the alwaysCharge flag), translating the
latter internally so the same battery-priority logic applies either way. The dashboard
badge shows “Smart”/“Smart+” when talking to a 0.316.0+ instance,
or “PV”/“Min+PV” for older versions.
openHAB
openHAB works as a data source: set data_source.type to
openhab and data_source.url to your server. EOS Connect reads
current values from the item REST API and historical load from
/rest/persistence/items/<item>, so the items you use for
load must have persistence configured — without it the load history
comes back empty and the forecast degrades. Item names go in the same sensor keys as HA
entity ids; there is no access token to set.
For control in the other direction, use the openHAB MQTT binding against the command topics above. There is no native openHAB inverter driver.
Automation
Home Assistant: force a charge from a script
Publishing to the command topics works the same on every install, so it needs no assumptions about how your entity ids came out. Set the window and the power first, then the mode — the mode is what starts the override.
alias: EOS Connect - grid charge for 2 hours
mode: single
sequence:
- service: mqtt.publish
data:
topic: eos_connect/control/override_remain_time/set
payload: "02:00"
- service: mqtt.publish
data:
topic: eos_connect/control/override_charge_power/set
payload: "3000"
- service: mqtt.publish
data:
topic: eos_connect/control/overall_state/set
payload: "0"
Auto-discovery also gives you the same three controls as
select and number entities under the EOS Connect device, if
you would rather drive them from a dashboard card or reference them by id. Look their
real ids up in Developer Tools → States first — Home
Assistant derives them from the entity names, so they differ between installs.
A health check that actually tells you something
Polling the dashboard only proves the web server is up. This checks that the optimizer has run recently and that nothing has gone wrong since the process started:
#!/usr/bin/env bash
set -euo pipefail
HOST="http://localhost:8081"
errors=$(curl -fsS "$HOST/logs/alerts?startup_only=true" \
| jq '.alert_counts.ERROR + .alert_counts.CRITICAL')
last_run=$(curl -fsS "$HOST/json/current_controls.json" | jq -r '.timestamp')
echo "last update: $last_run, errors since start: $errors"
[ "$errors" -eq 0 ]
Python: read state, then act
The whole control surface in one place. Note the units: duration is a
string, grid_charge_power is kilowatts.
import requests
BASE = "http://localhost:8081"
state = requests.get(f"{BASE}/json/current_controls.json", timeout=10).json()
soc = state["battery"]["soc"]
override_active = state["current_states"]["override_active"]
if soc < 20 and not override_active:
r = requests.post(
f"{BASE}/controls/mode_override",
json={"mode": 0, "duration": "01:30", "grid_charge_power": 3.0},
timeout=10,
)
r.raise_for_status()
print(r.json()) # {"status": "success", "message": "Mode override applied"}
An override suspends the optimizer for its whole duration. If your automation re-issues one every few minutes, the optimizer never gets a look in and you lose the savings it was there to find. Override for a purpose, for a bounded window, and let it expire.
Grid import and export limits
If your grid connection or your feed-in contract caps how much power may cross the meter, tell the optimizer — it will plan inside that ceiling instead of producing a schedule your hardware then has to clip.
Four settings, all in watts, all
restart. Which pair applies depends on
eos.source.
| Setting | Applies when | Default | Range |
|---|---|---|---|
eos.local_evopt_max_grid_import_w |
eos.source: local_evopt |
0 |
0–100000 |
eos.local_evopt_max_grid_export_w |
eos.source: local_evopt |
0 |
0–100000 |
eos.external_evopt_max_grid_import_w |
eos.source: evopt |
10000 |
0–100000 |
eos.external_evopt_max_grid_export_w |
eos.source: evopt |
10000 |
0–100000 |
0 does not mean unlimited. It means "derive it": import
falls back to inverter.max_grid_charge_rate and export to
inverter.max_pv_charge_rate, both of which default to 5000 W. There
is no configuration that gives the optimizer an unconstrained grid, so a plausible
ceiling is always in force whether you set one or not.
The two pairs default differently. local_evopt starts at
0 and therefore picks up your inverter limits automatically. External
evopt starts at a flat 10000 W, so it does
not follow your inverter unless you set the value to 0
yourself.
The built-in solver enforces these as hard constraints on every slot of the schedule.
When a plan cannot be built inside them the run reports
grid_import_limit_exceeded or grid_export_limit_hit, which
the dashboard surfaces as a limit violation — if you see one, your ceiling is
below what the house actually needs.
The external Akkudoktor EOS backend
(eos.source: eos_server) has no equivalent; it receives no grid limits at
all. And because none of these fields hot-reload, changing one through
PUT /api/config/ stores the value but leaves the running optimizer on the
old one until you restart. There is no MQTT topic for them.
Sensor checks and sign detection
EOS Connect does not browse Home Assistant for candidate entities. You name the entity; it checks the one you named. Two separate mechanisms are described below and they are often confused.
Testing one sensor before you save it
POST /api/config/test-entity reads a single entity or item and reports what
came back. The body takes key — the dot-notation setting you are
testing, such as battery.soc_sensor — plus any connection settings
you want applied without saving them first, typically
data_source.type, data_source.url and
data_source.access_token. That is how the settings page can test a token
you have typed but not yet committed.
curl -X POST http://localhost:8081/api/config/test-entity \
-H "Content-Type: application/json" \
-d '{"key": "battery.soc_sensor"}'
A working sensor answers {"ok": true, "error": "", "value": "63.0"}. A
failure answers {"ok": false, "error": "..."} with no value
— a missing entity, a rejected token, an unreachable host and a state of
unavailable each get their own message.
POST /api/config/test-timeseries does the same job for a whole series. It
takes domain, either "price" or "pv", plus the
same style of unsaved overrides. Success returns entry_count,
resolution_seconds, value_unit, warnings, and a
short slots sample so you can eyeball the numbers and the units. Failure
returns ok: false with an error and a field
naming the setting most likely at fault.
Both routes answer HTTP 200 even when the test fails. The HTTP
status tells you the probe ran, not that it succeeded. Branch on the
ok field.
Automatic sign-convention detection
This is the part that really is automatic, and it has nothing to do with finding sensors. Installations disagree about the sign of battery and grid power: some report charging positive, some negative; some report import positive, some export positive. Getting it backwards silently inverts your stored-energy costing.
So EOS Connect works it out. On the first run of the battery price handler it takes the last 48 hours of battery, PV, grid and load history, tests all four sign combinations against the energy balance PV + grid + battery = load, and keeps whichever produces the smallest average error. The result is cached for the session and logged, so you can confirm it:
curl -s "http://localhost:8081/logs?limit=500" \
| jq -r '.logs[] | select(.message | test("Detected conventions")) | .message'
Detection needs battery, grid and load history to work with. Without them it falls back to the common convention — charging positive, import positive — which is right for most hardware but is a guess, not a measurement.