An OpenClaw agent that intelligently manages battery charging on a Solis inverter by combining:
| Data source | HA integration |
|---|---|
| Battery SoC & inverter control | solis_modbus |
| Octopus Energy Agile tariff prices | HomeAssistant-OctopusEnergy |
| Solar generation / forecast | ha-solarman |
Every 30 minutes (configurable) the agent runs an observe → reason → act cycle:
- Observe – reads battery SoC, current Agile rate, upcoming half-hourly rates, and the PV generation forecast concurrently from Home Assistant.
- Reason – the decision engine applies your charging policy rules.
- Act – instructs the Solis inverter to either force-charge from the grid or return to self-consumption mode.
| Condition | Action |
|---|---|
| Rate ≤ 0 (Octopus pays you) | Force charge (grid draw regardless of SoC) |
Battery already ≥ max_soc_for_charge_pct |
Self-consumption |
Rate ≤ cheap_rate_threshold_gbp AND a cheaper slot is imminent |
Defer (self-consumption) |
Rate ≤ cheap_rate_threshold_gbp AND solar forecast covers needs |
Self-consumption |
Rate ≤ cheap_rate_threshold_gbp |
Force charge |
| Rate > threshold | Self-consumption |
All thresholds are user-configurable, including support for negative prices
(e.g. cheap_rate_threshold_gbp: -0.05 means "only charge when Octopus pays
you at least 5 p/kWh").
cp config/config.example.yaml config/config.yamlEdit config/config.yaml – at minimum you must set:
home_assistant:
url: "http://homeassistant.local:8123"
token: "YOUR_HA_LONG_LIVED_ACCESS_TOKEN" # HA → Profile → Security
solis:
battery_soc_entity: "sensor.solis_<SERIAL>_battery_soc"
battery_power_entity: "sensor.solis_<SERIAL>_battery_power"
pv_power_entity: "sensor.solis_<SERIAL>_pv_total_power"
charge_current_entity: "number.solis_<SERIAL>_battery_charge_current_limit"
storage_mode_entity: "select.solis_<SERIAL>_energy_storage_control_switch"
force_charge_switch_entity: "" # optional; set if your setup uses a switch to enable TOU charge
octopus:
current_rate_entity: "sensor.octopus_energy_electricity_<MPAN>_<SERIAL>_current_rate"
solarman:
current_power_entity: "sensor.solarman_pv_power"Use HA Developer Tools → States to find the exact entity IDs for your setup.
policy:
cheap_rate_threshold_gbp: 0.20 # charge when price <= 20 p/kWh| Example value | Meaning |
|---|---|
0.20 |
Charge whenever price is 20 p/kWh or cheaper |
0.10 |
Charge only when price is 10 p/kWh or cheaper |
0.00 |
Charge only when Octopus pays you (negative rate) |
-0.05 |
Charge only when Octopus pays at least 5 p/kWh |
pip install .
pvopt-agent --config config/config.yamlFor persistent operation on Linux, see the
systemd unit example in SETUP.md.
docker compose up -dThe config/ directory is mounted read-only into the container.
Docker is recommended but not required. The agent is a plain Python application and runs equally well directly via
pip install .– no Docker daemon needed.
For an unattended/automated installation, see the fully explicit SETUP.md. The steps below cover everything needed for a manual installation from scratch.
Before starting, confirm the following are available:
- A running Home Assistant instance (OS, Supervised, Container, or Core) reachable over HTTP/HTTPS.
- A Solis solar inverter with the solis_modbus HACS integration installed and configured.
- An Octopus Energy electricity account on the Agile tariff, with the HomeAssistant-OctopusEnergy HACS integration installed.
- The ha-solarman HACS integration installed.
- A Home Assistant long-lived access token (HA → Profile → Security → Long-Lived Access Tokens).
- Python ≥ 3.11 — Docker is optional but recommended when already available.
Open HA → Developer Tools → States and note the exact entity IDs for the following groups.
| Config key | Entity ID pattern | Example |
|---|---|---|
battery_soc_entity |
sensor.solis_<SERIAL>_battery_soc |
sensor.solis_SN123_battery_soc |
battery_power_entity |
sensor.solis_<SERIAL>_battery_power |
sensor.solis_SN123_battery_power |
pv_power_entity |
sensor.solis_<SERIAL>_pv_total_power |
sensor.solis_SN123_pv_total_power |
charge_current_entity |
number.solis_<SERIAL>_battery_charge_current_limit |
number.solis_SN123_battery_charge_current_limit |
storage_mode_entity |
select.solis_<SERIAL>_energy_storage_control_switch |
select.solis_SN123_energy_storage_control_switch |
force_charge_switch_entity (optional) |
switch.solis_<SERIAL>_grid_time_of_use_charging_period_1 |
switch.solis_SN123_grid_time_of_use_charging_period_1 |
Also check the options attribute on the storage_mode_entity select to
confirm the exact strings for mode_self_use (typically "Self-Use Mode")
and mode_force_charge (typically "Timed Charge").
| Config key | Entity ID pattern |
|---|---|
current_rate_entity |
sensor.octopus_energy_electricity_<MPAN>_<METER_SERIAL>_current_rate |
rates_event_entity (optional) |
event.octopus_energy_electricity_<MPAN>_<METER_SERIAL>_current_day_rates |
Leave rates_event_entity as "" if the event entity is not present; the
agent falls back to the rate-sensor attributes.
| Config key | Typical entity ID |
|---|---|
current_power_entity |
sensor.solarman_pv_power |
today_energy_entity |
sensor.solarman_today_energy |
forecast_entity (optional) |
Leave as "" unless a forecast sensor exists |
git clone https://github.com/ZaviiNet/HA-PVOpt-Agent.git
cd HA-PVOpt-Agentcp config/config.example.yaml config/config.yamlEdit config/config.yaml, replacing every <placeholder> with the real
values gathered in Step 1. The mandatory fields are:
home_assistant:
url: "http://<HA_HOST>:8123" # no trailing slash; use https if applicable
token: "<HA_LONG_LIVED_TOKEN>"
solis:
battery_soc_entity: "sensor.solis_<SERIAL>_battery_soc"
battery_power_entity: "sensor.solis_<SERIAL>_battery_power"
pv_power_entity: "sensor.solis_<SERIAL>_pv_total_power"
charge_current_entity: "number.solis_<SERIAL>_battery_charge_current_limit"
storage_mode_entity: "select.solis_<SERIAL>_energy_storage_control_switch"
octopus:
current_rate_entity: "sensor.octopus_energy_electricity_<MPAN>_<METER_SERIAL>_current_rate"
solarman:
current_power_entity: "sensor.solarman_pv_power"
today_energy_entity: "sensor.solarman_today_energy"All other fields are optional with safe defaults. See
config/config.example.yaml for the full
annotated reference.
Recommended: enable dry-run for first boot
Add dry_run: true to config/config.yaml before running for the first
time. The agent will log what it would do without writing anything to Home
Assistant. Remove or set to false once you are satisfied.
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install .
pvopt-agent --config config/config.yamlThe agent logs to stdout. For persistent background operation, create a systemd unit — see SETUP.md for a ready-to-use unit file.
docker compose up -d
docker compose logs -fThe config/ directory is mounted read-only into the container. Docker
Compose restarts the container automatically on failure.
Look for lines like the following in the logs to confirm a successful cycle:
PVOpt Agent started. Poll interval: 30 min.
PVOpt Agent cycle starting at <UTC timestamp>
[Observation] battery_soc=<N>%, current_rate=<N> £/kWh, solar_forecast=<N> Wh
Cycle complete. Action: <FORCE_CHARGE|SELF_CONSUMPTION> | <reason>
In dry-run mode any inverter action will appear as:
[DRY RUN] Would set select.solis_<SERIAL>_energy_storage_control_switch → 'Timed Charge'
Once the dry-run output looks correct:
- Set
dry_run: falseinconfig/config.yaml(orPVOPT_DRY_RUN=false). - Restart the agent:
- Python/systemd:
systemctl restart ha-pvopt-agent - Docker:
docker compose restart
- Python/systemd:
| Symptom | Likely cause | Resolution |
|---|---|---|
Configuration file not found |
Missing config/config.yaml |
Run Step 3 or set PVOPT_HA_URL / PVOPT_HA_TOKEN |
Failed to read battery state |
Wrong entity ID | Check entity IDs in HA Developer Tools → States |
Failed to read current rate |
Wrong entity ID or Octopus integration not configured | Verify current_rate_entity |
| Agent starts but takes no action | dry_run: true |
Set dry_run: false and restart |
Select option not found |
Mode strings don't match HA | Check options attribute of the select entity |
| High CPU / never sleeps | poll_interval_minutes too low |
Increase to 30 (recommended) |
Every required and frequently-changed config value can be supplied (or
overridden) via PVOPT_* environment variables. This is particularly useful
for systemd units, CI pipelines, or any setup where writing a YAML file is
inconvenient.
| Environment variable | Config key | Notes |
|---|---|---|
PVOPT_HA_URL |
home_assistant.url |
Base URL of your HA instance |
PVOPT_HA_TOKEN |
home_assistant.token |
Long-lived access token |
PVOPT_DRY_RUN |
dry_run |
true/1/yes → True |
PVOPT_LOG_LEVEL |
log_level |
DEBUG, INFO, WARNING, ERROR |
PVOPT_POLL_INTERVAL_MINUTES |
poll_interval_minutes |
Integer minutes |
PVOPT_CONFIG |
(config file path) | Overrides the --config default |
Environment variables take precedence over values in the YAML file.
The YAML file is entirely optional when PVOPT_HA_URL and PVOPT_HA_TOKEN
are set.
Minimal env-only example (no config file required):
export PVOPT_HA_URL="http://homeassistant.local:8123"
export PVOPT_HA_TOKEN="your_token_here"
export PVOPT_DRY_RUN=true
pvopt-agentSet dry_run: true in your config. The agent will log exactly what it would
do without calling any HA services:
[DRY RUN] Would set select.solis_SN123_energy_storage_control_switch → 'Timed Charge'
See config/config.example.yaml for the full
annotated reference. All fields except home_assistant.url and
home_assistant.token have sensible defaults.
Key policy options:
| Option | Default | Description |
|---|---|---|
cheap_rate_threshold_gbp |
0.20 |
Maximum price (£/kWh) to trigger a grid charge |
min_soc_to_charge_pct |
20.0 |
Don't force-charge if SoC is already above this % |
max_soc_for_charge_pct |
95.0 |
Stop charging once SoC reaches this % |
min_solar_reserve_wh |
500.0 |
Skip grid charge if solar will provide this much Wh in the next slot |
look_ahead_slots |
3 |
Number of upcoming slots to check for cheaper prices |
battery_capacity_kwh |
5.0 |
Usable battery capacity (for log estimates) |
poll_interval_minutes |
30 |
How often the agent runs (matches Agile slot width) |
dry_run |
false |
Log actions without writing to HA |
agent/
├── main.py # Entry point, scheduler, observe-reason-act loop
├── config.py # Pydantic configuration models
├── models.py # Data models (Observation, Decision, TariffSlot, …)
├── ha_client.py # Async Home Assistant REST API client
├── decision.py # Stateless decision engine
└── tools/
├── solis.py # Reads battery state; sets inverter mode
├── octopus.py # Reads current & upcoming Agile rates
└── solarman.py # Reads PV power & derives 30-min generation forecast
pip install pytest pytest-asyncio
pytestThe following test cases demonstrate how the DecisionEngine evaluates real-world scenarios based on the defined SKILL.md rules. The agent evaluates priorities top-to-bottom, executing the first matching rule.
Default Assumptions for these tests:
cheap_rate_threshold_gbp: £0.20
max_soc_for_charge_pct: 95.0%
min_solar_reserve_wh: 500.0 Wh
look_ahead_slots: 3
Scenario 1: Deferring to Solar (Priority 4) The rate is cheap, but the sun is expected to shine, so we rely on self-consumption to save grid import costs.
Battery SoC: 53%
Current Rate: £0.15
Solar Forecast: 1,313 Wh
Upcoming Tariffs (Next 3): £0.16, £0.17, £0.30
Priority 1 (Battery Full): False
Priority 2 (Plunge Rate): False
Priority 3 (Cheaper Slot Imminent): False (Upcoming prices are higher)
Priority 4 (Cheap Rate + High Solar Forecast): True (£0.15 ≤ £0.20 AND 1,313 Wh ≥ 500 Wh)
Result: SELF_CONSUMPTION
Reasoning: Rate is below the cheap threshold, but the solar forecast is high enough that the sun will fill the battery.
Scenario 2: Deferring for Imminent Plunge Pricing (Priority 3) The current rate is cheap, but negative pricing is coming up within the look-ahead window. We wait to get paid to charge.
Battery SoC: 53%
Current Rate: £0.15
Solar Forecast: 1,313 Wh
Upcoming Tariffs (Next 3): -£0.16, -£0.17, -£0.30
Priority 1 (Battery Full): False
Priority 2 (Plunge Rate): False
Priority 3 (Cheaper Slot Imminent): True (£0.15 ≤ £0.20 AND a cheaper slot exists in the next 3)
Result: SELF_CONSUMPTION (Defer)
Reasoning: The agent defers charging because it recognizes that waiting will yield negative plunge pricing, literally paying you to charge.
Scenario 3: Standard Force Charge (Priority 5) The grid is cheap, there's no sun expected, and prices are going up. Time to fill the battery.
Battery SoC: 22%
Current Rate: £0.12
Solar Forecast: 50 Wh
Upcoming Tariffs (Next 3): £0.18, £0.25, £0.32
Priority 1 (Battery Full): False
Priority 2 (Plunge Rate): False
Priority 3 (Cheaper Slot Imminent): False (Upcoming prices are higher)
Priority 4 (Cheap Rate + High Solar Forecast): False (50 Wh < 500 Wh)
Priority 5 (Cheap Rate): True (£0.12 ≤ £0.20)
Result: FORCE_CHARGE
Reasoning: Grid rates are low, no cheaper slots are imminent, and there isn't enough solar forecast to rely on. The battery is instructed to charge from the grid.
MIT