Skip to content

Repository files navigation

ecobee-runtime-importer

Imports ecobee's runtimeReport history into VictoriaMetrics: 5-minute buckets of zone temperature, humidity, setpoints, occupancy, outdoor conditions, per-room sensor readings, and equipment runtime in seconds — the duty-cycle measurement that live polling cannot reconstruct.

Works without an ecobee developer API key, which matters because ecobee no longer issues them. It authenticates the way Home Assistant does as of 2026.3.

Design rationale, failure modes and the traps in ecobee's data are in ARCHITECTURE.md. Read §3.2 before touching transform.py.


What you get

Metric
ecobee_zone_temperature_fahrenheit per thermostat — the zone average, not one sensor
ecobee_zone_humidity_percent per thermostat (not per room — see below)
ecobee_zone_heat_setpoint_fahrenheit / ..._cool_... per thermostat
ecobee_zone_occupancy per thermostat
ecobee_outdoor_temperature_fahrenheit / ..._humidity_percent per thermostat
ecobee_equipment_runtime_seconds{equipment} seconds per 5-min bucket
ecobee_zone_climate_info{climate} / ecobee_zone_hvac_mode_info{hvac_mode} value 1
ecobee_sensor_temperature_fahrenheit{sensor} per remote sensor
ecobee_sensor_occupancy{sensor} per remote sensor
ecobee_sensor_humidity_percent{sensor} the thermostats' own humidity sensors
ecobee_sensor_contact{sensor} door/window sensors — 1 open, 0 closed
ecobee_sensor_value{sensor,sensor_type} fallback for any type not listed above

Duty cycle is ecobee_equipment_runtime_seconds / 300. It is a gauge of seconds-per-bucket, not a counter — do not wrap it in rate().

Each bucket is written once. Every cycle re-requests the overlap window, so buckets already imported are offered again — with a 60-minute overlap and a 15-minute interval, four times each. Only values that are new or changed are sent, so late-arriving and revised data still lands while identical re-writes do not. ecobee_samples_skipped_total counts what was suppressed.

The one exception is a restart, which re-imports its lookback window with an empty cache and so re-writes those buckets. The cache is deliberately in memory: persisting it would reintroduce the state-corruption risk that keeping the watermark in memory exists to avoid, and restarts are rare.

That matters because VictoriaMetrics stores duplicate samples unless deduplication is configured, and raw-sample functions then over-count. If your history predates this, or spans restarts, prefer the subquery form:

sum(sum_over_time(last_over_time(ecobee_equipment_runtime_seconds[5m])[24h:5m])) by (thermostat) / 3600

last_over_time, max_over_time and min_over_time are unaffected either way, because duplicates carry identical values.

Door and window SmartSensors are included. They arrive as sensorType: dryContact in the sensor report, with the same 5-minute history as everything else. This contradicts the widely repeated claim that they are invisible to the ecobee API — that claim is true of GET /1/thermostat, which is where people look, but not of runtimeReport.

ecobee_zone_temperature_fahrenheit is an average, across whichever sensors participate in the zone — it comes from zoneAveTemp. Measured 0.3–1.2 °F from the thermostat's own reading. Use it to compare against setpoints, since it is what the thermostat controls against; use ecobee_sensor_temperature_fahrenheit{sensor_id="ei:0:1"} when you want the temperature at the thermostat itself. Pairing the zone average with ecobee_zone_humidity_percent — which is measured at a single point — is the common way to get a subtly wrong dewpoint. ARCHITECTURE.md §3.3 has the detail.

There is no per-room humidity. Room SmartSensors measure temperature and occupancy; humidity is measured only at the thermostats themselves. Room-level dewpoint analysis is limited to those locations.


Setup

Four steps: clone, bootstrap a token, create the Secret, deploy with Helm.

The clone and the make targets exist for one reason: the credential Secret is not managed by Helm. The importer rotates the refresh token in place, so a Helm-templated Secret would be reset on every upgrade. Minting and replacing that token is a local, interactive job — that is what the Makefile automates. Deployment itself is plain helm.

