Home Assistant for Meatbags
  • Jinja 67%
  • Shell 19.7%
  • Just 13.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Viktor Varland 7f9888dca9
All checks were successful
ci/woodpecker/push/check Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
chore: commit and deploy wip changes
2026-10-19 14:32:32 +02:00
.woodpecker ci: deploy main to the box from woodpecker 2026-10-19 07:20:10 +00:00
automations chore: commit new plugs and basement templates 2026-08-22 10:56:56 +02:00
blueprints
custom_templates chore: commit and deploy wip changes 2026-10-19 14:32:32 +02:00
dashboards chore: commit and deploy wip changes 2026-10-19 14:32:32 +02:00
docs chore: commit pending work 2026-08-31 13:01:37 +02:00
helpers chore: commit and deploy wip changes 2026-10-19 14:32:32 +02:00
scripts ci: deploy main to the box from woodpecker 2026-10-19 07:20:10 +00:00
sensors chore: commit pending work 2026-08-31 13:01:37 +02:00
templates chore: commit and deploy wip changes 2026-10-19 14:32:32 +02:00
.gitignore
.sops.yaml feat: secrets encrypted at rest with sops/age 2026-08-11 11:19:30 +02:00
CLAUDE.md
configuration.yaml feat: seasons detected from the weather, not flipped by hand 2026-08-11 21:00:09 +02:00
customize.yaml
justfile ci: deploy main to the box from woodpecker 2026-10-19 07:20:10 +00:00
README.md ci: deploy main to the box from woodpecker 2026-10-19 07:20:10 +00:00
secrets.sops.yaml feat: secrets encrypted at rest with sops/age 2026-08-11 11:19:30 +02:00

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):

  1. Cost-rate template (templates/energy_costs.yaml) — SEK/h, via the shared macro in custom_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') }}
    
  2. 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 }
    
  3. 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.

Deploying

main is what runs. Woodpecker deploys every push to main; nothing reaches the box any other way, short of a forced hand deploy.

  • .woodpecker/check.yml runs HA's own check_config --fail-on-warnings on every push and PR, in the HA image it pins. A stand-in secrets.yaml is built from the key names in secrets.sops.yaml, so the check needs no key and runs for forks too. just check-local runs the same check here.
  • .woodpecker/deploy.yml runs scripts/sync-box once the check passes. So does just deploy -- one script, so a hand deploy and CI cannot drift apart.

scripts/sync-box, in order:

  1. Refuses if the box runs a different HA than check.yml pins: the check vouched for that version and no other. When HA updates, bump the pin.
  2. Refuses on drift. The box records the commit it was given in .deployed-rev; if its files no longer match that commit, someone edited the box, and rsync --delete would erase it. Bring the edit into git, or look at just diff-box and deploy once with FORCE=1.
  3. Refuses a commit that does not descend from the deployed one, so an old build re-run cannot roll the box back. Roll back with a revert on main.
  4. Syncs, then runs ha core check on the box with the real secrets. If the box rejects it, the last deployed tree goes back.
  5. Restarts if sensors/, helpers/utility_meter.yaml or configuration.yaml changed (platform sensors and utility meters do not reload), and reloads otherwise.

themes/ and secrets.yaml live only on the box and are never synced. The first deploy needs FORCE=1: the box has no .deployed-rev yet.

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 push-secrets 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. It runs on its own, not as part of a deploy:

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 secrets are pushed from nikki, by hand. Deploys never touch secrets.yaml, which is what lets CI deploy without the key. 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 push overwrites it.