Automates downloading your OVO Energy (AU) usage data and imports it into Home Assistant
2.9K
Automates downloading your OVO Energy (Australia) usage data and importing it into Home Assistant as long-term energy statistics. Runs on a schedule — no manual exports, no spreadsheets.
Pipeline: logs into OVO → requests a usage data export → polls your email for the download link → downloads the CSV → archives the email → splits it into daily files → imports into Home Assistant → cleans up old files per your retention policy.
Supports linux/amd64 and linux/arm64 (Raspberry Pi).
Tags: latest tracks the newest release. Pin a specific version (e.g.
1.0.2) in production for predictable upgrades — see
CHANGELOG.md
for what's changed between versions before upgrading.
Create a working directory and a .env file:
mkdir -p /opt/ovo-agent/data
cd /opt/ovo-agent
docker-compose.yml:
services:
ovo-agent:
image: bmach/ovo-agent:latest
container_name: ovo-agent
restart: unless-stopped
pull_policy: always
env_file:
- .env
environment:
CSV_DOWNLOAD_DIR: /data
COOKIES_FILE: /data/ovo_cookies.json
volumes:
- ./data:/data
command: ovo-schedule
.env (minimum required):
[email protected]
OVO_PASSWORD=your_ovo_password
IMAP_HOST=imap.gmail.com
[email protected]
IMAP_PASSWORD=xxxx xxxx xxxx xxxx # Gmail App Password, see below
IMAP_ARCHIVE=[Gmail]/All Mail
HA_URL=http://homeassistant.local:8123
HA_TOKEN=your_long_lived_access_token
HA_TIMEZONE=Australia/Brisbane # also controls what timezone CRON_SCHEDULE runs in
CRON_SCHEDULE=0 4 * * * # 4am in HA_TIMEZONE, not UTC
Start it:
docker compose up -d
docker compose logs -f
That's it — the container will check for a new usage export every day at the scheduled time and keep Home Assistant up to date.
If your inbox is Gmail, you need an App Password (your normal password won't work with 2FA enabled):
IMAP_PASSWORDOther IMAP providers (Outlook, Fastmail, etc.) work too — just set
IMAP_HOST accordingly.
HA_TOKENNo YAML configuration is needed in Home Assistant — the container creates the energy sensors automatically the first time it imports data:
Energy (always imported):
| Sensor | Meaning |
|---|---|
sensor.ovo_grid_consumption | Grid import (hourly kWh) |
sensor.ovo_solar_export | Solar export to grid (hourly kWh) |
sensor.ovo_control_load_2 | Control Load 2 (zero until an appliance is connected) |
Cost statistics — created automatically when tariffs.yaml is present
alongside .env:
| Sensor | Meaning |
|---|---|
sensor.ovo_grid_cost | Cumulative AUD grid consumption cost (TOU-aware) |
sensor.ovo_solar_export_earnings | Cumulative AUD solar feed-in credit |
sensor.ovo_supply_charge | Cumulative AUD daily supply charge |
Cost sensors are best used in ApexCharts or statistics graph cards. They do not appear in the Energy Dashboard cost picker (HA limitation with imported statistics — see README for details).
Cost (imported when tariffs.yaml is present):
| Sensor | Meaning |
|---|---|
sensor.ovo_grid_cost | Cumulative grid consumption cost (AUD) |
sensor.ovo_solar_export_earnings | Cumulative solar feed-in credit (AUD) |
sensor.ovo_supply_charge | Cumulative daily supply charge (AUD) |
Add kWh sensors to Settings → Energy once the first import has run. Cost statistics work in ApexCharts and Statistics cards. See the project README for wiring the Energy Dashboard cost column.
⚠️ Don't create your own template sensors for these entities in HA YAML — it causes a statistics conflict. The container manages these sensors entirely on its own.
The default command (ovo-schedule) runs the daemon. To run any other task
as a one-off, throwaway container:
docker compose run --rm ovo-agent <command>
Useful examples:
# Run the full pipeline once, right now (don't wait for the schedule)
docker compose run --rm ovo-agent ovo-agent
# Test your IMAP/email setup without touching OVO
docker compose run --rm ovo-agent ovo-imap-test --since-minutes 1440 --dry-run
# Re-split the most recent download sitting in your data folder
docker compose run --rm ovo-agent ovo-split
# Preview what the next import would do, without writing to HA
docker compose run --rm ovo-agent ovo-ha-import --dry-run
# Force a full clean reimport of all historical data
docker compose run --rm ovo-agent ovo-ha-import --clear --reimport
# Apply your retention policy manually
docker compose run --rm ovo-agent ovo-archive --dry-run
Everything lives under the mounted ./data volume:
data/
├── raw/ full downloads (kept for RAW_RETAIN_DAYS, default 2)
├── daily/ per-day CSVs (kept for DAILY_RETAIN_DAYS, default 90)
├── errors/ browser debug screenshots (kept for ERRORS_RETAIN_DAYS, default 30 days)
│ each file prefixed YYYY-MM-DD_HHMMSS_ for chronological history
├── import_log.json tracks what's already in Home Assistant
└── ovo_cookies.json browser session, avoids logging in every run
└── tariffs.yaml user data to enable cost statistics.
A sample tariffs.yaml file is included in the project repository. Run docker compose run --rm ovo-agent ovo-tariff-test to verify rate resolution before your first import.
Back up this folder if you want to preserve session state and import history across container recreation — though everything can also be safely rebuilt by reimporting from OVO if lost.
If tariffs.yaml is missing, cost statistics are silently skipped.
| Variable | Default | Purpose |
|---|---|---|
CRON_SCHEDULE | 0 4 * * * | When the daily pipeline runs — interpreted in HA_TIMEZONE, not UTC |
HA_TIMEZONE | UTC | Your local timezone — also used to schedule CRON_SCHEDULE correctly |
MAX_SPLIT_DAYS | 90 | How far back to process on each run |
RAW_RETAIN_DAYS | 2 | How many full downloads to keep |
DAILY_RETAIN_DAYS | 90 | How many daily CSVs to keep |
ERRORS_DIR | CSV_DOWNLOAD_DIR/errors | Where browser failure screenshots are saved |
ERRORS_RETAIN_DAYS | 30 | How many days to keep debug screenshots |
TARIFFS_FILE | <project_root>/tariffs.yaml | Path to tariff config — omit file to skip cost import |
EMAIL_POLL_TIMEOUT | 600 | Seconds to wait for the OVO email |
RETRY | disabled | Set to enabled to schedule one delayed retry when no new dates are available |
RETRY_HOURS | 2 | Hours to wait before the one no-new-data retry |
LOG_LEVEL | INFO | Set to DEBUG for troubleshooting |
Tariff rates and plan history are configured in tariffs.yaml (not .env) —
see the project README for the full format.
⚠️ The container's system clock defaults to UTC regardless of your host's timezone. If
HA_TIMEZONEisn't set,CRON_SCHEDULEruns against UTC — "4am" will fire at 4am UTC, not 4am where you live. Always setHA_TIMEZONEto your local IANA timezone (e.g.Australia/Brisbane,America/New_York).
Full configuration reference and source: see the project README.
| Symptom | Fix |
|---|---|
| Login fails | Set LOG_LEVEL=DEBUG and check ./data/errors/ for a timestamped screenshot |
| Email never found | Confirm IMAP_USER / IMAP_PASSWORD are correct and OVO_EMAIL_SENDER matches [email protected] |
| "Mean type has changed" in HA | Remove any custom YAML sensors for sensor.ovo_*, then run ovo-ha-import --clear --reimport |
| CSV download fails (403) | The signed link expired (6h limit) — increase EMAIL_POLL_TIMEOUT or run more frequently |
| Scheduled run fires at the wrong time | HA_TIMEZONE not set — defaults to UTC, not your local time |
For full documentation, configuration options, and source code, see the project repository.
All notable changes to this project are documented here. Format follows Keep a Changelog, and this project uses Semantic Versioning.
LOGIN_RETRY_HOURS (default 2 hours), before hard
failure. This applies to unexpected login failures as well as known OVO
outage and form-processing errors.LOGIN_RETRY_INITIAL_DELAY_SECONDS,
LOGIN_RETRY_SECOND_DELAY_SECONDS, and LOGIN_RETRY_HOURS configuration
settings. LOGIN_RETRY_ATTEMPTS remains available and defaults to 4.wait_for_url() to detect a successful login.
OVO's login form is served from the same domain as the rest of the app, so
the URL already matched the "success" pattern before the form was even
submitted — meaning a transient OVO login-form error ("There was an error
processing your request…") was silently treated as a successful login, and
only surfaced two steps later as "Usage tab not found". Login success is
now confirmed the same way is_logged_in() does it: by waiting for the
Usage tab in the authenticated app shell.LOGIN_RETRY_ATTEMPTS times (default 3), waiting
LOGIN_RETRY_DELAY_SECONDS between attempts (default 300s / 5 min). This
is separate from the existing no-new-data retry: that one waits hours and
solves a different problem (OVO not having exported data yet), whereas a
login blip clears up within minutes and shouldn't delay the whole run.
Unrecognised login failures (e.g. selectors changed, real credential
failure) are not retried.mean_type metadata
and unit_class for AUD cost statistics, avoiding recorder deprecation
warnings ahead of HA Core 2026.11.RETRY=enabled in .env to retry once when a run finds no new dates to
import; RETRY_HOURS controls the delay and defaults to 2 hours.tariff.py — tariff engine that loads tariffs.yaml and resolves the
correct AUD rate for any 5-minute interval, supporting multiple plans with
date ranges, time-of-use windows, solar feed-in credits, and daily supply
charges.tariffs.yaml — static tariff configuration file placed in download folder.
Supports any number of plans; the active plan is detected by a null end
date. Rate windows are defined as local-time start times (HH:MM). Missing
file is gracefully skipped with an info log — no code changes required to
enable or disable cost import.tariffs.yaml
is present):
sensor.ovo_grid_cost — cumulative AUD cost of grid consumption (E1),
calculated per 5-minute interval using the applicable TOU window ratesensor.ovo_solar_export_earnings — cumulative AUD credit for solar
export (B1) at the configured feed-in tariff ratesensor.ovo_supply_charge — cumulative AUD daily supply charge,
apportioned per day from supply_charge_daily (cents/day) in the planovo-tariff-test CLI entrypoint — verifies tariff resolution across all
time-of-use windows for today (default) or a specific datetime. Useful
for confirming plan transitions and window boundaries before a full import.pyyaml dependency added for tariffs.yaml parsing.ovo-tariff-test added to launch.json with date/time prompt inputs.ha_importer.py --clear now also clears the three cost statistics
(sensor.ovo_grid_cost, sensor.ovo_solar_export_earnings,
sensor.ovo_supply_charge) alongside the kWh statistics.tariffs.yaml logs at INFO level (not WARNING) since it is a
valid opt-out state, not an error./tmp which is ephemeral in Docker
and overwritten on every run. Screenshots now save to CSV_DOWNLOAD_DIR/errors/
(inside the mounted data volume) with a YYYY-MM-DD_HHMMSS_ datetime prefix
so failures are persistent and historical across cron runs.os.getenv() calls for retention policy values
instead of reading from config.py, creating two sources of truth for
defaults that could silently drift. All retention constants now live
exclusively in config.py.ERRORS_DIR had no consistent default in the archiver — it relied on
config.ERRORS_DIR by accident rather than by design.ERRORS_DIR config constant in config.py (defaults to
CSV_DOWNLOAD_DIR/errors/) for browser debug screenshots.RAW_RETAIN_DAYS, DAILY_RETAIN_DAYS, and ERRORS_RETAIN_DAYS retention
constants moved to config.py as the single source of truth.apply_errors_retention() in archiver.py — deletes debug screenshots
older than ERRORS_RETAIN_DAYS days (default 30) as part of the standard
archival pipeline.CRON_SCHEDULE in HA_TIMEZONE instead of the
container's system clock (which defaults to UTC). Previously a schedule
of 0 4 * * * would fire at 4am UTC, not 4am local time.TZ environment variable support in docker-compose.yml for
OS-level timezone consistency (log timestamps, file mtimes).docker-compose.yml now pins a specific image version by default instead
of tracking latest, for predictable upgrades and easy rollback.README.md was missing from the builder stage's build
context, causing pip install . to fail during multi-arch image builds
(Poetry's build backend requires the file referenced in pyproject.toml's
readme field to be present).sum reset to zero instead
of continuing from the last known value in Home Assistant, causing
incorrect (including negative) energy readings at day boundaries..dockerignore to exclude local development files from the build context.MAX_SPLIT_DAYS) and automatic
skipping of already-imported dates.recorder/import_statistics
API — imports hourly energy statistics directly into HA's long-term
statistics database. No YAML configuration required.linux/amd64, linux/arml64) with a
multi-stage build that excludes Poetry and build tools from the final
image.Content type
Image
Digest
sha256:52f8d665b…
Size
451.2 MB
Last updated
about 1 month ago
docker pull bmach/ovo-agent