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.