The household stack
Home Assistant, Grocy, Paperless and five email robots, wired so the house prices its own power, gas, water and petrol, tracks the pantry and files paperwork.
- Automations
- 183
- Documents filed
- ~2,750
- Robots on cron
- 5
- Max open questions
- 3
How it fits together
None of these pieces is unusual on its own. Home Assistant, Grocy and Paperless-ngx are free and well documented, and the rest is a few scheduled jobs and small scripts. What makes them worth writing up is the hand-offs: a bill that files itself and lands on the calendar, a shopping docket that becomes stock, a drive that becomes a fuel cost, and one place where any decision that needs me arrives as a yes or a no.
A robot reads the amount and the debit date, puts both on the calendar and in the next morning's digest, and the PDF files itself in Paperless.
Home Assistant is the hub, with about 390 devices and 183 automations, and I change it through a small helper that backs up before every write. It has its own write-up on the home automation page. The one local language model that several of these systems lean on is on the local LLM page, and the cluster it all runs on is on the Proxmox page.
Power and climate
The electricity plan has free power from 12:00 to 15:00, for the first 24 kWh a day, so the house is built around those three hours. The battery grid-charges from 12:00 to 16:00, through the free window and one cheap shoulder hour, and a few flexible loads run then too. At 11:50 the forecast decides whether the air-con pre-cools, pre-heats or holds.
- kWh a day imported in the peak window, as free-window charging ramped up
- 13.4 → 8.0
- of peak-rate cooling on the first 34 °C day, about 35c
- 0.59 kWh
- how far the air-con's own sensor misread the room on 40 °C days
- up to 6 °C
That last number is why the controller no longer trusts the air-con. It runs from the average of two sensors in the room that matters and asks the unit for a degree past the point where it should stop. Blinds stay shut on days forecast at 27 °C or more, and when someone changes the air-con from a dashboard, the automation backs off for two hours instead of fighting them.
# 10 minutes before the free window: let the forecast decide.
# No forecast falls through to "hold".
alias: Climate - free-window plan from the forecast
triggers:
- { trigger: time, at: "11:50:00", id: plan }
- { trigger: time, at: "15:00:00", id: reset }
actions:
- if: "{{ trigger.id == 'reset' }}"
then:
- action: input_select.select_option
target: { entity_id: input_select.free_window_plan }
data: { option: hold }
- stop: window over
- action: weather.get_forecasts
target: { entity_id: weather.home }
data: { type: daily }
response_variable: fc
continue_on_error: true
- variables:
days: "{{ fc['weather.home'].forecast if fc is defined and 'weather.home' in fc else [] }}"
hi_today: "{{ days[0].temperature if days | count > 0 else none }}"
# tonight's low is TOMORROW's templow
low_tonight: "{{ days[1].templow if days | count > 1 else none }}"
hot: "{{ hi_today is not none and hi_today | float >= states('input_number.precool_high') | float }}"
cold: "{{ low_tonight is not none and low_tonight | float <= states('input_number.preheat_low') | float }}"
# a hot day AND a cold night = hold: banked cool would just be reheated
choice: "{{ 'hold' if (hot and cold) or not (hot or cold) else ('pre-cool' if hot else 'pre-heat') }}"
- action: input_select.select_option
target: { entity_id: input_select.free_window_plan }
data: { option: "{{ choice }}" }
- action: logbook.log
data:
name: Climate
message: "Plan = {{ choice }} (high {{ hi_today }}, low tonight {{ low_tonight }})"What a day costs
Electricity, gas and water are metered in Home Assistant and priced the way the bills price them, including the daily supply charges, so every day has a dollar figure. Given the volumes on a real bill, the pricing model reproduces the electricity, gas and water bills to the cent. Home Assistant’s own live readings are close, but not yet bill-exact.
- power
$7.20a day, 20-month average
Clamp meter corrected to the revenue meter. Time-of-use, free at midday since July 2026, supply charge included.
- gas
$3.40a winter day
A reed switch on the meter's dial. Declining-block tariff, supply charge included.
- water
$3.50–5a day, about $2 of it fixed
A pulse counter on the meter. Three-tier inclining tariff, priced over the billing period.
- petrol
$4.50–5a typical commute leg
Fuel worked out from the engine computer, priced at what the fuel in the tank cost.
Rounded from real averages. Each figure has its own meter and tariff; there is no combined total yet.
The ground truth is the network’s own revenue meter. The distributor hands out its five-minute interval data for free, and comparing 261 days of it with Home Assistant showed the switchboard clamp reading about 10% low in both directions, even at night. The correction now sits upstream of Home Assistant’s tariff buckets, Grafana applies the same factor when it reads the raw history, and 21 months of grid import and export were rebuilt to match the meter exactly.
Home Assistant’s energy dashboard has nowhere to put a fixed charge, so each utility gets a phantom source that grows with the clock and is priced to equal its daily supply charge:
template:
- sensor:
# A fake volume that grows 0.001 m³ a day from wall-clock time alone.
# It keeps accruing while HA is down, and a restart can't reset it.
# Start the epoch near the day you create it, or the first reading
# gets booked as a one-off burst of "usage".
- name: Gas Supply Charge
unique_id: gas_supply_charge_volume
unit_of_measurement: "m³"
device_class: gas
state_class: total_increasing
state: "{{ ((as_timestamp(now()) - 1788000000) * 0.001 / 86400) | round(8) }}"
# Priced so that 0.001 m³ a day x price = the daily supply charge.
- name: Gas Supply Charge Price
unique_id: gas_supply_charge_price
unit_of_measurement: "AUD/m³"
state_class: measurement
state: >-
{{ (states('input_number.gas_supply_charge_daily') | float(0) / 0.001) | round(4) }}
# Then add it in Settings > Energy as a second gas source.The same data watches for leaks. The water meter’s pulse counter can see a leak of about half a litre to a litre an hour, and the thresholds for “tap left on” and “never quiet” came from the house’s own history, not from a guess. Floor leak sensors alert with no conditions at all, because every condition is a chance to stay quiet at 3 am.
Cars
Each family car has a small ESP32 box that reads the engine computer about five times a second. It works out fuel and distance for every trip, keeps each trip as a file on its own flash, and uploads it to Home Assistant when the car gets back to home Wi-Fi. Home Assistant prices the trip at what the fuel in the tank cost, not today’s pump price, because Adelaide’s weekly price cycle moves it by about 15%.
- from first flash to a dongle-free box wired to the car's CAN bus
- 11 days
- engine requests on the first full commute day, none failed
- 14,906
- manual step per fill-up: answer one phone notification with the pump price
- 1
The car reports no fuel flow, so fuel is calculated from the engine load, rpm and air-fuel ratio, and nothing is counted while the car coasts with the injectors off. In the main car, an odometer check proves every drive was logged: each trip has to start where the last one ended.
// Fuel from the ECU's "absolute load" value, for a car with no
// fuel-rate or airflow sensor. One engine-size constant per car.
const float ENGINE_L = 2.0f; // litres (example value)
const float AIR_G_PER_L = 1.184f; // SAE J1979 reference air density
const float AFR_STOICH = 14.7f; // petrol
const float FUEL_G_PER_ML = 0.745f; // petrol density
float fuelMlPerSecond(float absLoadPct, float rpm, float lambda, float cal) {
if (isnan(absLoadPct) || isnan(rpm) || rpm <= 0) return 0;
if (isnan(lambda) || lambda < 0.5f || lambda > 1.5f) lambda = 1.0f; // no lambda PID: assume 1
float airPerSecond = absLoadPct / 100 * AIR_G_PER_L * ENGINE_L * rpm / 120;
float fuelPerSecond = airPerSecond / (AFR_STOICH * lambda);
return fuelPerSecond / FUEL_G_PER_ML * cal; // cal = one pump calibration
}
// On the overrun the injectors are off but the load value still shows
// air moving through the engine, so count nothing while coasting.
bool coasting(float pedal, float pedalRest, float rpm, float kmh, float load) {
return pedal <= pedalRest + 1.5f && rpm > 1100 && kmh > 5 && load < 35;
}Groceries
Grocy keeps the household stock. Groceries come in from the receipt: a photo of the docket goes to Paperless, a local vision model reads the lines, and the items are matched to the catalogue and booked in. Groceries go out through a cordless barcode scanner by the bin, which marks each item as used by default. There are no supermarket accounts, no scraping and no paid cloud services, and the receipts never leave the house.
- dockets read and booked since the relaunch, 769 lines
- 58
- of purchase bookings came from receipts, not hand scanning
- 91%
- median per receipt, end to end on a local GPU
- 50 s
A receipt books only when the money adds up and the quantities agree a second, independent way: the unit count matches the count printed on the docket or, on dockets that print no count, every line’s quantity times unit price equals its total. Anything else parks for a person, with the suspect line named when a single line explains the gap, and only what a person confirms is ever learned.
TOLERANCE = 0.50 # dollars
def money_gate(d):
net = sum(i["price"] for i in d["items"]) - d["discounts"] + d["fees"]
return abs(net - d["total"]) <= TOLERANCE
def qty_gate(d):
# "Total for 35 items": a count of UNITS, printed by the till
if d.get("printed_item_count") is None:
return True # no evidence is not failure
return sum(i["qty"] for i in d["items"]) == d["printed_item_count"]
def line_gate(d):
# Only where no count is printed: qty x unit price == line total
if d.get("printed_item_count") is not None:
return True
for i in d["items"]:
unit = i.get("unit_price")
if not unit or i["qty"] <= 0 or "/kg" in i["raw"]:
continue # weighed lines state nothing
if abs(i["qty"] * unit - i["price"]) > 0.005 + 0.01 * i["qty"]:
return False
return True
def may_book(d):
# All three, or nothing is booked and the docket goes to a person
return money_gate(d) and qty_gate(d) and line_gate(d)The first attempt at this, in April, died quietly after two weeks. Nothing was being scanned out, so the stock stopped being trusted, and the uptime monitor stayed green the whole time. Now a daily watchdog alerts if nothing moves for seven days, and the 14-day ratio of things used to things bought has held above its 0.8 target since mid-August. Meat, produce and some pantry items still drift; it is not a perfect inventory, and it does not need to be.
Paperwork
Every PDF files itself into Paperless-ngx, whether it arrives by email, from the scanner or from my phone. One Gmail label feeds 16 mail rules across two household inboxes, and senders that matter downstream, like payroll and the utilities, get a rule that pins them. Around 13 to 18 documents a week arrive with nobody filing them.
- documents in the archive
- ~2,750
- first-run agreement between a local model's second opinion and the filed type; about 65% on unfamiliar mail
- 91%
- real filing errors that second opinion found, in 28 minutes, on the local GPU
- ~35
The local model is a cross-check, never the judge. It reads a document’s sender, filename and the first 1,500 characters of its text and names a type, and I read only the disagreements. It also writes titles for scanned pages, but it may only replace the scanner’s placeholder title, and “unknown” is an accepted answer:
# Only ever rewrite the placeholder title the scanner workflow assigned.
TITLE_GATE = re.compile(r"^Scan \d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}| \(\d+\))?$")
SYSTEM_PROMPT = (
"You title scanned paper documents for a personal household archive. "
"Reply with ONLY the title: 3-8 words, no quotes, no trailing period. "
"Name the organisation and the document type when you can identify "
"them, e.g. 'Example Dental receipt'. The text comes from OCR and may "
"be garbled. If you cannot tell what the document is with reasonable "
"confidence, reply with exactly: UNKNOWN"
)
def process(doc):
if not TITLE_GATE.match(doc.title or ""):
return # a person or a mail rule chose this title: leave it alone
if not (doc.content or "").strip():
return # no OCR text: keep the dull, correct title
title = clean(ask_local_model(SYSTEM_PROMPT, doc.content[:3000]))
if title: # None for UNKNOWN, overlong or punctuation-only replies
doc.title = title
doc.save() # the gate no longer matches, so a replay does nothingLife admin
Five scheduled robots, each a Claude Code prompt run headless from cron in a small container, handle the household email. Each morning one reads new mail, the calendar, the bills and the text of PDF attachments. It archives what is safe to archive, puts bills and school dates on the calendar, and writes a digest of up to twelve lines. Half an hour later a second robot checks that the digest exists. The others are a weekday check for urgent same-day school notices, a weekly review and a monthly look 90 and 365 days ahead.
- ☀️ Tue 13 Oct · 2 events today
- 📅 10:00 Car service drop-off · 15:30 Dentist
- 💰 Next 7 days: power bill $214.60 debits Fri 16 Oct · internet $89.00 Mon 19 Oct
- ⚡ Return the signed council form, due Tue 20 Oct
- ⚡ Call the insurer about the policy change, reply by Fri 23 Oct
- ❓ 3 login codes from a shopping site in 2 h. You? Y/N
- 🏫 School: pupil-free day Mon 26 Oct, on the calendar with a reminder the night before
- 🤖 Robot did: calendared the power bill, then archived the email
- +3 waiting, oldest 1 Oct
3 login codes from a shopping site in 2 h. You?
Codes arrived 07:02 to 08:55. Code values are never stored.
Before this was pushed, the ledger stored exactly what each button may do. Yes closes the item. No flags the thread for action and parks it. A tap cannot do anything else.
The safety is in the tools, not the prompt. The robots reach Gmail through a small server I wrote with narrow tools only: read, label, archive, draft to myself, and manage their own calendar events. No tool the robots can reach sends, forwards, trashes or deletes mail, and a test fails if a general-purpose editing tool is ever added to their allow-list. Any decision that needs me arrives as a separate push with yes and no buttons, and there are never more than three unanswered questions at once, across all five robots.
- inbox threads after one sweep: 8,703 archived, none deleted
- 13,039 → 4,336
- robot tools that can send, forward, trash or delete mail
- 0
- automated tests on the tools, ledger and runner
- 168
Before a question is pushed, a small SQLite ledger records exactly what each answer will do. A tap on the lock screen is a receipt for that plan, and the ledger refuses anything that does not match it:
{
"obligation_key": "gmail:THREAD-A",
"question": "3 login codes from a shopping site in 2 h. You? Y/N",
"context": "Codes arrived 07:02-08:55. Code values are never stored.",
"intent": {
"Y": [
{ "action": "transition_obligation", "obligation_key": "gmail:THREAD-A",
"to": "done", "reason": "Confirmed the codes were mine" }
],
"N": [
{ "action": "mark_action_thread", "thread_id": "THREAD-A" },
{ "action": "transition_obligation", "obligation_key": "gmail:THREAD-A",
"to": "waiting", "reason": "Did not request these codes" }
]
}
}Everything that leaves the inbox gets a dated label first, so undoing a whole run is one search. There is no per-token API spend: the robots run on a Claude subscription I already pay for.
Money
The newest piece, still in progress, is a household finance dashboard fed only by free sources: the bank’s alert emails, bank-app notifications relayed by Home Assistant, and monthly statements through Paperless. It opens with a sentence, not a wall of numbers, and it is strict about what it knows. A balance the bank stated shows with its date. A balance it cannot vouch for is a sentence saying so, never $0.
The free feeds are treated as estimates, because the alert emails miss about a third of the rows on a statement. Feeds are never merged automatically. Only a statement that proves it is complete may remove a row, and complete means exact to the cent:
// A bank file may remove a captured row only if it proves it is complete.
type StatementProof = { openingCents: number; closingCents: number };
export function proveComplete(
bankRows: { amountCents: number }[],
proof: StatementProof,
) {
const expected = proof.closingCents - proof.openingCents;
const actual = bankRows.reduce((sum, r) => sum + r.amountCents, 0);
// Integer cents, exact: one cent of drift means a row is missing,
// and a file that is nearly complete is not complete.
return { complete: actual === expected, driftCents: actual - expected };
}A local model guesses categories overnight, but its guesses stay unconfirmed and out of every total until someone taps them. The dashboard never pushes questions, because the three-question limit belongs to the life admin robots. It has 911 automated tests. Paid open-banking feeds, at about A$120 to A$160 a year, were priced and declined.
The glue
Apart from Home Assistant, which uses its own phone app, everything leans on the same three services. A self-hosted push server carries alerts and the yes/no buttons. Uptime Kuma runs about 80 monitors, and 25 of them are dead-man switches for scheduled jobs and robots. Proxmox Backup Server runs on a Raspberry Pi, and daily checks name any machine whose newest backup is late. One local language model on the cluster’s GPU is shared by six automatic callers and three I run by hand, including the voice assistant, receipt reading, document titles and type checks, and spending categories.
A heartbeat is only worth something if it proves the work happened. The morning digest pings its dead-man after drafting, and the watchdog robot sends a second, independent heartbeat only after it has seen today’s draft:
# Runs half an hour after the morning robot. Its only job: prove today's
# digest EXISTS, then (and only then) feed the dead-man switch.
status=$(robot-status daily-digest) # running | retry_wait | success | failed
drafts=$(list_todays_digest_drafts) # list DRAFTS: mail search hides drafts
rc=$? # non-zero = could not ask
if [ $rc -ne 0 ]; then # UNVERIFIED
push "Digest unverified" "Couldn't reach mail. Last run: $status" 2
elif [ -n "$drafts" ]; then # HEALTHY
curl -fsS -m 10 "$KUMA_PUSH_URL" >/dev/null # heartbeat on proof
elif [[ $status =~ ^(running|starting|retry_wait)$ ]]; then
push "Digest delayed" "Still $status, no draft yet" 4
else
push "Digest robot DOWN" "No digest this morning" 5
fi
# The digest also pings this monitor after drafting. This second ping lands
# only on proof, and Kuma pages if nothing lands inside 25 h.Rules I would keep
Most of these came from something going wrong. They apply well beyond a house.
- Archive, never delete.
- Everything that leaves an inbox gets a dated label first, so undoing a bad run is one search.
- Safety lives in the tools.
- A robot cannot send or delete mail because it has no tool that does. A prompt can be talked around; a missing tool cannot.
- Record the action before asking.
- A tap on yes runs a plan written down before the question went out, never an interpretation of the tap.
- Unknown is not clear.
- A source that could not be read is reported as a gap, never as all good.
- Heartbeat on the result, not the run.
- A job that ran proves nothing. Feed the dead-man switch only after the output has been seen.
- Monitor usage, not just uptime.
- The failure that kills home systems is people quietly stopping, while every monitor stays green.
- A blank beats a wrong guess.
- Models suggest and people decide. Nothing books unless the maths closes or a person confirms it.
- Prove it against the bill.
- A number is not trusted until it matches the revenue meter, the meter dial or a real invoice.
- Put the fragile step last.
- A flaky plug or a sleeping PC must never be able to cancel the step that matters.
- Build the part without AI first.
- Filters, mail rules and scanners keep working if the robots stop. The models sit on top.
Build your own
If you want to do the same, this is the order I would do it in again. Each step is useful on its own, so you can stop anywhere.
- Alerts and backups first. A self-hosted push server with its phone app, Uptime Kuma, and Proxmox Backup Server on a Raspberry Pi with a large USB disk. Give every scheduled job a dead-man switch before you build the job.
- Home Assistant on a small always-on box. An old laptop works, and its battery rides out short power cuts. Use Home Assistant's own nightly backup to a NAS rather than hypervisor snapshots.
- Meter energy, then prove it. A multi-channel clamp meter at the switchboard, fitted by an electrician. Download your distributor's free five-minute interval data, check the clamps against it, and reproduce one real bill to the cent before trusting any daily dollar figure.
- Paperless from email. One Gmail filter that labels PDF attachments and one catch-all mail rule on that label. On day one, move its scratch folder off memory onto disk, and make every intake path, whether mail rule, scanner or upload, assign a document owner.
- Grocy, starting with the way out. A cheap barcode scanner by the bin that marks each item as used by default, plus a watchdog that alerts after seven quiet days. Receipt reading comes after the habit sticks, not before.
- A local model, only if you have a GPU. Use it as a second opinion with an 'unknown' escape, never as the judge, and keep a small fixed set of test questions to catch regressions.
- Robots last. Narrow tools with no send or delete, a ledger that records the action before asking, one shared budget of three questions, and a dead-man switch gated on the output.
What it costs to run
- The whole rack draws about 330 W, roughly A$50 to A$80 a month before solar and the battery.
- The robots run on an existing Claude subscription, with no per-token API spend. Everything else is free software.
- The car box is an ESP32 board with a CAN transceiver, about A$25, plus an OBD extension cable. The gas meter reader is a reed switch that costs a few dollars.
- No supermarket or bank logins, and no paid data feeds. Open banking was priced and declined.
What I’d set up for you
Most of this scales down to one small box in a cupboard. The parts I get asked about most are paperwork that files itself from email, energy numbers you can trust against the bill, and backups with alerts that tell you when they stop running. If one of those would help your house or small business, the first conversation is free.