git clone https://github.com/scottrus/ecobee-runtime-importer.git && cd ecobee-runtime-importer

1. Get a refresh token (once, interactively)

Needs your ecobee login and, if you have TOTP MFA enabled, your authenticator app. This step cannot be automated — that is why it is a script you run rather than a job that runs.

make bootstrap

This builds the venv first (uv if installed, stdlib venv otherwise), so it works on a machine with neither. On Debian and Ubuntu the fallback needs python3-venv (apt install python3-venv) if python3 -m venv reports that ensurepip is unavailable.

It prompts for your email, then your password (not echoed), then a 6-digit code if your account has TOTP MFA. Push, SMS and email MFA are not supported — only authenticator-app codes.

It writes ./credentials.json at mode 0600 and stops there. The token stays out of your scrollback, and the file is gitignored. (--out PATH moves it; --print puts it on stdout instead, which you rarely want.)

Your password is used only to complete this login. It is never stored and never reaches the running importer.

Put the refresh_token in your password manager — this is your only copy until the Secret exists:

.venv/bin/python -c 'import json;print(json.load(open("credentials.json"))["refresh_token"])'

2. Create the namespace and Secret

make secret

That creates the namespace, then creates the Secret by reading the token straight out of the file bootstrap wrote. It is safe to re-run: it replaces an existing Secret rather than failing, so the same command serves first install and recovery. The Secret is created out-of-band rather than applied from the repo because the importer rotates it in place, so a committed copy would go stale immediately.

Then delete the local copy — the cluster's is authoritative from here:

rm credentials.json

This Secret becomes mutable state. Auth0 may rotate the refresh token, and the importer patches the new value back into this Secret. After the first rotation the cluster's copy is authoritative. Re-applying the vault copy later will lock the account out and force you back to step 1. Back it up by reading it out of the cluster.

3. Deploy

Nothing to build. Every tagged release publishes a multi-arch image and a versioned Helm chart to GHCR, with an SBOM and a signed provenance attestation.

helm upgrade --install ecobee-runtime-importer \
  oci://ghcr.io/scottrus/charts/ecobee-runtime-importer \
  --version <version> \        # see Releases for the current one
  --namespace ecobee-runtime-importer --create-namespace \
  --set fullnameOverride=ecobee-runtime-importer \
  --set victoriaMetrics.url=http://vmsingle-<release>.<namespace>.svc.cluster.local:8428/api/v1/import/prometheus

Pin an explicit --version from the Releases page rather than taking whatever is newest — an unpinned upgrade is not something you want happening by surprise to a workload that holds a rotating credential.

victoriaMetrics.url is the one value you must set. The chart's default is a placeholder, and a bare service name will not resolve from another namespace. Everything else has a working default — see Configuration.

Keep fullnameOverride. Without it Helm prefixes the release name onto every resource, which changes the job label and orphans existing metric history.

For anything beyond a couple of overrides, use a values file:

helm upgrade --install ecobee-runtime-importer oci://ghcr.io/scottrus/charts/ecobee-runtime-importer --version <version> -n ecobee-runtime-importer -f my-values.yaml
Deploying the chart from a clone instead

make deploy runs helm upgrade --install against charts/ in the working tree, with the namespace and fullnameOverride filled in. That is for developing the chart; released deployments should name a published version so what ran is recoverable from the tag.

make deploy HELM_ARGS='--set victoriaMetrics.url=http://...'

4. Confirm it works

kubectl logs -n ecobee-runtime-importer deploy/ecobee-runtime-importer -f

A healthy first cycle looks like this:

Loaded credentials: Tokens(refresh=set, access=empty, api_key=unset)
No access token loaded; refreshing before first request
Persisted rotated tokens to Secret ...
Thermostats: Upstairs (America/New_York), Main Floor (...), Basement (...), Suite (...)
Fetching runtimeReport ... for 4 thermostat(s)
Wrote 30712 samples to http://.../api/v1/import/prometheus
Imported 30712 samples, newest bucket ...

