Visualizes household power, gas, water, and battery usage from a local P1 smart-meter setup — real time via the local API (P1 meter, Plug-in Battery, Watermeter), or from CSV exports imported into SQLite.
Meter hardware: any brand. Both ways of getting data in work for any P1 meter, live or from files:
- Live polling — if your meter serves JSON over your LAN (most do), give OmniMeter its URL and tell it what your vendor calls each value. See "Live polling from any meter" below.
- File import — a vendor-neutral CSV whose columns are OmniMeter's own canonical names. See "Importing from any meter" below.
HomeWizard is the only brand that needs no configuration for either: its
local API and its CSV export format are recognized out of the box. Every other
brand takes one setup step — stating your meter's own field or column names
once — after which nothing brand-specific is involved. See
docs/DESIGN_SPEC.md §3, "Getting data in".
Energy suppliers: many. Separate axis, often confused with the above. Whoever bills you only affects the rates used on the Costs tab, and nine Dutch suppliers' rate sheets are recognized, plus a generic CSV format for any other — see "Settings" below. Your supplier and your meter brand are independent choices.
Where this works: not just the Netherlands. The P1 port is a shared European standard (DSMR — Dutch Smart Meter Requirements), not a Dutch-only quirk. Besides the Netherlands, it's present on smart meters in Belgium (deactivated by default outside Brussels — ask your grid operator, e.g. Fluvius, to enable it), Luxembourg ("Smarty" meters — the port is encrypted and needs a P1 key from your utility), Sweden, Finland, Denmark, Hungary, Austria, and Norway. Germany is partial — only specific meter models (e.g. EasyMeter Q3D) expose a compatible port, not a national standard. If your meter has a P1 port at all, OmniMeter can read it regardless of country.
Built by T. Philip for personal use. See "Native systemd install" near the bottom if you're running without Docker. Everyone else: start below.
Licensed under AGPL-3.0. Please credit t-philip if you use or share this. If you modify it and make it available to others over a network, you must offer those users the source of your modified version.
The interface is English only — see "Language" under "Using the dashboard" below for why, and for how to read it in your own language anyway without any app changes.
- Zoom by dragging — drag straight across any chart to narrow the date range to exactly the span you selected, no date-picker fiddling. See "Using the dashboard" below.
- Weather-correlated estimates — solar self-sufficiency is reconciled against real daily weather (Open-Meteo shortwave radiation), not a flat seasonal assumption; the Gas tab shows a heating-degree-day rail so cold spells and usage spikes are visibly linked.
- Tariff import, auto-detected — upload a rate-sheet PDF and OmniMeter recognizes the format itself (nine supplier formats today); no supplier picker, no manual entry required. See "Settings" below.
- Bulk historical import — CSV exports covering years of readings import in one file, not just going-forward data.
- Occupancy correlation — log who's home and when; consumption charts can be read against that overlay.
- Feature toggles — hide any tab/category you don't have (gas, water, battery, solar), and every outbound network call (weather, update check, leak alerts) is its own opt-in switch, off by default.
- Water leak alerts — a fast-path detector describes a continuous run of flow by three signals (how long, how much, how fast) and fires on whichever fits: a burst, a fixture left running, or sheer volume. A second, slower detector watches 15-minute buckets overnight and while you're away, to catch the background trickle a run-based rule structurally cannot see. Telegram message plus a persistent in-app banner as the reliable, no-network fallback. See "Settings" below.
- Backup and restore — automated nightly backups with 30-day retention, and a documented restore path. See "Backups" below.
A few other self-hosted P1/DSMR options exist — worth knowing where OmniMeter sits among them before you pick one:
- DSMR-reader is the established option (490+ stars, Django-based) and a solid choice if you only care about electricity. It's power-only and licensed free for non-commercial use only. OmniMeter also covers gas, water, and battery in one dashboard, with a Costs tab priced against your actual rate schedule, and is licensed AGPL-3.0 — free including commercial use, as long as you share source changes with your own users.
- Small HomeWizard-specific dashboards (e.g.
p1dash,homewizard-monitor) are lighter-weight live-power viewers, generally single-device and power-only, without CSV import, cost tracking, or multi-category history. - HomeWizard's own app is the easiest path if you only own HomeWizard hardware and don't mind a cloud-connected, brand-locked tool. OmniMeter is self-hosted — none of your actual data (readings, costs, occupancy, and so on) ever leaves your LAN. Three optional features make outbound calls, all off by default: weather correlation sends only a coarsened, ~11 km grid location coordinate to Open-Meteo (never an address); the update check sends nothing but an anonymous request to GitHub's public releases API; leak alerts send a message to your own Telegram bot only when a possible leak is detected. It also works with any P1 meter brand, not just HomeWizard's.
Already have OmniMeter running and just want a newer version? Skip to "Upgrading"
below instead — the steps here assume a fresh install, and repeating them over an
existing one (a fresh git clone, or re-running the two cp lines below) discards
your real database, device pairing, and write-auth token.
Requires only Docker + Docker Compose — no Python install needed on your machine.
git clone https://github.com/t-philip/omnimeter.git && cd omnimeter \
&& cp .env.example .env && cp devices.json.example devices.json \
&& docker compose run --rm setup && docker compose up -dOr the same steps broken out:
git clone https://github.com/t-philip/omnimeter.git
cd omnimeter
cp .env.example .env
cp devices.json.example devices.json
docker compose run --rm setup # interactive — pairs your P1 meter / Plug-in Battery / Watermeter
docker compose up -dOpen http://localhost:8000 (or whatever OMNIMETER_PORT you set in .env).
What the setup wizard does: for each meter you have, asks its IP and walks you through pairing — press the physical button when prompted, no app account or third-party bot needed. Generates a random write-auth token automatically. Safe to re-run later: it asks before touching anything already configured.
Backups: a backup container runs nightly, writing to OMNIMETER_BACKUP_HOST_DIR (set in
.env, defaults to ./backups next to this file) — 30-day retention, pruned automatically. See
"Restoring a backup" below for the self-hosted restore command.
Uninstalling: ./uninstall.sh — stops and removes containers/image first (routine, always
safe, fully recreatable), then asks individually before removing anything that holds real data
(.env, devices.json, data/, your backups directory). Nothing is ever deleted silently.
Deliberately not built: HTTPS/reverse-proxy — put this behind your own (Caddy, Nginx, a Tailscale funnel, etc.) if you need it reachable beyond your own LAN. No multi-user accounts; the write-auth token above is the only access control, matching the app's existing LAN-only design.
Update your existing install directory in place — don't git clone a second copy
and don't repeat the cp .env.example .env / cp devices.json.example devices.json
lines above. Your real database, .env, and devices.json only exist in that one
directory, on your own machine, on purpose (see .gitignore) — none of it lives in
git, so anything that starts you over in a different or freshly-cloned directory
leaves them all behind with nothing carried forward.
cd omnimeter # your existing install directory, not a new clone
git pull
docker compose up -d --buildThat's it. git pull never touches data/, .env, or devices.json — they're
gitignored specifically so a pull can't overwrite them. Any new database columns a
release adds are applied automatically the first time the app starts afterwards (see
the migration functions in src/db.py); you don't need to run anything by hand for
that. If a release's notes call out a new .env variable, add that one line to your
existing .env yourself — don't recopy .env.example over it, that would blank out
everything else already in there.
If you already lost data by re-cloning instead: check whether the old directory
still exists under its original name, or a renamed/backup copy of it — if so, copy its
data/omnimeter.db into the new install's data/ directory before starting the
containers, then restart. Otherwise, see "Restoring a backup" below for recovering
from the nightly backup, if one exists from before the reinstall.
Zoom by dragging. Every chart tab shares one date range, set by the controls above the charts — presets (7d/30d/90d/1y), or exact From/To dates. The quickest way is to drag straight across a chart: release, and the range narrows to the span you dragged over. A 90-day view is dense; dragging over a fortnight makes it readable in one gesture.
That drag sets the actual range rather than a chart-only zoom, which keeps
everything in step: the From/To boxes fill in, the preset deselects, every chart
on the tab redraws to match, and the < / > buttons then step by exactly the
span you selected — so you can walk week by week through a period. Click any
preset to zoom back out. A plain click does nothing, so hover tooltips still work
as normal, and it works on touchscreens too.
Show and hide series. The pills under each chart switch individual series on and off. Everything drawn on a chart gets a pill — including the optional Sunshine rail — so you can compare just the two things you care about, such as Import against Export, or Import against sunshine alone. Hiding a series only hides it; nothing is recalculated.
Comparing quarters. Comparison → Quarters compares calendar quarters (Q1 is January–March, Q2 April–June, Q3 July–September, Q4 October–December). Two quarters puts any quarter and year beside any other, for example Q1 of one year against Q1 of the next, with the difference and percentage change for each figure. One quarter across years shows the same quarter for every year that has data, with the change from the previous year under each figure. It covers power import, export and net, gas, water, solar production, cost, battery, average headcount, average temperature, heating degree days (base 18 °C) and gas per heating degree day. Tick Show per-day averages to compare fairly when quarters differ in length or have gaps.
Any figure that is built from incomplete data carries an incomplete tag, and a list below the table says exactly what is missing — days without readings, a quarter still in progress, days priced with an expired rate, or a source that is not set up. Cost is energy only (fixed or standing fees are not included, and water has no cost). Add the missing data and the tags disappear on their own.
Weather (optional, off by default). Enable it under Settings → Feature toggles
to add daily solar-radiation data from Open-Meteo. It
draws a per-day sunshine rail along the bottom of the solar-driven charts, and
makes the Self-Sufficiency production estimate weather-aware instead of a flat
monthly average (used for days with no measured inverter data). This is one of three features that contact the internet (the
others are the GitHub update check and Telegram leak alerts, both also off by
default), and the request carries your configured coordinates — which are
rounded in code before use (see OMNIMETER_WEATHER_LOCATION_PRECISION in
.env.example; town-level precision is plenty, because the underlying data is
on a ~9 km grid anyway). Weather data by Open-Meteo.com is licensed CC BY 4.0,
and the credit is shown wherever the data appears.
Language. The interface is English only, with no translation system built in —
deliberately: with roughly 800 UI strings across help text, labels, and card copy, and
no non-English users of this dashboard to check translations against, a maintained
translation layer risked shipping wrong terminology nobody could catch. Modern browsers
cover this for free instead. Chrome, Edge, and Brave detect that the page declares
<html lang="en">, compare it against your browser's own language, and offer to
translate the page on the spot — entirely in your browser, using your browser vendor's
translation service, with nothing sent to or known by this app (unlike embedding a
translation widget, which would mean every page view leaving your LAN — see "Weather"
above for how carefully this project treats each of its few existing exceptions to
that). Firefox
has an equivalent free extension. Static text (headings, help popovers, card copy,
buttons, table headers) translates cleanly this way; the Power/Gas/Water/Battery charts
render to <canvas>, which is pixels rather than text, so their axis labels and
tooltips are the one thing no translator — browser-native or otherwise — can reach.
On a phone. The dashboard is responsive and works in a mobile browser — including translation (see "Language" above): open the URL directly in your phone's own Chrome/Edge and the same one-tap translate applies. Automatic translate is the one thing that doesn't carry over to the Android app below (it's a Chrome application feature, not part of the underlying WebView engine any app embeds) — Android's own system-wide "select text → Translate" still works there, just manually per-selection.
A native Android client is available under android/ — a WebView
shell pointed at your own instance (host/scheme/port set in-app, nothing
hardcoded). Grab the APK from the
android-v* releases
(versioned separately from this repo's regular releases), or build it
yourself with cd android && ./gradlew assembleDebug. Sideload only for now —
see android/README.md for install steps and the Play
Store status.
Everything lives in one .env file (see .env.example for the full list with comments) — device
Bearer tokens and the write-auth token are filled in by the setup wizard, not something you invent
yourself. devices.json holds LAN IPs/serials/protocol per device — not secret, safe to inspect —
and is also written by the wizard (see devices.json.example for its schema). Env var names are
deliberately generic (OMNIMETER_P1_TOKEN, not HOMEWIZARD_P1_API_TOKEN) since HomeWizard is one
of several P1-meter brands, even though it's the only vendor actually implemented right now — see
src/meter_device.py. The "Configuration" table below documents every variable in full, including
the ones the wizard doesn't touch (timezone, ports).
src/db.py— SQLite schema + connection helpersrc/ingest.py— CSV parsing, idempotent load into raw*_readingstablessrc/aggregate.py— daily rollups from cumulative meter readingssrc/solar_estimate.py— estimated (not measured) PV production, the fallback when no inverter feed existssrc/solar_production.py— measured PV production (when an inverter feed is connected) and the battery-aware self-sufficiency calculationsrc/tariff_parser.py—TariffParserregistry + auto-detection for supplier rate-sheet PDFs (Vattenfall, Greenchoice), plus the generic CSV import format for any other suppliersrc/app.py— Flask app factory + REST API + page routessrc/ingest_cli.py— entry point foromnimeter-ingest.timersrc/homewizard_api_client.py— low-level HTTPS client for one HomeWizard local API v2 device (loadsdevices.json, cert/hostname verification)src/meter_ingest.py/src/meter_poller.py— field mapping + long-running entry point foromnimeter-api-ingest.servicesrc/runaway_detector.py— water leak detection (three-signal gates on the live run, plus a 15-minute-bucket background-leak check; occupancy-gated), called frommeter_poller.pyevery poll cyclesrc/health_monitor.py— staleness detection (is each metric's live data still arriving), entry point foromnimeter-health-check.timer, independent ofmeter_poller.pysrc/anomaly_model.py— per-metric ridge-regression model of expected usage given calendar/weather/occupancy/EV-charging context; nightly retrain + digest, entry point foromnimeter-anomaly.timer(scripts/anomaly.py), independent of the above twosrc/alert_delivery.py— shared escalating-notification delivery for the leak/health alerts abovesrc/telegram_notifier.py— best-effort Telegram delivery for leak/health/anomaly-digest alertsscripts/setup_wizard.py— interactive device pairing, run viadocker compose run --rm setupscripts/backup.py— entry point foromnimeter-backup.timer(native systemd) / thebackupcontainer (Docker)wsgi.py— gunicorn entry point foromnimeter-web.service(native systemd) / thewebcontainer (Docker)- Native systemd install only: a wrapper of your own that exports
.envbefore starting gunicorn (OMNIMETER_WRITE_API_TOKENmust be in the environment before Flask starts, see Write authentication below); the Docker image reads.envdirectly, seeDockerfile/docker-compose.yml Dockerfile/docker-compose.yml/.env.example/uninstall.sh— Docker packaging, not used by a native systemd install (see "Native systemd install" below)data/— not in git.data/imports/is the CSV dropzone;data/imports/failed/holds uploads that failed to parse (not retried automatically);data/omnimeter.dbis the SQLite DB.
All services run as a non-root user — an omnimeter system user on a native systemd install,
the omnimeter container user (created by Dockerfile) when running in Docker.
Environment variables that override deployment-specific defaults. A few (marked below) only apply to a Docker install and have no native-systemd equivalent.
| Variable | Default | Overrides |
|---|---|---|
OMNIMETER_DEVICES_CONFIG |
/opt/omnimeter/devices.json |
Path to the device registry (see devices.json.example for the schema) |
OMNIMETER_TIMEZONE |
UTC |
Set this to where you actually live. IANA name, e.g. Europe/Amsterdam, America/New_York, Asia/Kolkata. Resolved once in src/localtime.py, the single place every other module gets it from — it decides where a day starts and ends, so it drives reading timestamps, daily-total/chart day boundaries, the Open-Meteo weather day, and the start date of any tariff whose document states no date of its own. Left unset it falls back to UTC with a warning in the logs, not silently — near midnight that's the wrong day, not a rounding error. An invalid name stops the app on startup rather than timestamping data in the wrong zone. The self-hosted setup wizard asks for this explicitly (also defaulting to UTC, so accepting the prompt without typing anything is still worth double-checking). |
OMNIMETER_WRITE_API_TOKEN |
(none — required) | Write-auth token, see the Write authentication section below. Auto-generated by the self-hosted setup wizard; a native systemd install can source it from any secrets store. |
OMNIMETER_P1_TOKEN / OMNIMETER_BATTERY_TOKEN / OMNIMETER_WATERMETER_TOKEN |
(none) | Per-device Bearer token env var names, as declared in each device's devices.json entry's token_env field — these three are devices.json.example's defaults for a fresh install. Any other name works too — it is config, not hardcoded in Python. Filled in by pairing (the setup wizard). |
OMNIMETER_BIND |
(set per install) | gunicorn bind address, e.g. 0.0.0.0:8000 to listen on every interface or <lan-ip>:8000 to pin it to one. Only used by a native systemd install's own start script; the Docker image always binds 0.0.0.0:8000 inside the container and publishes it via OMNIMETER_PORT instead (below). |
OMNIMETER_PORT |
8000 |
Self-hosted only — host port docker compose publishes the dashboard on. |
OMNIMETER_BACKUP_DIR |
./backups |
Nightly backup destination (scripts/backup.py, scripts/restore.py). The default is a bare-run fallback only — both deployment shapes set this explicitly instead: in docker-compose.yml it's fixed to /backups (a container-internal mount point, not user-edited directly), and a native systemd install sets it in its own backup service unit. |
OMNIMETER_BACKUP_HOST_DIR |
./backups |
Self-hosted only — where that /backups mount actually points to on your machine. This is the variable you actually set in .env. |
devices.json holds LAN IPs/serials, not secrets — a fresh install starts from devices.json.example
(the setup wizard fills it in for you — see Quick Start above).
Self-hosted: copy CSV exports into the bind-mounted dropzone directly:
cp <file>.csv ./data/imports/
The ingest container picks them up within 15 minutes, or force it immediately:
docker compose restart ingest
Native systemd install: copy directly to the dropzone:
cp <file>.csv /opt/omnimeter/data/imports/
omnimeter-ingest.timer picks them up within 15 minutes, or force it immediately:
systemctl start omnimeter-ingest.service
Re-exporting a wider date range is safe — files are re-ingested by content
hash, and rows are keyed by timestamp (INSERT OR REPLACE), so nothing
duplicates.
If your meter isn't a HomeWizard, this is the way in. Download a template:
curl -O -J "http://localhost:8000/api/import/meter-csv/template?category=power"
(category is one of power, gas, water, battery.) Fill it in and drop
it in the dropzone, or upload it under Import → CSV. The rules:
- Name the file
omnimeter-<category>-<anything>.csv. That prefix is what selects this format — without it the file is judged against the HomeWizard export format and rejected. - The header row uses OmniMeter's own column names (the template lists the valid ones for each category). Include only the columns your meter reports and leave the rest out entirely. A column name that isn't recognized is rejected, not ignored — so a typo is reported rather than silently discarding that column's data.
- Values are cumulative meter readings, not per-period usage. OmniMeter derives usage by differencing consecutive readings.
timeisYYYY-MM-DD HH:MM. Rows a day apart are stored as daily readings; closer together as 15-minute data. Daily-only meters are fine — everything except the few genuinely sub-daily features works normally.
No dual-tariff meter? Use import_combined_kwh / export_combined_kwh on
their own and omit the T1/T2 columns entirely.
If your meter serves its readings as JSON on your LAN — most do — you can poll
it directly instead of importing files, without any code being written for your
brand. Add an entry to devices.json with "protocol": "generic_json" and tell
OmniMeter what your vendor calls each value:
{
"p1": {
"protocol": "generic_json",
"url": "http://192.0.2.10/api/v1/data",
"token_env": null,
"field_map": {
"import_t1_kwh": "total_power_import_t1_kwh",
"import_t2_kwh": "total_power_import_t2_kwh",
"import_combined_kwh": "total_power_import_kwh",
"export_combined_kwh": "total_power_export_kwh",
"l1_max_w": "active_power_l1_w"
}
}
}- Left side = OmniMeter's canonical names (the same ones the CSV template
lists). Right side = whatever your meter's JSON calls them. Use dots for
nested values (
"data.power.import_kwh"). - Only map what you have. Anything you leave out is simply not recorded.
token_env: the name of an environment variable holding a bearer token, if your meter needs one. Usenullif it doesn't — most LAN meter APIs are unauthenticated.- If nothing maps, the poller reports a
field_mapping_errornaming the keys your meter did return, so you can correct the map — it will not write a row of empty values.
There is deliberately no auto-detection of vendor. Two brands can use the same key name for different quantities (cumulative vs. instantaneous, Wh vs. kWh), and guessing wrong would write plausible but wrong numbers instead of failing — the worst outcome for a meter-reading app. An explicit map can't be wrong silently.
Every state-changing request (/api/import/*, /api/settings/*, and any
future POST/PUT/PATCH/DELETE route by default — the check is on HTTP method, not a route allowlist)
must carry an X-OmniMeter-Write-Api-Token header matching OMNIMETER_WRITE_API_TOKEN. Without it: 401.
GET requests are unaffected.
The dashboard's own UI sends this automatically — index.html embeds the token as
window.OMNIMETER_WRITE_API_TOKEN on page load, and dashboard.js's writeHeaders() attaches it to
every write request. This isn't meant to stop a determined attacker already on the LAN (the token
is visible in the page source to anyone who loads the dashboard, same as any first-party page's
own client-side state) — it stops accidental or automated writes from other LAN devices that never
render the page as a browser. The app's LAN-only design remains the primary control; this raises
the bar above "any device can POST here with no interaction at all."
create_app() fails closed: if OMNIMETER_WRITE_API_TOKEN isn't in the environment at startup, it
raises rather than starting with unauthenticated writes. On a native systemd install, your own start wrapper
must put it in the environment before gunicorn starts; a Docker install gets it auto-generated
into .env by the setup wizard instead (see Quick Start above) — docker-compose.yml passes
.env straight into each container's environment.
omnimeter-api-ingest.service polls the P1 meter, Plug-in Battery, and Watermeter directly
over their own local API v2 endpoints (not a timer like omnimeter-ingest
— a long-running loop, ~20s interval, to actually deliver on "real-time"). Each device is a
separate integration: its own IP, own pairing, own Bearer token.
Why it writes granularity='api_live', not 'live': 'live' is the granularity of data from
a legacy live-data source that is no longer written, so nothing writes 'live' rows going
forward. api_live is left unranked rather than promoted, because it doesn't need to be —
aggregate.py's _GRANULARITY_RANK ({"live": 3, "15min": 2, "daily": 1}) ranks per date, and an
unranked granularity (rank 0) already wins whenever no higher-ranked row exists for that date.
'live' itself stays in the dict permanently: historical rows at that granularity may exist in an
older database and must keep outranking any CSV re-import for the same dates.
Setup: devices are set up one at a time, not all-or-nothing —
homewizard_api_client.is_configured() gates each device independently, so the service starts
and polls whatever's ready, skipping any device still on its "REPLACE_ME" placeholder (see
meter_ingest.ingest_all()'s 'skipped: device not configured' status), same as it
already tolerates a device with no token yet. docker compose run --rm setup
(see Quick Start above) does the pairing interactively, no bot/account needed.
Not every device has v2 yet: the P1 meter and Plug-in Battery both offer API v2, but some
Watermeter firmware versions only expose the older v1 local API in the app — a generic "Local
API" toggle, not "API v2 (experimental)". v1 is
plain HTTP with no auth and needs no pairing at all; DEVICES["watermeter"]["protocol"] = "v1"
routes it through fetch_measurement_v1() instead of the pairing/Bearer-token flow the other two
use. If a firmware update later adds v2 support, flip that field back to "v2" and pair it through
the normal flow (its ip/serial are already on file).
Battery energy counters: the Plug-in Battery's /api/measurement returns cumulative
energy_import_kwh/energy_export_kwh alongside state_of_charge_pct/power_w, and
meter_ingest._map_battery() maps them into battery_readings.import_kwh/export_kwh for
api_live rows. Gas is similarly available in real time: it isn't a separate HomeWizard device, but DSMR
smart meters relay the gas meter reading through the P1 telegram, which the P1 dongle's own
/api/measurement surfaces via an external: [{"type": "gas_meter", ...}] entry —
meter_ingest._map_gas() extracts it and writes gas_readings rows alongside each
successful P1 poll.
Device-specific gotchas (none of this is documented by HomeWizard, or the docs are actively misleading) — check any new device's live certificate before trusting a "standard" scheme:
- A
curl --resolve-based pairing script failed outright: HomeWizard's local-API identity string is shapedappliance/{product_type}/{serial}, which contains slashes and cannot be represented as a URL host or--resolvetoken at all (URLs use/as the path separator) — it silently tried to resolve the literal string"appliance"and failed. Fixed by using Python'surllib3.HTTPSConnectionPoolwithassert_hostname=, a pure string comparison against the cert, entirely decoupled from URL/DNS parsing (seehomewizard_api_client.py). - The P1 meter's certificate carries that identity in the Subject Common Name only, with no
SAN — modern TLS verification checks the SAN exclusively, so CN-fallback needs a caller-built
ssl.SSLContextwithhostname_checks_common_name=Trueexplicitly. The simplerca_certs=/cert_reqs=kwargs makeurllib3build its own context that hardcodes this toFalseregardless. - The Plug-in Battery's certificate is not the same scheme as the P1 meter's — same CN
pattern, but it also carries a DNS SAN (
{serial}.battery.device.homewizard.energy), and once a certificate carries any SAN it is matched against the SAN exclusively — CN-fallback doesn't apply once a SAN exists. There is no formula deriving the right identity fromproduct_type+serialacross HomeWizard's device types; each device's real certificate must be inspected (openssl s_client -connect {ip}:443 | openssl x509 -noout -text) and the correct value stored explicitly per device (devices.json'sverify_hostnamefield). The self-hosted setup wizard automates this inspection step for you (trust-on-first-use, same model as an SSH host-key prompt) — see Quick Start above.
By default OmniMeter estimates solar production from your kWp rating (optionally
weather-adjusted). If your inverter is a SolarEdge, it can read the real figure from
SolarEdge's cloud Monitoring API (V2) instead. The estimate stays as the fallback for days
without a measurement. Every figure is labelled measured or estimated, never blended
silently.
What it adds: measured production in Self-Sufficiency, the Overview and the energy-flow diagrams; battery-aware self-sufficiency; and a Solar tab (daily production, a 15-minute curve, and year-over-year history with a sun-adjusted index). This is the third feature that contacts the internet, and like the others it is off until you switch it on.
Setup (once):
- In the SolarEdge developer portal, create an application and note its OAuth client id and
secret; register
http://localhost:8888/callbackas its redirect URI. Put the id and secret in.envasSOLAREDGE_CLIENT_ID/SOLAREDGE_CLIENT_SECRET(see.env.example). - Authorise:
docker compose run --rm --service-ports solaredge-authorize, open the URL it prints, sign in as the site owner (not the developer login) and approve. If your browser is on a different machine, forward port 8888 to the Docker host first (ssh -L 8888:127.0.0.1:8888 <host>). The token pair is stored indata/solaredge_token.json(mode 600) and never printed. - Optional but recommended: pull the history already stored on SolarEdge's side with
docker compose run --rm solaredge-backfill --dry-run, then again without--dry-run. It uses about one API call per calendar month of history, paces itself, resumes safely, and refuses to start if it would exhaust the credit allowance. - Switch on SolarEdge production in Settings -> Feature toggles. The
solaredgeservice polls every 30 minutes (daylight only).
Things worth knowing:
- The access token lives 2 hours; the refresh token rotates on every refresh, so the token file is replaced atomically under a lock. Do not copy it between machines or restore an old copy -- the old refresh token is already dead and you would have to re-authorise.
- The vendor grant lasts at most 24 months. The days left are in the read-only
/api/solaredge/statusresponse and, with Telegram alerts configured, OmniMeter warns once about 30 days before it ends. A revoked or expired grant pauses polling and alerts once; run the authorise step again to resume. - API usage is metered in credits. The balance shown is an estimate from this app's own
request count (set
OMNIMETER_SOLAREDGE_CREDIT_LIMITandOMNIMETER_SOLAREDGE_CREDIT_RESET_DAYfrom your portal's usage page); the vendor does not count token refreshes. - Other layouts (for example no consumption meter) are handled in code and tests but have not been exercised against a live site.
- Without Docker, run
python -m scripts.solaredge_pollfrom any scheduler (cron, a systemd timer) every 30 minutes, with the credentials in the environment or inOMNIMETER_CREDENTIALS_DIRas files named after the variables.
PV kWp rating and the Costs rate schedule are entered via the Settings tab in the UI
(or POST /api/settings/pv / /api/settings/rates / /api/settings/gas-rates).
The Self-Sufficiency and Costs pages stay in an explicit "not available yet"
state until these are populated — no page silently shows a wrong number. Where an inverter
feed supplies measured production, Self-Sufficiency does not need the kWp rating at all; the
rating is then only the fallback for days without a measurement.
Solar production source. Every solar figure is labelled measured (read from the inverter),
estimated (modelled from the kWp rating), or partly estimated (a range that mixes both). The
API carries the basis per day (basis: measured | weather | seasonal) and a range-level
production_source. Today's measured figure is "so far" (flagged partial), not a projection.
Self-sufficiency accounts for the battery: stored energy is not counted as consumed, and battery
output counts as self-supplied only in proportion to how much of what charged it came from solar
(charging takes solar surplus first, the grid only for the remainder).
Bulk rate import, under Import → Tariffs, instead of typing periods in one at a time.
Upload a PDF and OmniMeter auto-detects the format — no supplier picker, and the result
names which one it recognized so you can see it isn't guessing. Nine formats are supported
today. Seven are downloadable documents, straight from each supplier's own website: both of
Vattenfall's (the personal, retrospective Tarievenspecificatie your own account portal
gives you, closed date ranges, and the public, open-ended Tarievenblad), and one public,
open-ended Modelcontract tariff sheet each from Greenchoice, Pure Energie, Innova
Energie, Mega Energie, and Clean Energy. Two — Eneco and Budget Thuis — are
live webpages rather than a document at all, so the input there is a PDF you create: open
the page and use your browser's Print → Save as PDF, then upload that. Eneco's page needs no
extra steps; Budget Thuis's does — accept its cookie banner and click open "Tarievenblad
Modelcontract voor onbepaalde tijd met variabele tarieven" before printing, or the saved PDF
captures the cookie overlay instead of the rate table. Neither states an effective date on the
page itself, so their imported rate period starts on the day you upload it rather than a date
the supplier claims — a stale, previously-saved copy uploaded later will misdate itself, so
save a fresh one; every other supplier's document states its own date instead. Any other
supplier: download the CSV template from the same tab (GET /api/import/tariff-csv/template)
and fill in your rate periods by hand from your bill — format is
category,period_start,period_end,rate (see parse_tariff_csv() in src/tariff_parser.py
for the full rules), or use the manual one-period-at-a-time form in Settings → Rates instead.
A document nothing recognizes is rejected outright with the list of supported formats, rather
than risking a partial or wrong parse.
Fiscal year start: fiscal_year_config (GET/POST /api/settings/fiscal-years)
controls what date range each tab's "1y" preset button resolves to — the configured
month/day through today, not a fixed 365-day trailing window. Defaults: Power and Gas
start 1 May (the Dutch utility billing year), Water starts 1 Jan (calendar year). Costs,
Overview, Battery, and Self-Sufficiency all use the Power boundary (see
FISCAL_CATEGORY_BY_TAB in dashboard.js), since they aren't billed on a cycle of
their own.
Feature toggles: feature_toggles (GET/POST /api/settings/toggles) is a
full-replace singleton row (like PV/fiscal-years above, not an append-a-row form) —
a POST sets every field, so a partial payload disables anything it omits. The Settings UI
form always submits all nine, matching native HTML checkbox semantics (a checked box is
included, unchecked is simply absent — both map correctly onto "full replace, missing means
off"). Toggles: import_power_enabled / import_gas_enabled / import_water_enabled
(gate CSV ingest per category — checked once, in ingest.ingest_file(), so both the web
upload route and the dropzone timer respect it), pdf_import_enabled (gates
/api/import/tariff-pdf), nightly_backup_enabled (gates omnimeter-backup.timer, see
Backups below), homewizard_api_enabled — gates omnimeter-api-ingest.service
(see above), checked once per poll loop iteration so toggling it off takes effect within one
cycle, not just on next restart — and three opt-in outbound-call switches, all off by
default: weather_enabled, update_check_enabled, and anomaly_alerts_enabled (leak
alerts, below). Every gate skips/rejects
cleanly rather than erroring: a disabled scheduled job exits 0 (so a scheduler doesn't
treat a deliberate pause as a failure), a disabled web upload route returns 403 with a clear
message.
Leak alerts: GET/POST /api/settings/anomaly (chat ID and thresholds; the
anomaly_alerts_enabled on/off switch itself lives in the feature-toggles form above) plus
POST /api/alerts/test. Detection is a fast-path rule set, not machine learning. A
continuous, unbroken run of water flow is described by three signals — how long it has run,
how much it has passed, and its mean rate over the trailing 5 minutes — and any one of three
gates fires it: ≥250 L in a single run (the backstop), 15 min at ≥10 L/min (a fixture
left running), or 5 min at ≥20 L/min (a burst). Three, rather than one number, because a
bath and a burst pipe have the same flow rate and are told apart only by the fact that a bath
stops.
A second detector runs alongside it on 15-minute buckets, because a run-based rule has a hard floor: a run only survives while every poll advances the meter, so a slow enough trickle resets it forever and no volume threshold, however low, can see it. That one fires on 2.5 h of continuous flow overnight or 2 h while the house is away.
A much lower "away" volume threshold (default 10 L) takes over automatically after several
consecutive near-zero gas+water days, unless an Occupancy entry (see above) says someone is
actually there — this is deliberately never inferred from consumption alone, since a leak is
consumption, and that would relax protection exactly when it should tighten. Delivery is two
channels: a
persistent in-app banner (reliable, no network needed, stays until acknowledged) and a
best-effort Telegram message (OMNIMETER_TELEGRAM_BOT_TOKEN in the environment,
OMNIMETER_TELEGRAM_CHAT_ID or the Settings field for the chat ID) that retries the next poll
if delivery fails, on an escalating repeat schedule (immediate, then 15/30/60 min) until
acknowledged. The overnight background-leak alert is the one exception: it sends once and
then stays quiet, since it is something to look at in the morning rather than a reason to
nag through the night — its banner is still persistent. Acknowledging a false alarm
specifically as "not a leak" nudges the gate that fired it past the value that fired it, so
the same false positive won't repeat without loosening the other gates.
Health/staleness alerts: the same anomaly_alerts_enabled toggle and delivery channels
also cover a second, independent check (src/health_monitor.py, run hourly by
omnimeter-health-check.timer / the health-check container): whether each metric's live
data is still arriving at all. A metric that has never produced live data (no device
configured for it) is never checked; one that has and then goes quiet for 90+ minutes fires
an alert, catching a stalled poller or an unreachable device independently of the poller
itself. Scoped to the live-polling path specifically — a metric fed only by the 15-minute CSV
dropzone will read as "stale" between drops, since this isn't meant to replace the pull-based
/api/data-health gap report for that use case.
Anomaly digest (context-aware model): a third, independent check
(src/anomaly_model.py, run nightly by omnimeter-anomaly.timer / the anomaly
container), sharing the same toggle but a different delivery style — this one is a
digest, never the persistent banner the two alerts above use. Each metric
(power/gas/water) gets its own ridge-regression model of what a "normal" day looks
like given that day's calendar position, weather, occupancy, and (for power) EV
charging — so a cold, fully-occupied winter day is judged against a cold-and-occupied
baseline, not a flat year-round average. A day is flagged only if it exceeds that
household's own 99th-percentile historical deviation; anything flagged sends one
Telegram summary and is recorded for later review, but never blocks the dashboard.
Needs 90+ days of settled history per metric before it starts training at all, and
gracefully does nothing until then. GET /api/anomaly/models reports each metric's
training status (last trained, rows used, current threshold) for anyone who wants to
check on it without a database client.
omnimeter-backup.timer runs nightly (03:30 UTC), backing up omnimeter.db via SQLite's
online backup API to the configured backup destination (30-day retention) — the CSV exports
are re-exportable from the HomeWizard app, but any data from the legacy
live-data source, the real-time api_live history, the rate schedules, and pv_config
are not recoverable from anywhere else. On a native systemd install the backup destination must
be present when the timer runs. The timer can be paused
from the Settings tab (nightly_backup_enabled) — e.g. during a schema migration, or while the
backup destination is intentionally unavailable, so the timer doesn't fail loudly every
night in the meantime.
scripts/restore.py is the tested counterpart to backup.py — until it existed,
restore had never been exercised even in principle. It only handles the DB-file-level restore;
it does not stop or start anything itself — stop the write paths yourself first.
Self-hosted:
docker compose stop web ingest api-ingest
docker compose run --rm web python -m scripts.restore --list
docker compose run --rm web python -m scripts.restore --latest
docker compose start web ingest api-ingest
Native systemd install:
systemctl stop omnimeter-web.service omnimeter-ingest.timer omnimeter-api-ingest.service
cd /opt/omnimeter && .venv/bin/python -m scripts.restore --list
cd /opt/omnimeter && .venv/bin/python -m scripts.restore --latest
systemctl start omnimeter-web.service omnimeter-ingest.timer omnimeter-api-ingest.service
--latest restores the newest backup; --file <name> restores a specific one from the --list
output; --yes skips the interactive confirmation prompt (useful for scripting, not recommended
for a manual recovery).
Test-only dependencies (pytest and friends) live in requirements-dev.txt, separate from the
production requirements.txt gunicorn actually needs:
cd /opt/omnimeter
.venv/bin/pip install -r requirements-dev.txt # once
.venv/bin/python -m pytest tests/ -v
Config in pyproject.toml (baseline mypy, not strict mode — this codebase was never type-checked
before a code review added the config):
.venv/bin/python -m ruff check .
.venv/bin/python -m mypy src/ scripts/ wsgi.py
The running version is shown in the page footer and at GET /api/version, sourced from the single
constant in src/__version__.py. To cut a release: bump that constant, commit it alongside the
change it describes, then tag and publish — never bump after tagging, or the two drift:
git tag vX.Y.Z
gh release create vX.Y.Z --title "vX.Y.Z" --notes "..."
Docker Compose (Quick Start, above) is the supported way to run OmniMeter. This section is not a second supported install path — it describes how the app can run under systemd for readers who prefer it to Docker and are willing to assemble it themselves. The unit files it relies on are not in this repository, and nothing here is packaged or tested for it. Use the Quick Start unless you actively want to do this work yourself.
A native install runs via systemd, no Docker involved — a plain venv,
your own small start wrappers as the systemd unit entry points, and the services listed under
Layout above, each with its own unit file. requirements.txt changes are not auto-installed
on this path — run .venv/bin/pip install -r requirements.txt manually after a dependency change.
Device pairing here is done by running the setup wizard (scripts/setup_wizard.py) directly,
rather than through Docker Compose. devices.json is the single source of truth for
IPs/serials/protocol either way.