# handoff.md — HomeOS > **Status: FINAL (2026-07-17).** This is the authoritative handoff — supersedes any earlier > draft or chat summary. > > **Handoff for the Claude Code session on the server.** This document is the source of truth > to build the project from scratch on the target VM. Read fully before the first command. > Starting state of the VM: see §6 (user `nexxo` + sudo, Docker installed, otherwise empty). > Companion file: `design-mockup.html` (interactive design reference — open in a browser; > contains all three views: Dashboard, access gate, mobile/PWA layout). > > Meta-docs are English; **UI copy is German** (localized DE+EN, see rules). Conventions and > STRICT RULES are carried over from the user's Clusev project and are **non-negotiable**. --- ## 1. Product **HomeOS** *(working title — rename is an open decision, see §12)* — a **self-built smart-home control plane**. Explicitly NOT Home Assistant and not a wrapper around it: own data model, own integrations, own UI. Single household, self-hosted, LAN-first. **Core capabilities (full vision):** - Automatic **device discovery** on the network (mDNS/SSDP/ARP) with an "assign new device" flow - **Device control** (switches, lights, plugs, power metering; later TVs, covers, …) - **Rooms** and **persons**: every device/entity assignable to a room and optionally a person - **Presence detection** (home/away per person) via UniFi + later geofencing, score-based fusion - **Window/door contact sensors** with status + automations - **Automations** (trigger → condition → action) - Live dashboard (Reverb) + later **PWA** on the phone - Later: settings area protected by **WebAuthn/passkeys** (deferred, see §11) **v1 scope (build this first — do NOT boil the ocean):** 1. Foundation: Docker stack, auth, design system, device/entity/room model, MQTT ingest, Reverb live updates, audit/command log 2. **Shelly integration** (the user's actual hardware today — see §4) 3. Discovery sidecar + "Neue Geräte" assignment flow 4. Presence via **UniFi API** (the user's actual network — see §5) 5. Automations engine (minimal TCA: state-change + time triggers, few actions) **Deferred (keep OUT of v1):** face recognition entirely (later, maybe), WebAuthn gate, TV/Chromecast control, Matter, Zigbee (until hardware decision), Ring bridge, native mobile app (PWA later covers it), energy history charts beyond a simple sparkline. --- ## 2. Tech stack (hard requirements) | Layer | Choice | |---|---| | Framework | **Laravel 13** (current major; user's newer projects run 13 — confirm once, see §12) | | UI | **Livewire v3** — class-based, **NOT Volt** | | CSS | **Tailwind v4** (CSS-first, `@theme` in `app.css`, no `tailwind.config.js`) | | Build | **Vite** | | Modals | **wire-elements/modal** | | Realtime | **Laravel Reverb** + Echo — **every channel PRIVATE** | | Queue/Cache | **Redis** (+ Horizon) | | DB | **PostgreSQL 17 + TimescaleDB** recommended (telemetry hypertables, compression, retention). Fallback if user prefers stack consistency with Clusev: MariaDB + monthly partitioning + prune job. **Open decision §12 — ask before migrating.** | | MQTT | **Eclipse Mosquitto 2** (auth + per-client ACLs from day one) | | MQTT client | **php-mqtt/laravel-client** (^1.8) — subscriber runs as its own Artisan daemon container | | Discovery | **Python sidecar** container (`network_mode: host`): python-zeroconf (mDNS), async-upnp-client (SSDP), arp-scan — reports via MQTT. PHP cannot do multicast listening from the bridge network. | | Charts | JS lib as Alpine island (uPlot or ApexCharts) — v1 needs only sparklines | | Icons | **Lucide**, inline SVG via `x-icon` Blade component — no emoji anywhere | | Fonts | **Plus Jakarta Sans** (UI) · **IBM Plex Mono** (values, MACs, IPs, timestamps) — **self-hosted woff2 only** (R14) | Composer: `livewire/livewire wire-elements/modal laravel/reverb laravel/horizon php-mqtt/laravel-client` NPM: `tailwindcss @tailwindcss/vite laravel-echo pusher-js` (+ chart lib) --- ## 3. Architecture **MQTT-first hybrid.** Mosquitto is the central bus. Everything that speaks MQTT natively (Shelly Gen2+, later Zigbee2MQTT, Tasmota, WLED, ring-mqtt) connects directly. Devices without MQTT get adapters later (HTTP drivers in PHP, or sidecars for TV/Cast/Matter). The UI never touches protocol topics directly — **always through the DB mapping** (protocol-agnostic). ``` devices ──MQTT──► mosquitto ──► mqtt-listener (artisan daemon) │ parse + dispatch only (NEVER heavy work in the loop) ▼ Events / Horizon jobs ──► DB upsert (device_states) │ ▼ Reverb (private channels) ──► Livewire UI (Echo) UI action ──► Livewire ──► DeviceCommand job ──► driver (MqttDriver | HttpDriver) ──► device └── every command written to command log (audit) ``` **Data model (core tables):** - `users`, `rooms`, `persons` (person ↔ user optional link) - `devices` — identity: uuid (route key, R11), name, vendor, model, protocol, connection config (JSON: topic prefix / IP / auth ref), `room_id`, `person_id` nullable, first_seen/last_seen, approval state (`discovered` | `active` | `ignored`) - `entities` — one capability per row: type (`switch`, `light`, `power`, `contact`, `temperature`, `humidity`, `motion`, …), capabilities JSON, `device_id` - `device_states` — CURRENT state only: `entity_id` unique, state JSON, updated_at (pure upserts, stays small and fast) - `telemetry` — history (TimescaleDB hypertable; numeric value column + state JSON). Compression + retention policy **from day one** (a 30 s sensor ≈ 1 M rows/year) - `commands` — audit: who/what switched which entity when (user | automation | physical), payload, result. Unverzichtbar for debugging "warum ging das Licht an?" - `automations` — name, enabled, trigger_type + trigger_config JSON, conditions JSON, actions JSON, `cooldown_seconds`, `last_triggered_at` (cooldown from day one — motion sensors without debounce flood the queue) - `presence_signals` + computed person state (see §5) - `discovery_findings` — raw sidecar reports before a device is approved **Driver contract:** `app/Support/Drivers/DeviceDriver` interface (`turnOn`, `turnOff`, `setState`, `capabilities`). v1 implements `ShellyMqttDriver`. Everything else is a later driver behind the same contract — this is what keeps the system "für alle Produkte brauchbar". **Broadcasting:** private channels only — `rooms.{uuid}`, `devices.{uuid}`, `presence`, `discovery`. Authorize in `routes/channels.php`. State changes broadcast with `ShouldBroadcastNow` (or a dedicated high-priority queue) — a slow job in the default queue must never delay a light toggle in the UI. **Long-runners:** `mqtt-listener`, `reverb`, `horizon`, `scheduler` are separate containers off the same PHP image. MQTT loop rule: parse + dispatch only; reconnect with exponential backoff; graceful SIGTERM shutdown via pcntl. --- ## 4. Hardware reality (drives the build order) **Today the user owns: Shelly devices only, plus one Ring product. No Zigbee, no Hue.** **Shelly (v1 target):** - Gen2/Gen3 speak JSON-RPC over **MQTT** (enable MQTT in device settings → point at Mosquitto), plus HTTP `POST /rpc` and WebSocket. Status arrives on `/status/...`, notifications via `rpc_ntf`; commands via RPC (`Switch.Set`, `Light.Set`). - Discovery: Shellys announce via mDNS (`_shelly._tcp`) and expose `GET /shelly` (identity JSON: model, gen, mac) — the sidecar uses both to classify. - Power metering (PM devices) → `power` entity → dashboard energy tile. - Docs: shelly-api-docs.shelly.cloud **Window/door contacts (near future, still Shelly-native):** **Shelly BLU Door/Window** (Bluetooth) received by any Shelly Gen3 device or a Shelly BLU Gateway acting as BT gateway — events surface over the same MQTT path. This avoids buying into Zigbee just for contacts. Zigbee (Zigbee2MQTT + LAN coordinator SLZB-06, never USB passthrough into the Proxmox VM) remains the documented expansion path if/when the user buys Zigbee hardware. **Ring (deferred, optional):** no official local API. Established bridge: **ring-mqtt** (tsightler/ring-mqtt, Docker) — cloud-based, token auth, surfaces motion/ding/contact/camera events on MQTT. Fits the bus as-is. Cloud dependency must be labeled in the UI (device badge "Cloud"). Do not build a custom Ring client. **Later expansion paths (documented, not built):** Hue via local CLIP v2 (HTTPS, HTTP driver in PHP — existing PHP Hue packages are all deprecated v1 API, don't use them), TVs (LG SSAP / Samsung Tizen websockets) + Chromecast + **Matter** (matter.js + @matter/mqtt) via a Node sidecar, Tasmota/WLED native MQTT. The entity/driver model above must never assume Shelly. --- ## 5. Network topology & presence **Actual network:** Fritz!Box (internet, NAT) → **UniFi Dream Machine** (own NAT — double NAT, works fine, accepted) → LAN/WLAN with all devices and phones. Consequences: - **Presence source of truth = UniFi**, NOT Fritz!Box (phones associate with UniFi WLAN). Package: **art-of-wifi/unifi-api-client** (`list_clients()` → MAC, IP, AP, last_seen). UDM = UniFi OS console (port 443); create a dedicated read-only local user or API key for HomeOS. Fritz!Box TR-064 is irrelevant here (it only sees the UDM as one client). - Presence engine: "home" immediately on association; "away" only after **5–10 min debounce** (`last_seen_at` + scheduler sweep). iPhone WLAN sleep causes false-aways otherwise. Never hang a security automation on a single raw signal. - MAC randomization: private MAC is stable per SSID — onboarding flow assigns "this client = person X" from the live UniFi client list instead of asking for hardware MACs. - Later additions: OwnTracks geofencing (MQTT, fastest clean "left home" signal), score-based fusion of UniFi + GPS (+ BLE if ever). v1 = UniFi only. - **Remote access:** double NAT makes port forwarding annoying (two chained forwards) — use **Tailscale** (or WireGuard on the UDM) instead. No open ports. This is also the interim answer for "PWA von unterwegs" until a public domain + TLS is set up. - **WebAuthn (deferred)** will require HTTPS + a real domain (RP ID cannot be an IP; domain change invalidates all passkeys) — plan `home.` + Let's Encrypt DNS-01 via Caddy when that phase starts. - Proxmox: VM NIC on vmbr0, **same L2/VLAN as the smart-home devices**, otherwise mDNS/SSDP discovery is blind. If IoT ever moves to its own VLAN → mDNS reflector required. --- ## 6. Docker topology (dev = prod parity, R8 — host has only Docker) **Starting point on the target VM (already prepared — this is where you begin):** - User **`nexxo`** exists and has **sudo**. Work as this user. - **Docker is already installed.** Verify with `docker compose version` as one of the first commands; if the compose plugin is missing, report it. - The VM is otherwise **empty**: no PHP, no Composer, no Node on the host — hence R8, every project command runs in containers (bootstrap Laravel via a throwaway container). - Project root: **`/home/nexxo/homeos`** (own git repo — does not exist yet, create it during bootstrap; feature branches, not `main`). Read `HOST_UID`/`HOST_GID` from `id nexxo` into `.env` — do not hardcode a UID. Services in `docker-compose.yml`: | Service | Role | |---|---| | `app` | php-fpm + nginx + Vite (dev) via supervisor — Laravel + Livewire | | `horizon` | queue workers (same image, `php artisan horizon`) | | `scheduler` | `php artisan schedule:work` (same image) — presence sweeps, retention, polling fallbacks | | `reverb` | `php artisan reverb:start` (same image) | | `mqtt-listener` | `php artisan mqtt:listen` (same image, `restart: always`) | | `mosquitto` | eclipse-mosquitto:2 — password auth + ACL per client (laravel, sidecar, shelly, later z2m/ring) | | `db` | timescale/timescaledb:latest-pg17 (or MariaDB — §12) | | `redis` | redis:7-alpine | | `discovery` | Python sidecar, **`network_mode: host`** + `cap_add: [NET_RAW]` | Port planning (host-mode container shares the VM's ports — plan collisions explicitly): app **:80**, Vite **:5173**, Reverb internal 8080 → host **:6001**, Mosquitto **:1883** (LAN only), DB bound to 127.0.0.1. All ports env-driven, nothing hardcoded. Ops non-negotiables: - **Backups:** Proxmox vzdump for the VM + DB dump job; later `coordinator_backup.json` when Zigbee exists. Mosquitto persistence volume. - **Timezone:** app logic Europe/Berlin, storage UTC; VM NTP-synced (DST double/zero-fire bug in time triggers otherwise). - **Reverb behind proxy:** websocket upgrade headers + long `proxy_read_timeout`, or sockets drop every 60 s. - **Graceful degradation:** server down must never make the home unusable — physical switches keep working because Shellys switch locally (detached/relay mode kept functional). --- ## 7. Folder map (R6 — follow exactly) ``` app/ Livewire/ Dashboard.php Rooms/{Index.php, Show.php} Devices/{Index.php, Show.php} Discovery/Index.php Persons/Index.php Automations/{Index.php, Edit.php} Settings/Index.php Modals/ # wire-elements/modal (ConfirmDelete, AssignDevice, …) Concerns/ Models/ # Device, Entity, DeviceState, Room, Person, Automation, Command, DiscoveryFinding Support/ Mqtt/ # topic mapping, payload normalizers per vendor Drivers/ # DeviceDriver contract + ShellyMqttDriver (+ later drivers) Services/ # DeviceCommandService, PresenceEngine, AutomationEngine, DiscoveryService, UnifiClient Events/ # DeviceStateChanged, DeviceDiscovered, PresenceChanged (ShouldBroadcastNow) Jobs/ Console/Commands/MqttListenCommand.php resources/ views/livewire/… # mirrors app/Livewire, kebab-case views/components/ # x-panel, x-kpi, x-status-pill, x-status-dot, x-badge, x-icon, # x-room-card, x-device-chip, x-toggle, sidebar, topbar views/layouts/app.blade.php css/app.css # @import "tailwindcss"; @theme { tokens §8 } fonts/ # plus-jakarta-sans*.woff2, ibm-plex-mono*.woff2 (Vite-bundled) js/app.js # Echo bootstrap + Alpine chart islands lang/{de,en}/ # common, dashboard, rooms, devices, discovery, persons, automations, settings, modals routes/web.php # full-page Livewire routes, English paths (R13) routes/channels.php # private-channel auth sidecar/ # Python discovery sidecar (own Dockerfile) docker/ docker-compose.yml docker-compose.prod.yml handoff.md rules.md CLAUDE.md design-mockup.html ``` --- ## 8. Design system — "Enterprise Ops, dark OLED" Reference: **`design-mockup.html`** (open it — it is the approved direction: UniFi/Grafana-class ops console, NOT a consumer app). Port these tokens verbatim into `@theme` (R3); markup uses token utilities only. ```css @theme { /* surfaces */ --color-base: #080D18; /* page bg, blue-biased OLED black */ --color-surface: #0D1424; /* cards */ --color-raised: #121C31; /* nested surfaces, chips */ --color-inset: #18233D; /* icon wells, kbd */ /* text ramp */ --color-ink: #EAF0FB; --color-ink-2: #93A1BD; --color-ink-3: #5E6C8A; /* brand */ --color-accent: #4FC1FF; /* cyan — interaction, active nav, toggles; tints via /10 /25 */ /* status triad — reserved, never decorative */ --color-online: #34D399; --color-warning: #FBBF24; --color-offline: #F87171; /* hairlines */ --color-line: #1C2846; --color-line-soft: #141E36; /* fonts */ --font-sans: "Plus Jakarta Sans", ui-sans-serif, system-ui; --font-mono: "IBM Plex Mono", ui-monospace, monospace; } ``` Rules of the look: status via colored dot/pill + text (never color alone, never emoji); **every numeric value, MAC, IP, timestamp in `font-mono` with `tabular-nums`**; hairline borders instead of shadows; accent used sparingly (one primary action per view); subtle pulse on the live indicator; `prefers-reduced-motion` respected. Responsive 375/768/1280 (R7): sidebar → drawer, KPI grid 5→3→2→1, right rail stacks below. --- ## 9. STRICT RULES (carried over — compact form) > The user's Clusev `rules.md` applies 1:1 to this project (full examples live there). > Copy this list into `rules.md` in the repo at bootstrap. On any conflict: **STOP and ask.** - **R1** Pages = full-page class-based Livewire components mapped directly in `routes/web.php`. No page controllers. - **R2** Livewire class + separate Blade view. **Volt forbidden** (never `make:volt`). - **R3** All colors/design tokens in `@theme` (`app.css`); markup uses token utilities only — no raw hex/rgb. - **R4** No inline `style=` — single exception: progress-bar `width`. - **R5** Destructive/confirm actions via **wire-elements/modal** — never `confirm()`/Alpine popups. - **R6** Files exactly per folder map (§7). - **R7** Responsive verified at 375/768/1280; touch targets ≥ 44 px. - **R8** Every PHP/Composer/Node/artisan command **inside the container** — host has only Docker. - **R9** UI copy German, terse/operational, **no emoji**; status via color/dots/pills; technical tokens stay native. - **R10** Reuse `@theme` tokens + Blade component kit — no ad-hoc colors/widgets. - **R11** URL-exposed records addressed by **UUID**, never integer PK (`getRouteKeyName(): 'uuid'`). - **R12** Every touched page verified in a real browser: **HTTP 200, zero console errors, zero failed requests**; loaded state of lazy pages, not the skeleton; inspect rendered DOM for leaked `@`/`{{ }}`/`group.key` literals. Green `Livewire::test` ≠ done. - **R13** Route paths + names **English** (`/devices`, not `/geraete`); German only in visible labels. - **R14** Fonts **self-hosted** (`resources/fonts/*.woff2`, relative `url('../fonts/…')` + `@font-face`) — no Google Fonts/CDN link or import. - **R15** **Codex review** (`/codex:review`) after every change — no errors, no security findings, else fix and re-run. Task not done until clean. - **R16** Every UI string localized `lang/{de,en}/.php`, identical keys both languages, `__('group.key')` — no hard-coded copy anywhere. - **R17** Blade: block `@php … @endphp` only (no inline `@php(...)`); never the literal directive tokens in comments/text. - **Secrets:** `.env*`, tokens, keys never staged; check `git status` before every commit. Git repo does not exist yet — create it at bootstrap (feature branches, not `main`). **HomeOS-specific additions:** - **H1** UI/Livewire never publishes to protocol topics directly — every command goes through `DeviceCommandService` → driver, and every command is written to the `commands` audit table. - **H2** MQTT subscriber callbacks: parse + dispatch only. No DB aggregation, no HTTP calls in the loop. - **H3** Vendor specifics (topics, payload shapes) live only in `Support/Mqtt` + `Support/Drivers`. Models, Livewire and views stay protocol-agnostic. - **H4** Telemetry writes go through one ingest path that enforces retention/compression policies. - **H5** Automations always carry `cooldown_seconds`; the engine has a **dry-run mode** (log instead of switch) for development — never develop automations against the live home. --- ## 10. Claude Code plugin setup (server session — do this FIRST, before Phase 1) The server session must mirror the user's local Claude Code plugin set and **use** these plugins during the build. At session start: 1. **Add the external marketplaces**, then install: | Plugin | Marketplace / source | Install | |---|---|---| | superpowers | `claude-plugins-official` (built-in) | `/plugin install superpowers@claude-plugins-official` | | frontend-design | `claude-plugins-official` | `/plugin install frontend-design@claude-plugins-official` | | code-review | `claude-plugins-official` | `/plugin install code-review@claude-plugins-official` | | context7 | `claude-plugins-official` | `/plugin install context7@claude-plugins-official` | | codex | `/plugin marketplace add openai/codex-plugin-cc` | `/plugin install codex@openai-codex` | | claude-mem | `/plugin marketplace add thedotmack/claude-mem` | `/plugin install claude-mem@thedotmack` | | ui-ux-pro-max | `/plugin marketplace add nextlevelbuilder/ui-ux-pro-max-skill` | `/plugin install ui-ux-pro-max@ui-ux-pro-max-skill` | | caveman | `/plugin marketplace add JuliusBrussee/caveman` | `/plugin install caveman@caveman` | 2. **Use them, don't just install them:** superpowers workflows (brainstorming before creative work, TDD, systematic debugging, writing/executing plans), frontend-design + ui-ux-pro-max for every UI task (§8 design system), code-review + **codex for R15** (mandatory review gate), claude-mem for cross-session memory, context7 for current library docs (Laravel/Livewire/Tailwind APIs — don't answer from training data), caveman optional. 3. **Anything missing → install it.** If an install fails, a plugin needs a runtime it can't find, or **any tool requires a login/authentication** (Codex CLI login, API keys, tokens, Gitea credentials, UniFi access) → **stop and report to the user**; never skip silently or work around it. Because R15 depends on Codex, verify `/codex:setup` during bootstrap, not when the first review is due. 4. Scope note: plugin runtimes (node/python for claude-mem, ui-ux-pro-max) belong to the Claude Code session on the host — R8 (everything in containers) applies to **project** tooling, not to session tooling. --- ## 11. Deferred decisions already researched (do not re-litigate, just don't build yet) - **Face recognition:** postponed entirely. When it returns: **WebAuthn/passkeys** (spatie/laravel-passkeys or asbiin/laravel-webauthn) is the security gate — browser-based DIY liveness is not securable (virtual cameras, replay, deepfakes). Camera face recognition only ever as comfort/personalization layer, never as the lock. - **Zigbee:** if bought, Zigbee2MQTT + **SLZB-06 LAN coordinator** (PoE), never USB passthrough into the Proxmox VM. Zigbee channel chosen against WLAN channels BEFORE first pairing. - **Matter/TVs/Cast:** Node/Python sidecars speaking MQTT — never native PHP implementations. - **Mobile:** PWA first (manifest + service worker + Web Push via laravel-notification-channels/webpush; iOS supports Web Push for installed PWAs). Expo/React Native only if a concrete PWA limit hurts. NativePHP rejected (runs a second Laravel on the phone — wrong model). --- ## 12. Open decisions — ask the user before committing 1. **DB:** PostgreSQL+TimescaleDB (recommended for telemetry) vs MariaDB (Clusev consistency)? 2. **Laravel version:** 13 assumed (current, Clusev precedent) — original statement said 12. 3. **Project name:** "HomeOS" is a working title. 4. **Git remote:** Gitea like Clusev (`git.bave.dev`)? Repo must be created at bootstrap. 5. **UniFi auth:** local read-only user vs API key on the UDM — needs the user to create it. 6. **Which Ring product** the user owns (relevant only when the ring-mqtt phase starts). --- ## 13. Build order (each phase ends browser-verified, R12 + R15; §10 plugin setup comes first) 1. **Bootstrap** — compose stack up, Laravel installed in-container, git repo + `rules.md` + `CLAUDE.md` created, tokens + fonts + layout shell (sidebar/topbar per mockup), auth + login. 2. **Domain core** — migrations for §3 tables, models, seeders with fake devices; dashboard renders mock state (rooms, KPI row, live feed) — mock first, real hardware after. 3. **MQTT ingest** — mosquitto + mqtt-listener daemon; Shelly state flows into `device_states`; Reverb pushes to the dashboard; toggle switches a real Shelly via `ShellyMqttDriver`; commands audited. 4. **Discovery** — Python sidecar (mDNS/SSDP/ARP → MQTT), `discovery_findings`, "Neue Geräte" panel with assign/ignore flow (per mockup). 5. **Presence** — UnifiClient poller (scheduler), person↔client mapping UI, debounce sweep, presence panel + avatars live. 6. **Automations** — TCA engine (state_change + time triggers; actions: switch entity, notify), cooldown, dry-run mode, audit integration. 7. **Polish + PWA prep** — empty states, `wire:init` loading states, breakpoints pass, manifest groundwork. Definition of done per phase: R12 browser proof (200, console clean, all 3 breakpoints) + R15 Codex clean + commit on a feature branch.