access=empty on the first line is expected: the Secret holds only the refresh token, so the first act is a refresh, and the rotated pair is written straight back. The startup lookback pulls 24 hours, so the first count is in the tens of thousands; later cycles are a few hundred.

Then in VictoriaMetrics:

count_over_time(ecobee_zone_temperature_fahrenheit[24h])

Development

Every CI check is a make target, so make check locally is the same gate a pull request faces — there is no second copy of the commands to drift.

make check

Targets that need a tool you do not have are skipped with a SKIP: line. CI sets REQUIRE_ALL=1, which turns each skip into a failure, so a check can never appear to have run when it did not. make help lists everything.

make setup && .venv/bin/pytest

Releasing

No pull request declares a version. The git tag is the only place a version exists:

  • the package version comes from setuptools-scm, which reads the tag — local trees get 0.1.5.dev3+g9e3b566, release builds get the tag exactly;
  • the chart version and appVersion are injected at package time (helm package --version "$TAG" --app-version "$TAG"), so Chart.yaml holds 0.0.0 placeholders forever;
  • the image tag follows appVersion.

So a fix PR contains only the fix. Releasing is:

git tag vX.Y.Z && git push origin vX.Y.Z

The workflow refuses the tag unless that exact commit is on main and CI passed on it — branch protection is what stands behind every release. It then builds linux/amd64,linux/arm64, publishes the image and chart to GHCR, attests the build, and opens a GitHub release.

Wait for CI to go green on main before tagging: the gate cannot tell "CI is still running" from "CI failed", so tagging too early fails the release. Re-run it once CI finishes — no re-tag needed, since the commit has not changed.

Configuration

Everything lives in the chart's values.yaml. Nothing has to agree with anything else by hand — the values that used to be duplicated are now rendered from one place each:

Value Drives
credentials.existingSecret the env var and the Role's resourceNames
service.port containerPort, the Service, and ECOBEE_METRICS_PORT
.Release.Namespace every resource, via --namespace
.Chart.AppVersion the image tag, injected from the git tag at release

A values change rolls the pods automatically: the pod template carries a checksum/config annotation over the rendered ConfigMap, so an edit changes the pod spec. That matters because the process reads its environment once at startup.

Key Default Notes
victoriaMetrics.url placeholder Set this. Needs an FQDN cross-namespace
victoriaMetrics.authHeaderFile — Path to a mounted file; never put the value in values
credentials.existingSecret ecobee-importer-tokens Created out of band; never templated
credentials.namespace — Only to read a Secret elsewhere; needs a ClusterRole
importIntervalSeconds 900 Hard floor 900. Lower values are clamped
startupLookbackHours 24 Re-imported on every restart; capped at 31 days
overlapMinutes 60 Re-request recent buckets to pick up late data
collection.includeSensors true Per-remote-sensor history
collection.extraColumns / .columns — Add to / replace the default column set
collection.extraLabels {} Static labels on every sample
service.port 9863
vmServiceScrape.enabled / vmRule.enabled true Set false without the VM operator
logLevel INFO

Deploying to a different namespace

Pass --namespace. Helm namespaces the whole release, so nothing in the chart needs editing, and the importer resolves its Secret's namespace from the pod at runtime.

helm upgrade --install ecobee-runtime-importer oci://ghcr.io/scottrus/charts/ecobee-runtime-importer --version <version> --namespace my-namespace --create-namespace --set fullnameOverride=ecobee-runtime-importer

make secret NAMESPACE=my-namespace puts the credential in the same place.

One caveat outside this repo's control: VMServiceScrape and VMRule are only discovered from an arbitrary namespace if your VMAgent and VMAlert run selectAllByDefault: true, or you add matching namespace selectors.

The Secret is never templated

The chart will not create the credential Secret, and make helm fails if a template ever renders one. The importer writes that Secret — Auth0 rotates the refresh token and the new value is patched back — so a Helm-managed copy would be reset on every helm upgrade, presenting a revoked token and requiring a fresh interactive login.

