- Jinja 80.7%
- Just 19.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| automations | ||
| blueprints | ||
| custom_templates | ||
| dashboards | ||
| docs | ||
| helpers | ||
| sensors | ||
| templates | ||
| .gitignore | ||
| .sops.yaml | ||
| CLAUDE.md | ||
| configuration.yaml | ||
| customize.yaml | ||
| justfile | ||
| README.md | ||
| secrets.sops.yaml | ||
Home Assistant
Contains custom stuff for HASS.
Accurate appliance running cost
Price-weighted accumulated cost for a switched appliance on a metered plug.
Each moment's cost uses the all-in price (sensor.nordpool_price_with_tariffs
= spot + energiskatt + elöverföring + markup, ×1.25 VAT) in effect then, so
daily/monthly totals reflect when the appliance actually ran — unlike
total_kWh × current_price, which drifts with the current hour.
All costs are consolidated on the Energy Costs dashboard view, grouped by
category. Most loads live in templates/energy_costs.yaml +
sensors/energy_costs.yaml; dehumidifier and hot water keep their pipeline in
their own domain files but feed the same view. Category tables are rendered by
the cost_rows macro (also in custom_templates/energy_cost.jinja), which
takes [(label, daily_entity, monthly_entity), …], sorts by today's cost, and
appends a bold Subtotal row.
To add another appliance with a sensor.<x>_power sensor, replicate these
three primitives (then add a row to the relevant category card, and
just restart — new platform sensors don't load on reload_all):
- Cost-rate template (
templates/energy_costs.yaml) — SEK/h, via the shared macro incustom_templates/energy_cost.jinja(auto-normalises W vs kW):- name: "<X> cost rate" unique_id: <x>_cost_rate unit_of_measurement: SEK/h state_class: measurement state: >- {% from 'energy_cost.jinja' import cost_rate %} {{ cost_rate('sensor.<x>_power') }} - Riemann integration (
sensors/…) — accumulated SEK:- platform: integration name: "<X> cost" unique_id: <x>_cost source: sensor.<x>_cost_rate unit_time: h method: trapezoidal max_sub_interval: { minutes: 5 } - Utility meters (
helpers/utility_meter.yaml) —<x>_cost_daily/_monthly,source: sensor.<x>_cost.
Then read sensor.<x>_cost_daily / _monthly on the dashboard.
When the plug's power channel lies
Some Zigbee metering plugs emit spurious spikes on their power channel while
their energy accumulator stays clean. Billing the power channel turns those
phantom watts into real SEK, and the error is invisible because the kWh figures
(fed from the energy counter via the <x>_energy_daily utility meters) stay
correct while the SEK figures drift upward.
Check for it by comparing the two: time-weighted mean power × 24 h should match the energy counter's daily change. If it doesn't, clamp the power channel rather than replacing it — these spikes are rare and strictly upward, so a bound removes them without touching normal operation:
- platform: filter
name: "<X> power filtered"
unique_id: <x>_power_filtered
entity_id: sensor.<x>_power
filters:
- filter: range
upper_bound: 600 # ~1.5x nameplate
then point step 1's cost_rate() at sensor.<x>_power_filtered. The range
filter clamps to the bound rather than dropping the sample — keep it that way,
since dropping biases the mean downward. sensor.dehumidifier_cost is wired
this way.
Do not differentiate a coarse energy counter
The obvious alternative — a derivative sensor on the kWh counter, on the
grounds that the accumulator is glitch-free — is a trap, and this repo shipped
it for three weeks. It is only sound when the counter advances more often
than max_sub_interval.
sensor.dehumidifier_cost was wired that way until 2026-08-19. Measured over
2026-08-09 → 08-19 with the unit running continuously: the plug's counter moves
in 0.10 kWh quanta a median 921 s apart (at ~350 W, a 0.10 kWh step takes
~15 min to accumulate), against max_sub_interval: 5 min. All 775 steps
arrived late, so the derivative was dragged to zero between every pair of them
— reading exactly 0.000 for 71.7% of wall-clock time, averaging 102 W
against a true 349 W, and accumulating 32.41 SEK for a window whose true
price-weighted cost was 168.19 SEK. A 5.2x under-bill.
The failure is silent in exactly the direction that matters: max_sub_interval
is there to stop a stale rate billing forever after shutoff, but set below the
counter's own step interval it cannot distinguish "off" from "hasn't ticked
yet", and bills a running appliance as idle. The kWh figures stay correct
throughout — only the SEK sag — so the dashboard looks healthy.
If you ever do need to differentiate a counter, check the step interval first
(p90 of the gaps, not the median) and set max_sub_interval comfortably
above it.
Custom Jinja macros live in custom_templates/ — HA does not reload them
with reload_all; just reload/deploy calls reload_custom_templates
first. Adding/removing platform: sensors (integration, statistics,
history_stats) requires a full restart — use just restart (or just redeploy
= sync + restart), not just deploy.
Secrets
Encrypted at rest with sops/age, using the same two recipients as the infra repo — one operational key on nikki, one disaster-recovery key kept offline. Deliberately the same scheme: a second key-management story for one file is a second thing to get wrong on the day something has to be rotated in a hurry.
secrets.sops.yaml is committed. YAML mode, so keys stay cleartext and only
values are encrypted — the file is its own schema, and a rotation is a legible
diff without decrypting anything.
just edit-secrets # opens decrypted in $EDITOR, re-encrypts on save
Home Assistant cannot read it. HA has no notion of sops; it reads
secrets.yaml as plaintext when it loads its config. So just sync decrypts on
nikki and streams the result to the box:
nikki homeassistant
───── ─────────────
secrets.sops.yaml ──sops -d──► (pipe) ──ssh──► /homeassistant/secrets.yaml (0600)
The plaintext never touches nikki's disk, and it is on stdin rather than in argv
so it reaches neither machine's ps. This is the shape infra already uses, where
kate holds no key and only ever receives already-decrypted secrets.
It prints what it did, and it is the first thing sync runs:
secrets: 1 key(s) → root@homeassistant.home.arpa:/homeassistant/secrets.yaml
The decrypt is checked before the remote file is opened, and that is not
fussiness. sops -d … | ssh … "cat > secrets.yaml" reads better and silently
destroys the file it is meant to write: a pipeline exits with the status of its
last command, so a failed decrypt still truncates secrets.yaml to nothing
and reports success. Measured, not feared. So the value is decrypted in full and
checked for at least one key first, then written to a temp file on the box and
mvd into place, so an interrupted transfer cannot leave a half-written secrets
file for HA to load.
What it buys is a repo that holds ciphertext. What it does not buy is a box
with no plaintext on it — HA needs the cleartext to start, and that is the end of
it. secrets.yaml stays gitignored; the copy on the box is the only one.
Decryption needs the age key, so deploys run from nikki. Anywhere else the decrypt fails, the recipe refuses, and the box keeps the secrets.yaml it already had. Editing that file directly on the box is pointless — the next sync overwrites it.