A self-hosted Python service that turns a Home Assistant dashboard button into an automatic order — logged to Google Sheets and notified via Telegram. No confirm step; roommates are trusted.
Each item can declare two sources: Costco is tried first, falling back to Amazon when Costco is sold out, not carried, or over its price ceiling.
note: drives real Costco/Amazon checkout via Playwright. brittle by nature, against the stores' ToS, and requires a persistent logged-in browser profile per store. Costco fronts the site with Akamai bot detection, which is far more aggressive than most retailers' — the automation may be blocked outright.
Two decoupled halves joined by a SQLite queue:
HA dashboard button → script.order_<item> → POST /reorder {item_key}
validate + guards + enqueue → queue row (status=pending)
worker loop drains queue → Playwright buys → scrape order # + total
→ append to Google Sheet → Telegram notify
Intake is always-on; execution needs a live graphical session. Requests sit in the queue if the desktop is asleep and drain on wake.
serve— start the FastAPI intake service + worker loopinit-db— initialize the SQLite schemacatalog— print all items in the catalogqueue— show pending/recent queue rowstest-notify— emit a test message via the configured notifierlogin --provider costco|amazon— open a store's profile to sign in by hand (one profile per store)dry-run ITEM_KEY --provider costco|amazon— navigate one store to checkout and screenshot without placing the orderdump-dom ITEM_KEY --provider costco|amazon— read-only DOM + selector probe for bring-upverify-selectors [ITEM_KEY] --provider costco|amazon— probe live product pages and report PASS/MISS per item for the price + add-to-cart selectors (operator-run; hits the store, never orders)doctor [--check-login]— one-shot, read-only health check of config, Chrome, the graphical session, per-store profiles, the DB/queue, and the catalog;--check-loginalso relaunches each store profile to report whether it's still signed infailures [--limit N]— list recent failed/blocked orders with their notes and the newest screenshots to openretry ROW_ID [--resume]— re-enqueue a failed row (refuses rows that may already have placed an order)prune-shots [--days N]— delete old screenshots/DOM dumps from the shots dir (the worker also prunes automatically)
catalog.json maps item keys to shared fields (title, quantity, cooldown) plus a costco block (item number + URL) and/or an amazon block (ASIN + URL), each with its own expected price and price ceiling. Costco is tried first; Amazon is the fallback. At least one source is required. An optional owner field marks an item as one roommate's personal buy — the order is still placed for real, but the Sheets status column logs ordered for <owner> instead of placed so the shared log separates personal orders from shared-household ones. See examples/catalog.json and examples/env.example for the full schema.
DRY_RUNflag — stops before the final click; defaulttrueuntil you've confirmed each item reaches the review page cleanly- Double-tap guard — same item within 60 s is ignored
- Per-item cooldown —
cooldown_daysfrom the catalog - Price ceiling — per-source; on Costco an over-ceiling price falls back to Amazon, on the last store it aborts and alerts
- Out-of-stock fallback — Costco sold out / not carried / not found falls back to the item's Amazon source
- Daily spend cap — global
$ceiling per rolling 24 h; pauses worker and alerts on breach - Challenge detection — CAPTCHA / OTP pages halt the worker and ping you with a screenshot rather than looping
- Heartbeat — set
ROOMIEORDER_HEARTBEAT_URLand the worker pings it on a timer (ROOMIEORDER_HEARTBEAT_INTERVAL_SECONDS, default 300). A wedged worker thread stops the pings and your monitor alerts — works with hosted Healthchecks.io or a self-hosted open-source instance, Uptime Kuma push, etc. Empty disables it. - Session freshness — set
ROOMIEORDER_SESSION_CHECK_HOURSand the worker periodically relaunches each store profile read-only and notifies you if it's logged out, before a real order fails at the sign-in wall. Default3;0disables it.- Activity gate — the probe opens a headed Chrome window, so when it's due it waits until you're away rather than stealing focus mid-game / mid-work. Any of these defers it (and it fires within ~5s of clearing — the interval timer only advances on a probe that runs):
ROOMIEORDER_SESSION_CHECK_WINDOW— only probe inside a local-time window, e.g.03:00-08:00(wrap past midnight allowed,22:00-06:00). Empty (default) = any time. A malformed value fails the service at startup.ROOMIEORDER_SESSION_CHECK_SKIP_GAMEMODE(defaulttrue) — skip while a game runs under gamemode, detected byROOMIEORDER_SESSION_CHECK_GAMEMODE_CMD(defaultgamemoded -s); stdout containingis activedefers.ROOMIEORDER_SESSION_CHECK_IDLE_MINUTES(default0= off) — require this many idle minutes first. Wayland/Hyprland has no universal idle query, so the idle seconds come fromROOMIEORDER_SESSION_CHECK_IDLE_CMD(it must print idle seconds); with a threshold set but no/failed command the probe defers rather than risk popping a window.
roomieorder doctorprints anactivityline with the live verdict so you can confirm detection works on your box.
- Activity gate — the probe opens a headed Chrome window, so when it's due it waits until you're away rather than stealing focus mid-game / mid-work. Any of these defers it (and it fires within ~5s of clearing — the interval timer only advances on a probe that runs):
Each staple item gets a button card on the HA dashboard that calls a rest_command pointing at POST /reorder:
rest_command:
roomieorder_reorder:
url: "http://localhost:8723/reorder"
method: POST
content_type: "application/json"
payload: '{"item_key": "{{ item_key }}"}'
script:
order_paper_towels:
sequence:
- service: rest_command.roomieorder_reorder
data: { item_key: "paper_towels" }Each order attempt appends a row: timestamp | item_key | title | provider | product_id | qty | unit_price | order_total | order_id | status | requester | notes
status ∈ placed | dry_run | skipped_cooldown | skipped_debounce | price_blocked | spend_capped | unavailable | needs_review | failed | challenge | blocked.
Requires a Google Cloud service account JSON with editor access on the target sheet.
All durable state lives under the configured paths (the systemd StateDirectory in the deployment):
ROOMIEORDER_DB(data/state.sqlite) — the queue, order history, spend accounting, and worker-pause flag. Back it up with the WAL checkpointed (sqlite3 state.sqlite ".backup backup.sqlite").ROOMIEORDER_PROFILE_DIR(data/profile/{costco,amazon}) — the per-store browser profiles holding the signed-in sessions. These are whatroomieorder loginpopulates; restoring them avoids re-logging-in. Keep them private (they contain live auth cookies).ROOMIEORDER_SHOTS_DIR(data/shots) — screenshots / DOM dumps; safe to discard (auto-pruned, seeprune-shots).
To migrate to a new host, copy the DB and the profile dir; the catalog and env config come from your deployment.
nix flake check
nix build .#packages.x86_64-linux.default