Not abusing the API

ecobee's documentation is explicit: "DO NOT request report data at an interval quicker than once every 15 minutes."

Steady state, for the whole household:

Call Frequency Per day
runtimeReport (all thermostats, one request) 900s 96
thermostat (names, time zones) hourly 24
token refresh on expiry ~24

~144 requests/day. The interval is clamped in code, all thermostats share one request, and the loop never crashes — so a restart storm cannot bypass the interval. ecobee_api_requests_total is the audit trail if you want to check rather than trust.


Recovering a gap

Outages shorter than ECOBEE_STARTUP_LOOKBACK_HOURS heal themselves on the next cycle; nothing to do. Longer ones, up to the API's 31-day limit:

kubectl exec -n ecobee-runtime-importer deploy/ecobee-runtime-importer -- ecobee-runtime-importer --backfill-from 2026-08-01

Re-importing data you already have is harmless — same timestamp, same value.


Re-authenticating

When ecobee_reauth_required is 1 — or the log says "Re-authentication required" — the refresh token has been rejected by ecobee and no amount of waiting fixes it. This is the one failure that needs a human with an authenticator app.

make reauth

That is bootstrap + secret + restart: log in, replace the Secret, roll the pod. Then clean up and watch it recover:

rm credentials.json && kubectl logs -n ecobee-runtime-importer deploy/ecobee-runtime-importer -f

The three steps are each available on their own:

  • make secret replaces an existing Secret. Plain kubectl create secret fails once one exists, so the setup command is the wrong one for recovery.
  • make restart is no longer required — the importer re-reads the credential store after a rejected token, so correcting the Secret is enough on its own and it recovers within one cycle. make reauth still restarts, because waiting up to 15 minutes to find out whether you fixed it is a poor way to spend an incident.

The log says which case you are in:

Credential store now holds a different token; retrying on the next cycle.
The credential store still holds the token that was just rejected.

The second means the Secret was never actually updated — check that make secret ran against the namespace the importer is deployed in.

No history is lost. runtimeReport serves up to 31 days retroactively, so the startup lookback plus a --backfill-from recovers everything the outage covered.

Each login may invalidate the previous one. If ecobee's Auth0 tenant issues one refresh token per user per client, re-running bootstrap anywhere kills the token your cluster is using. Mint once, put it straight into the Secret, and don't run bootstrap on a second machine "to check" — that is itself a way to cause this failure.


Troubleshooting

ecobee_reauth_required 1 — see Re-authenticating above.

Timeout writing to VictoriaMetrics, while ecobee calls succeed — the destination's network policy does not list the importer as a client. A CiliumNetworkPolicy or NetworkPolicy that selects the metrics backend denies everything not explicitly allowed, and a denied connection times out rather than being refused, which reads like DNS or a wrong URL. Cross-namespace rules need the namespace label explicitly — a bare matchLabels matches only the policy's own namespace. Nothing is lost meanwhile: the watermark advances only after a successful write, so the backlog imports once the policy is fixed.

ZoneInfoNotFoundError / No module named 'tzdata' — the image has no tz database. tzdata is a hard dependency for this reason; if you built your own image, keep it.

Everything is off by several hours — a time zone bug. Report rows arrive in thermostat local time, not UTC (ARCHITECTURE.md §3.2). Check the thermostat's location.timeZone in the startup log. Note the importer now fails rather than falling back to UTC, precisely so this cannot happen silently.

Temperatures read like 7.37 — something reintroduced a ÷10. runtimeReport returns decimal degrees (73.7) and must not be scaled; beestat's /10, the usual reference, does not apply to this endpoint.

A column is missing from VictoriaMetrics — unmapped columns are skipped with an INFO log rather than exported with a guessed unit. Add it to transform.py once you have verified what its units actually are.

Login fails during bootstrap — ecobee may have changed its Auth0 forms. Upgrade python-ecobee-api; Home Assistant depends on the same library, so fixes land upstream quickly. Already-issued tokens keep working meanwhile.


License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages