Skip to content

HomeHub-Innovations/HA-PVOpt-Agent

Repository files navigation

HA-PVOpt Agent – OpenClaw PV Optimisation for Home Assistant

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:

  1. Observe – reads battery SoC, current Agile rate, upcoming half-hourly rates, and the PV generation forecast concurrently from Home Assistant.
  2. Reason – the decision engine applies your charging policy rules.
  3. Act – instructs the Solis inverter to either force-charge from the grid or return to self-consumption mode.

Decision logic

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").


Quick start

1. Install the required HA integrations (via HACS)

2. Create your config

cp config/config.example.yaml config/config.yaml

Edit 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.

Setting a custom price threshold

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

3. Run with Python (recommended for most users)

pip install .
pvopt-agent --config config/config.yaml

For persistent operation on Linux, see the systemd unit example in SETUP.md.

4. Run with Docker (recommended when Docker is already available)

docker compose up -d

The 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.


Complete setup

For an unattended/automated installation, see the fully explicit SETUP.md. The steps below cover everything needed for a manual installation from scratch.

Prerequisites

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.

Step 1 – Discover your Home Assistant entity IDs

Open HA → Developer Tools → States and note the exact entity IDs for the following groups.

Solis Modbus entities

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").

Octopus Energy entities

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.

Solarman entities

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

Step 2 – Clone the repository

git clone https://github.com/ZaviiNet/HA-PVOpt-Agent.git
cd HA-PVOpt-Agent

Step 3 – Create and edit the configuration file

cp config/config.example.yaml config/config.yaml

Edit 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.

Step 4a – Install and run with Python

python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install .
pvopt-agent --config config/config.yaml

The agent logs to stdout. For persistent background operation, create a systemd unit — see SETUP.md for a ready-to-use unit file.

Step 4b – Install and run with Docker Compose

docker compose up -d
docker compose logs -f

The config/ directory is mounted read-only into the container. Docker Compose restarts the container automatically on failure.

Step 5 – Verify the agent is working

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'

Step 6 – Go live

Once the dry-run output looks correct:

  1. Set dry_run: false in config/config.yaml (or PVOPT_DRY_RUN=false).
  2. Restart the agent:
    • Python/systemd: systemctl restart ha-pvopt-agent
    • Docker: docker compose restart

Troubleshooting

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)

Environment variable configuration

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-agent

Testing your config without touching the inverter

Set 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'

Configuration reference

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

Architecture

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

Running tests

pip install pytest pytest-asyncio
pytest

Logic Simulation & Test Cases

The 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.

Observation:

Battery SoC: 53%

Current Rate: £0.15

Solar Forecast: 1,313 Wh

Upcoming Tariffs (Next 3): £0.16, £0.17, £0.30

Evaluation:

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.

Observation:

Battery SoC: 53%

Current Rate: £0.15

Solar Forecast: 1,313 Wh

Upcoming Tariffs (Next 3): -£0.16, -£0.17, -£0.30

Evaluation:

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.

Observation:

Battery SoC: 22%

Current Rate: £0.12

Solar Forecast: 50 Wh

Upcoming Tariffs (Next 3): £0.18, £0.25, £0.32

Evaluation:

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.


Licence

MIT

About

An OpenClaw Agent for PV Opt: Home Assistant Solar/Battery Optimiser

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors