A smart-home dashboard: rooms and devices, scenes, schedules, if-this-then-that automations, energy cost analytics, household sharing, and a Gemini-powered assistant that controls the home in natural language. Real-time device state pushes over WebSocket.
There is no hardware layer — devices are simulated by a backend loop that drifts their state, records readings, and fires rules and schedules on each tick. Everything above that layer (auth, ownership, automation, billing, analytics) is real.
| Frontend | Next.js 16 (App Router, TS), Tailwind v4, shadcn/ui, next-themes |
| Backend | FastAPI, WebSocket, google-genai (Gemini), Stripe |
| Database | Supabase Postgres, schema owned entirely by Prisma (prisma-client-py) — no hand-written SQL |
| Auth | Supabase Auth (email/password + TOTP 2FA); the backend verifies the access token per request |
| Deploy | One Docker image (loomi-app) running both processes |
Design decisions, feature history, and competitive research live in PLAN.md.
ROADMAP-feasible-features.md is fully shipped (see its Implementation log);
FEATURE-habit-learning.md is the one remaining unbuilt spec.
- Node 22+, Python 3.12+ (the container uses Node 22 + a
venv) - A Supabase project (Postgres + Auth)
- A Gemini API key — free from Google AI Studio
- Optional: a Stripe account. Without it,
/api/billing/*returns 503 and everyone stays on the Free plan; nothing else is affected.
1. Configure environment. Copy both examples and fill them in:
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.envbackend/.env—SUPABASE_URL,SUPABASE_SERVICE_KEY,GEMINI_API_KEY, plusDATABASE_URLandDIRECT_URL.frontend/.env—NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEY(the anon/publishable key, never the service key), plus the API/WS URLs.
Two connection strings, and they are not interchangeable. DATABASE_URL must be
Supabase's transaction-mode pooler (port 6543), which is IPv4-reachable — the
direct host requires IPv6 egress the Docker container may not have. DIRECT_URL is the
direct connection (port 5432) and is used only by prisma migrate / db execute,
which the pooler can't serve.
2. Create the schema.
cd backend
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
./.venv/bin/prisma generate --schema prisma/schema.prisma
./.venv/bin/prisma migrate deploy --schema prisma/schema.prisma
./.venv/bin/python prisma/seed.py # optional demo home; idempotentNo SQL-editor step is needed. The seed builds a demo home (rooms, devices, an energy
budget) owned by user.1@loomi.io.
3. Run it.
# Backend → http://localhost:8000 (docs at /docs)
cd backend && ./.venv/bin/uvicorn app.main:app --reload
# Frontend → http://localhost:3000
cd frontend && npm install && npm run devOr the whole thing in one container:
docker compose up --build # 3000 = app, 8000 = APICompose mounts backend/.env rather than using env_file: — Compose interpolates
$VAR patterns in env-file values, which silently corrupts any DB password containing
a literal $. Both pydantic-settings and Prisma load a physical .env themselves.
4. Make yourself an admin. Sign up through the app, then in the Supabase SQL editor:
update profiles set role = 'admin' where email = 'you@example.com';There is no self-service admin promotion, by design. Admins are redirected from
/dashboard to /admin — an admin oversees members' homes rather than owning one.
Browser ──► Supabase Auth directly ............ sign-up / sign-in / session only
│
├──► FastAPI :8000 ──► Prisma ──► Supabase Postgres ....... all app data
│ │
│ ├── verifies the caller's Supabase token per request (app/auth.py)
│ └── simulator loop: drift state → write readings → broadcast
│ → evaluate rules → fire due schedules
└──◄ WebSocket /ws/live ....................... live device-state push
The frontend never reads or writes tables. The anon key is used for Auth and
nothing else; every row goes through the backend, which is why RLS is deliberately off
(PLAN.md → Notes).
Each member builds their own home. rooms, device_groups, scenes, schedules,
automation_rules, and energy_budgets carry owner_id → profiles.id. devices
deliberately does not — a device's owner is derived through device.room.owner_id
(where={"room": {"owner_id": user.id}}).
Every member-facing endpoint filters lists by owner, validates referenced rows belong to
the caller on create, and scopes where on update/delete. Acting on someone else's
row returns 404, not 403, so existence isn't leaked either. Cross-member visibility
exists only through admin.py. Two exceptions, both intentional: household.py
(explicitly shared homes) and GET /api/activity (admins get the full log, members get
only their own entries).
Adding an endpoint? It inherits none of this automatically — apply the pattern by hand.
backend/app/
main.py auth.py db.py config.py schemas.py ws.py
simulator.py rules_engine.py schedules_engine.py retention.py # the tick loop
ai.py activity.py billing.py plans.py geo.py solar.py readings.py csv_export.py
routers/ rooms devices rules scenes schedules groups notifications
activity_log energy profile admin ai billing presence
portability household access_codes
backend/prisma/ schema.prisma · migrations/ · seed.py
frontend/app/ (marketing)/ · dashboard/ · admin/ · account/
frontend/components/ · frontend/lib/
Two choke points worth knowing before changing behaviour: app/activity.py's
notify() — every notification, quiet-hours check, and outbound webhook flows
through it; and app/simulator.py's tick — rules, schedules, vacation mode, and the
daily wellness check all hang off it, because the project has no cron infrastructure.
- Schema changes go through Prisma only.
prisma migrate dev→ a new folder underprisma/migrations/. No hand-written DDL, anywhere. - Nullable
Json?columns: omit the key from thecreate()data dict entirely. PassingNoneraisesMissingRequiredValueError— this shipped as a real bug once. @db.Timecolumns need a fulldatetime(datetime.combine(EPOCH, t)) on write and.time()on read. A baretimeis not serializable — also a real bug once.- Plan limits: call
plans.check_limit()before creating, with the current count. It raises a structured 402 the frontend'sUpgradeDialogparses. - Frontend type-check:
cd frontend && npx tsc --noEmit. - Tests:
cd backend && pytest(40 tests, no live DB — seebackend/tests/fakes.py) andcd frontend && npm test(vitest). Both run in.github/workflows/ci.ymlon push/PR, alongside the frontend type-check.
- The command palette, device-offline UI, and voice dictation (see
ROADMAP-feasible-features.md#6/#5/#9) were verified live via Playwright but have no committed browser-driven regression test — CI'sfrontendjob istsc --noEmit+vitest run(pure logic), not a browser suite. - Out of scope by decision: real hardware/Matter, transactional email, push
notifications outside the app (webhooks cover it), and a first-class multi-home
Homeentity.
- Habit Learning (suggested automations) — fully spec'd in
FEATURE-habit-learning.md(schema, endpoints, frontend, scope boundaries) but status is explicitly "designed, not implemented." Next feature to build. - Browser-driven regression tests for the command palette, device-offline UI, and voice dictation — see "Known gaps" above. Logic/API is covered by CI; the live-verified UI flows are not yet automated.
- Deployment — not yet hosted anywhere; not on the portfolio's
project-deployments.mdtracker at all, unlike the other 8 projects. Needs a hosting decision (Vercel + a backend host, Oracle VM, etc.) and the actual deploy.