From a4489ac7d3501fadb7c4b2ac67ba32bab41cc6b9 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 01:58:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(addons):=20Ring=20backend=20bridge=20?= =?UTF-8?q?=E2=80=94=20ring-mqtt=20ingest,=20cloud=20devices,=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the Ring integration (handoff §12): - RingTopics + RingNormalizer (H3): parse ring/////state, map ding/motion/contact/lock/battery; bridge status topic drives the addon connection state. Defensive — unknown topics are ignored, never junk devices. - IngestRingMessage (H4, single ingest path): auto-creates cloud-flagged Ring devices on first sight, monotonic race-safe state upsert, broadcasts live. Gated on the addon being installed (cached) so a still-running bridge can't recreate devices after uninstall. - Shared AppliesDeviceState trait: the monotonic upsert now lives once, used by both Shelly and Ring ingest (MqttTest guards the Shelly path). - Listener subscribes ring/#. ring-mqtt container (opt-in `addons` compose profile) as least-privileged `ring` MQTT user (ACL: ring/# only); gen-passwd seeds the account. "Cloud" badge on Ring devices in list + detail. 11 Ring tests (topic parse, normalizer, bridge status, auto-create, install gate, unknown-topic guard, out-of-order). Suite 40 green; 12/12 tabs clean. Live-verified: real MQTT ring/.../ding + info publish → cloud device created with ding + battery state. (Real Ring OAuth login runs in the ring-mqtt sidecar.) Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 + .gitignore | 3 + .reviews/r15-8aa3257.md | 753 ----------- .reviews/r15-d10172a.md | 1184 ----------------- app/Console/Commands/MqttListenCommand.php | 11 +- app/Jobs/Concerns/AppliesDeviceState.php | 54 + app/Jobs/IngestRingMessage.php | 105 ++ app/Jobs/IngestShellyMessage.php | 42 +- app/Models/Device.php | 6 + app/Support/Mqtt/RingNormalizer.php | 61 + app/Support/Mqtt/RingTopics.php | 58 + docker-compose.yml | 24 + docker/mosquitto/config/acl | 5 + docker/mosquitto/gen-passwd.sh | 8 +- .../views/livewire/addons/index.blade.php | 4 + .../views/livewire/devices/index.blade.php | 3 + .../views/livewire/devices/show.blade.php | 3 + tests/Feature/RingIngestTest.php | 97 ++ 18 files changed, 444 insertions(+), 1981 deletions(-) delete mode 100644 .reviews/r15-8aa3257.md delete mode 100644 .reviews/r15-d10172a.md create mode 100644 app/Jobs/Concerns/AppliesDeviceState.php create mode 100644 app/Jobs/IngestRingMessage.php create mode 100644 app/Support/Mqtt/RingNormalizer.php create mode 100644 app/Support/Mqtt/RingTopics.php create mode 100644 tests/Feature/RingIngestTest.php diff --git a/.env.example b/.env.example index 698c2c0..e4cf6ad 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,10 @@ MQTT_ENABLE_LOGGING=false # sidecar password — used only to generate the broker passwd file (see README). # Physical devices get per-device credentials provisioned at onboarding. MQTT_SIDECAR_PASSWORD= +# ring-mqtt bridge account (Ring add-on). Only used when the addons compose profile is up. +MQTT_RING_PASSWORD= +# Ring add-on setup/token web UI port (ring-mqtt). Adjust if your ring-mqtt version differs. +RING_UI_PORT=55123 # ================= docker compose (deployment) ================= HOST_UID=1000 diff --git a/.gitignore b/.gitignore index 35e5f77..33dcc61 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ Thumbs.db /docker/mosquitto/config/reload /docker/mosquitto/data/ /.claude/ + +# Codex R15 review outputs (local scratch) +.reviews/ diff --git a/.reviews/r15-8aa3257.md b/.reviews/r15-8aa3257.md deleted file mode 100644 index 346433f..0000000 --- a/.reviews/r15-8aa3257.md +++ /dev/null @@ -1,753 +0,0 @@ -OpenAI Codex v0.144.5 --------- -workdir: /home/nexxo/homeos -model: gpt-5.6-sol -provider: openai -approval: never -sandbox: read-only -reasoning effort: none -reasoning summaries: none -session id: 019f7266-3d0b-7fb3-a7ca-25213f87961d --------- -user -commit 8aa3257 -warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime. -exec -/bin/bash -lc 'git status --short && git show --stat --oneline --decorate --no-renames 8aa3257 && git show --format=fuller --no-ext-diff --no-renames --find-renames=0 8aa3257' in /home/nexxo/homeos - succeeded in 0ms: -?? .reviews/r15-8aa3257.md -8aa3257 (HEAD -> feat/phase-1-bootstrap, origin/feat/phase-1-bootstrap) feat(pwa): installable app — manifest, service worker, offline shell, icons - docker/nginx/default.conf | 13 ++++++ - public/apple-touch-icon.png | Bin 0 -> 1697 bytes - public/icons/icon-192.png | Bin 0 -> 3087 bytes - public/icons/icon-512.png | Bin 0 -> 9868 bytes - public/icons/icon-maskable-192.png | Bin 0 -> 1316 bytes - public/icons/icon-maskable-512.png | Bin 0 -> 4879 bytes - public/manifest.webmanifest | 56 ++++++++++++++++++++++++ - public/offline.html | 50 +++++++++++++++++++++ - public/sw.js | 74 ++++++++++++++++++++++++++++++++ - resources/js/app.js | 8 ++++ - resources/views/layouts/app.blade.php | 11 +++++ - resources/views/layouts/guest.blade.php | 5 +++ - 12 files changed, 217 insertions(+) -commit 8aa3257fa23e668f54c9fa76c805b3674fd181cf -Author: HomeOS Bootstrap -AuthorDate: Sat Jul 18 01:25:24 2026 +0200 -Commit: HomeOS Bootstrap -CommitDate: Sat Jul 18 01:25:24 2026 +0200 - - feat(pwa): installable app — manifest, service worker, offline shell, icons - - Makes HomeOS installable on the tablet/phone (the tablet-control use case): - - manifest.webmanifest: standalone display, brand colors, 192/512 "any" + - maskable icons, /panel + /rooms shortcuts, start_url /dashboard. - - sw.js: conservative for an authenticated Livewire app — never intercepts - non-GET (login/Livewire/broadcasting stay online), cache-first only for - immutable /build/ + /icons/, network-first navigations with an offline - fallback so authenticated HTML is never served stale. - - offline.html: self-contained branded offline page (no Vite dependency). - - Rendered PNG icons (any + maskable, glyph inside the safe zone) + - apple-touch-icon; manifest/theme-color/apple meta in both layouts; SW - registered from app.js. - - nginx: application/manifest+json MIME, no-cache + Service-Worker-Allowed - for sw.js (compose mount now active on the app container). - - Verified: manifest 200/valid, SW registers+activates (scope /), 11/11 tabs - clean, zero console errors. - - Co-Authored-By: Claude Opus 4.8 - -diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf -index 8739d1a..7501cf3 100644 ---- a/docker/nginx/default.conf -+++ b/docker/nginx/default.conf -@@ -36,6 +36,19 @@ server { - try_files $uri $uri/ /index.php?$query_string; - } - -+ # PWA: correct manifest MIME, and keep the service worker un-cached so updates ship. -+ location = /manifest.webmanifest { -+ types { } -+ default_type application/manifest+json; -+ add_header Cache-Control "public, max-age=3600"; -+ access_log off; -+ } -+ -+ location = /sw.js { -+ add_header Cache-Control "no-cache"; -+ add_header Service-Worker-Allowed "/"; -+ } -+ - location = /favicon.ico { access_log off; log_not_found off; } - location = /robots.txt { access_log off; log_not_found off; } - -diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png -new file mode 100644 -index 0000000..5fcda22 -Binary files /dev/null and b/public/apple-touch-icon.png differ -diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png -new file mode 100644 -index 0000000..1c1adbf -Binary files /dev/null and b/public/icons/icon-192.png differ -diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png -new file mode 100644 -index 0000000..5b39a49 -Binary files /dev/null and b/public/icons/icon-512.png differ -diff --git a/public/icons/icon-maskable-192.png b/public/icons/icon-maskable-192.png -new file mode 100644 -index 0000000..e2e49ec -Binary files /dev/null and b/public/icons/icon-maskable-192.png differ -diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png -new file mode 100644 -index 0000000..613fe07 -Binary files /dev/null and b/public/icons/icon-maskable-512.png differ -diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest -new file mode 100644 -index 0000000..6e99d59 ---- /dev/null -+++ b/public/manifest.webmanifest -@@ -0,0 +1,56 @@ -+{ -+ "name": "HomeOS", -+ "short_name": "HomeOS", -+ "description": "Selbst-gehostete Haussteuerung — Räume, Geräte, Automationen.", -+ "id": "/", -+ "start_url": "/dashboard", -+ "scope": "/", -+ "display": "standalone", -+ "display_override": ["standalone", "minimal-ui"], -+ "orientation": "any", -+ "background_color": "#080D18", -+ "theme_color": "#0D1424", -+ "lang": "de", -+ "dir": "ltr", -+ "categories": ["utilities", "lifestyle"], -+ "icons": [ -+ { -+ "src": "/icons/icon-192.png", -+ "sizes": "192x192", -+ "type": "image/png", -+ "purpose": "any" -+ }, -+ { -+ "src": "/icons/icon-512.png", -+ "sizes": "512x512", -+ "type": "image/png", -+ "purpose": "any" -+ }, -+ { -+ "src": "/icons/icon-maskable-192.png", -+ "sizes": "192x192", -+ "type": "image/png", -+ "purpose": "maskable" -+ }, -+ { -+ "src": "/icons/icon-maskable-512.png", -+ "sizes": "512x512", -+ "type": "image/png", -+ "purpose": "maskable" -+ } -+ ], -+ "shortcuts": [ -+ { -+ "name": "Steuerung", -+ "short_name": "Steuerung", -+ "url": "/panel", -+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] -+ }, -+ { -+ "name": "Räume", -+ "short_name": "Räume", -+ "url": "/rooms", -+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] -+ } -+ ] -+} -diff --git a/public/offline.html b/public/offline.html -new file mode 100644 -index 0000000..02b70aa ---- /dev/null -+++ b/public/offline.html -@@ -0,0 +1,50 @@ -+ -+ -+ -+ -+ -+ Offline · HomeOS -+ -+ -+ -+
-+
-+ -+ -+ -+ -+ -+ -+
-+

Keine Verbindung

-+

HomeOS ist gerade nicht erreichbar. Prüfe deine Netzwerkverbindung zum Server.

-+ -+
-+ -+ -diff --git a/public/sw.js b/public/sw.js -new file mode 100644 -index 0000000..edc756a ---- /dev/null -+++ b/public/sw.js -@@ -0,0 +1,74 @@ -+/** -+ * HomeOS service worker. Deliberately conservative for an authenticated Livewire app: -+ * - never intercepts non-GET (login, Livewire updates, broadcasting/auth stay online-only) -+ * - cache-first only for fingerprinted, immutable assets under /build/ and static icons -+ * - navigations are network-first with an offline fallback, so authenticated HTML is never -+ * served stale from cache -+ * Bump VERSION to invalidate old caches on the next activate. -+ */ -+const VERSION = 'homeos-v1'; -+const STATIC_CACHE = `${VERSION}-static`; -+const OFFLINE_URL = '/offline.html'; -+const PRECACHE = [OFFLINE_URL, '/icons/icon-192.png', '/icons/icon-512.png']; -+ -+self.addEventListener('install', (event) => { -+ event.waitUntil( -+ (async () => { -+ const cache = await caches.open(STATIC_CACHE); -+ await cache.addAll(PRECACHE); -+ await self.skipWaiting(); -+ })(), -+ ); -+}); -+ -+self.addEventListener('activate', (event) => { -+ event.waitUntil( -+ (async () => { -+ const keys = await caches.keys(); -+ await Promise.all(keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k))); -+ await self.clients.claim(); -+ })(), -+ ); -+}); -+ -+self.addEventListener('message', (event) => { -+ if (event.data === 'skipWaiting') self.skipWaiting(); -+}); -+ -+self.addEventListener('fetch', (event) => { -+ const req = event.request; -+ if (req.method !== 'GET') return; // login / Livewire / broadcasting are POST — stay online -+ -+ const url = new URL(req.url); -+ if (url.origin !== self.location.origin) return; // don't touch cross-origin -+ -+ // Immutable, fingerprinted build output + static icons → cache-first. -+ if (url.pathname.startsWith('/build/') || url.pathname.startsWith('/icons/')) { -+ event.respondWith(cacheFirst(req)); -+ return; -+ } -+ -+ // Page loads → network-first, fall back to the offline shell only when truly offline. -+ if (req.mode === 'navigate') { -+ event.respondWith(networkFirst(req)); -+ } -+}); -+ -+async function cacheFirst(req) { -+ const cache = await caches.open(STATIC_CACHE); -+ const hit = await cache.match(req); -+ if (hit) return hit; -+ const res = await fetch(req); -+ if (res && res.ok) cache.put(req, res.clone()); -+ return res; -+} -+ -+async function networkFirst(req) { -+ try { -+ return await fetch(req); -+ } catch (e) { -+ const cache = await caches.open(STATIC_CACHE); -+ const offline = await cache.match(OFFLINE_URL); -+ return offline || Response.error(); -+ } -+} -diff --git a/resources/js/app.js b/resources/js/app.js -index 8337e44..f02bd0f 100644 ---- a/resources/js/app.js -+++ b/resources/js/app.js -@@ -85,3 +85,11 @@ window.Echo = new Echo({ - forceTLS: isHttps, - enabledTransports: ['ws', 'wss'], - }); -+ -+// Register the PWA service worker so HomeOS installs on tablets/phones and survives -+// brief server blips (offline shell). sw.js lives at the web root for a "/" scope. -+if ('serviceWorker' in navigator) { -+ window.addEventListener('load', () => { -+ navigator.serviceWorker.register('/sw.js').catch(() => {}); -+ }); -+} -diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php -index d0c3329..bab9047 100644 ---- a/resources/views/layouts/app.blade.php -+++ b/resources/views/layouts/app.blade.php -@@ -5,6 +5,17 @@ - - - -+ -+ -+ {{-- PWA: installable on tablet/phone (§ tablet control) --}} -+ -+ -+ -+ -+ -+ -+ -+ - {{ $title ?? __('common.app_name') }} · {{ __('common.app_name') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles -diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php -index 0f1251f..e7b80df 100644 ---- a/resources/views/layouts/guest.blade.php -+++ b/resources/views/layouts/guest.blade.php -@@ -5,6 +5,11 @@ - - - -+ -+ -+ -+ -+ - {{ __('auth.title') }} · {{ __('common.app_name') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles - -exec -/bin/bash -lc "sed -n '1,140p' docker/nginx/default.conf; echo '--- compose refs'; grep -R \"default.conf\\|docker/nginx\\|nginx\" -n docker-compose* compose* 2>/dev/null | head -80; echo '--- routes'; grep -R \"dashboard\\|panel\\|rooms\" -n routes | head -60; echo '--- vite'; cat vite.config.js; echo '--- package'; cat package.json" in /home/nexxo/homeos - succeeded in 0ms: -# WebSocket upgrade mapping (http context) -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} - -server { - listen 80 default_server; - listen [::]:80 default_server; - server_name _; - root /var/www/html/public; - index index.php; - - charset utf-8; - client_max_body_size 32M; - - # Reverb (Pusher protocol) proxied same-origin: browser connects to /app/, - # Laravel publishes to /apps//events. Keeps WS on the page's own host/port. - # Docker embedded DNS + variable proxy_pass → runtime resolution (reverb may start - # after nginx, or change IP on restart, without breaking the config). - location ~ ^/(app|apps)(/|$) { - resolver 127.0.0.11 valid=30s ipv6=off; - set $reverb_upstream reverb; - proxy_pass http://$reverb_upstream:8080$request_uri; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 600s; - proxy_send_timeout 600s; - } - - location / { - try_files $uri $uri/ /index.php?$query_string; - } - - # PWA: correct manifest MIME, and keep the service worker un-cached so updates ship. - location = /manifest.webmanifest { - types { } - default_type application/manifest+json; - add_header Cache-Control "public, max-age=3600"; - access_log off; - } - - location = /sw.js { - add_header Cache-Control "no-cache"; - add_header Service-Worker-Allowed "/"; - } - - location = /favicon.ico { access_log off; log_not_found off; } - location = /robots.txt { access_log off; log_not_found off; } - - error_page 404 /index.php; - - location ~ \.php$ { - fastcgi_pass 127.0.0.1:9000; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; - include fastcgi_params; - fastcgi_read_timeout 120s; - } - - location ~ /\.(?!well-known).* { - deny all; - } -} ---- compose refs -docker-compose.yml:16: - ./docker/nginx/default.conf:/etc/nginx/sites-available/default:ro ---- routes -routes/channels.php:14:Broadcast::channel('rooms.{uuid}', fn ($user, string $uuid) => $user !== null); -routes/web.php:20:Route::get('/', fn () => redirect()->route('dashboard')); -routes/web.php:27: Route::get('/dashboard', Dashboard::class)->name('dashboard'); -routes/web.php:28: Route::get('/panel', Panel::class)->name('panel'); -routes/web.php:29: Route::get('/rooms', RoomIndex::class)->name('rooms.index'); -routes/web.php:30: Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show'); ---- vite -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - tailwindcss(), - ], - server: { - host: '0.0.0.0', - port: 5173, - // HMR host for LAN dev; override with VITE_DEV_HOST when accessing via the VM's IP. - hmr: { host: process.env.VITE_DEV_HOST || 'localhost' }, - watch: { - ignored: ['**/storage/framework/views/**'], - }, - }, -}); ---- package -{ - "$schema": "https://www.schemastore.org/package.json", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "concurrently": "^9.0.1", - "laravel-vite-plugin": "^3.1", - "tailwindcss": "^4.0.0", - "vite": "^8.0.0" - }, - "dependencies": { - "chart.js": "^4.5.1", - "laravel-echo": "^2.4.0", - "pusher-js": "^8.5.0", - "sortablejs": "^1.15.7" - } -} - -exec -/bin/bash -lc 'cat docker-compose.yml; git show 8aa3257''^:docker-compose.yml | sed -n '"'1,80p'; git log --oneline --all -- docker/nginx/default.conf | head" in /home/nexxo/homeos - succeeded in 0ms: -# HomeOS — dev stack (dev = prod parity, R8). All app-family services share one image. -# mosquitto / mqtt-listener (Phase 3) and discovery sidecar (Phase 4) are added in their phases. - -x-app-image: &app-image - image: homeos-app:dev - build: - context: . - dockerfile: docker/app/Dockerfile - args: - HOST_UID: ${HOST_UID:-1000} - HOST_GID: ${HOST_GID:-1000} - # Laravel reads its own .env from the mounted volume (correct phpdotenv interpolation). - # Compose still reads root .env for ${...} substitution in this file (ports, uid, db creds). - volumes: - - .:/var/www/html - - ./docker/nginx/default.conf:/etc/nginx/sites-available/default:ro - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - restart: unless-stopped - -services: - app: - <<: *app-image - ports: - - "${APP_PORT:-80}:80" - - "${VITE_PORT:-5173}:5173" - - horizon: - <<: *app-image - command: ["php", "artisan", "horizon"] - ports: [] - - scheduler: - <<: *app-image - command: ["php", "artisan", "schedule:work"] - ports: [] - - reverb: - <<: *app-image - command: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"] - ports: - - "${REVERB_HOST_PORT:-6001}:8080" - - mqtt-listener: - <<: *app-image - command: ["php", "artisan", "mqtt:listen"] - restart: always - ports: [] - - db: - image: timescale/timescaledb:latest-pg17 - environment: - POSTGRES_DB: ${DB_DATABASE:-homeos} - POSTGRES_USER: ${DB_USERNAME:-homeos} - POSTGRES_PASSWORD: ${DB_PASSWORD:-homeos} - volumes: - - db-data:/var/lib/postgresql/data - ports: - - "127.0.0.1:${DB_HOST_PORT:-5432}:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-homeos} -d ${DB_DATABASE:-homeos}"] - interval: 5s - timeout: 5s - retries: 10 - restart: unless-stopped - - redis: - image: redis:7-alpine - command: ["redis-server", "--appendonly", "yes"] - volumes: - - redis-data:/data - restart: unless-stopped - - mosquitto: - image: eclipse-mosquitto:2 - # Wrapper runs mosquitto + reloads it (SIGHUP) when a device credential is provisioned. - command: ["/bin/sh", "/mosquitto/config/entrypoint.sh"] - volumes: - - ./docker/mosquitto/config:/mosquitto/config - - mosquitto-data:/mosquitto/data - ports: - # LAN-reachable so real devices can connect (auth + ACL enforced) - - "${MQTT_HOST_PORT:-1883}:1883" - restart: unless-stopped - - # Discovery sidecar — host network so mDNS/SSDP multicast reaches the smart-home VLAN. - discovery: - build: - context: ./sidecar - dockerfile: Dockerfile - network_mode: host - cap_add: [NET_RAW] - environment: - MQTT_HOST: 127.0.0.1 - MQTT_PORT: ${MQTT_HOST_PORT:-1883} - MQTT_USERNAME: sidecar - MQTT_PASSWORD: ${MQTT_SIDECAR_PASSWORD} - restart: unless-stopped - -volumes: - db-data: - redis-data: - mosquitto-data: -# HomeOS — dev stack (dev = prod parity, R8). All app-family services share one image. -# mosquitto / mqtt-listener (Phase 3) and discovery sidecar (Phase 4) are added in their phases. - -x-app-image: &app-image - image: homeos-app:dev - build: - context: . - dockerfile: docker/app/Dockerfile - args: - HOST_UID: ${HOST_UID:-1000} - HOST_GID: ${HOST_GID:-1000} - # Laravel reads its own .env from the mounted volume (correct phpdotenv interpolation). - # Compose still reads root .env for ${...} substitution in this file (ports, uid, db creds). - volumes: - - .:/var/www/html - - ./docker/nginx/default.conf:/etc/nginx/sites-available/default:ro - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - restart: unless-stopped - -services: - app: - <<: *app-image - ports: - - "${APP_PORT:-80}:80" - - "${VITE_PORT:-5173}:5173" - - horizon: - <<: *app-image - command: ["php", "artisan", "horizon"] - ports: [] - - scheduler: - <<: *app-image - command: ["php", "artisan", "schedule:work"] - ports: [] - - reverb: - <<: *app-image - command: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"] - ports: - - "${REVERB_HOST_PORT:-6001}:8080" - - mqtt-listener: - <<: *app-image - command: ["php", "artisan", "mqtt:listen"] - restart: always - ports: [] - - db: - image: timescale/timescaledb:latest-pg17 - environment: - POSTGRES_DB: ${DB_DATABASE:-homeos} - POSTGRES_USER: ${DB_USERNAME:-homeos} - POSTGRES_PASSWORD: ${DB_PASSWORD:-homeos} - volumes: - - db-data:/var/lib/postgresql/data - ports: - - "127.0.0.1:${DB_HOST_PORT:-5432}:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-homeos} -d ${DB_DATABASE:-homeos}"] - interval: 5s - timeout: 5s - retries: 10 - restart: unless-stopped - - redis: - image: redis:7-alpine - command: ["redis-server", "--appendonly", "yes"] - volumes: - - redis-data:/data - restart: unless-stopped - - mosquitto: - image: eclipse-mosquitto:2 - # Wrapper runs mosquitto + reloads it (SIGHUP) when a device credential is provisioned. - command: ["/bin/sh", "/mosquitto/config/entrypoint.sh"] -8aa3257 feat(pwa): installable app — manifest, service worker, offline shell, icons -beac04e Phase 1: bootstrap — Docker stack, Laravel 13, design system, auth - -exec -/bin/bash -lc "file public/*.png public/icons/*.png; nl -ba public/sw.js | sed -n '20,75p'; nginx -t -c /home/nexxo/homeos/docker/nginx/default.conf 2>&1 || true" in /home/nexxo/homeos - succeeded in 330ms: -public/apple-touch-icon.png: PNG image data, 180 x 180, 8-bit/color RGB, non-interlaced -public/icons/icon-192.png: PNG image data, 192 x 192, 8-bit/color RGBA, non-interlaced -public/icons/icon-512.png: PNG image data, 512 x 512, 8-bit/color RGBA, non-interlaced -public/icons/icon-maskable-192.png: PNG image data, 192 x 192, 8-bit/color RGB, non-interlaced -public/icons/icon-maskable-512.png: PNG image data, 512 x 512, 8-bit/color RGB, non-interlaced - 20 })(), - 21 ); - 22 }); - 23 - 24 self.addEventListener('activate', (event) => { - 25 event.waitUntil( - 26 (async () => { - 27 const keys = await caches.keys(); - 28 await Promise.all(keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k))); - 29 await self.clients.claim(); - 30 })(), - 31 ); - 32 }); - 33 - 34 self.addEventListener('message', (event) => { - 35 if (event.data === 'skipWaiting') self.skipWaiting(); - 36 }); - 37 - 38 self.addEventListener('fetch', (event) => { - 39 const req = event.request; - 40 if (req.method !== 'GET') return; // login / Livewire / broadcasting are POST — stay online - 41 - 42 const url = new URL(req.url); - 43 if (url.origin !== self.location.origin) return; // don't touch cross-origin - 44 - 45 // Immutable, fingerprinted build output + static icons → cache-first. - 46 if (url.pathname.startsWith('/build/') || url.pathname.startsWith('/icons/')) { - 47 event.respondWith(cacheFirst(req)); - 48 return; - 49 } - 50 - 51 // Page loads → network-first, fall back to the offline shell only when truly offline. - 52 if (req.mode === 'navigate') { - 53 event.respondWith(networkFirst(req)); - 54 } - 55 }); - 56 - 57 async function cacheFirst(req) { - 58 const cache = await caches.open(STATIC_CACHE); - 59 const hit = await cache.match(req); - 60 if (hit) return hit; - 61 const res = await fetch(req); - 62 if (res && res.ok) cache.put(req, res.clone()); - 63 return res; - 64 } - 65 - 66 async function networkFirst(req) { - 67 try { - 68 return await fetch(req); - 69 } catch (e) { - 70 const cache = await caches.open(STATIC_CACHE); - 71 const offline = await cache.match(OFFLINE_URL); - 72 return offline || Response.error(); - 73 } - 74 } -/bin/bash: line 1: nginx: command not found - -codex -The PWA setup is generally sound, but runtime caching is unreliable because cache writes are not included in the service worker event lifetime. - -Review comment: - -- [P2] Await runtime cache writes before completing the fetch — /home/nexxo/homeos/public/sw.js:61-63 - On the first request for a non-precached `/build/` asset or icon, `cache.put()` is started but its promise is discarded. Once the `respondWith()` promise resolves, the service worker may be terminated before that write finishes, leaving the asset uncached and defeating cache-first behavior on later or offline requests. Await the write before returning the response, or keep it alive with `event.waitUntil()`. -The PWA setup is generally sound, but runtime caching is unreliable because cache writes are not included in the service worker event lifetime. - -Review comment: - -- [P2] Await runtime cache writes before completing the fetch — /home/nexxo/homeos/public/sw.js:61-63 - On the first request for a non-precached `/build/` asset or icon, `cache.put()` is started but its promise is discarded. Once the `respondWith()` promise resolves, the service worker may be terminated before that write finishes, leaving the asset uncached and defeating cache-first behavior on later or offline requests. Await the write before returning the response, or keep it alive with `event.waitUntil()`. diff --git a/.reviews/r15-d10172a.md b/.reviews/r15-d10172a.md deleted file mode 100644 index faf5b89..0000000 --- a/.reviews/r15-d10172a.md +++ /dev/null @@ -1,1184 +0,0 @@ -OpenAI Codex v0.144.5 --------- -workdir: /home/nexxo/homeos -model: gpt-5.6-sol -provider: openai -approval: never -sandbox: read-only -reasoning effort: none -reasoning summaries: none -session id: 019f725a-883f-7863-92a3-eec5b0fdcd43 --------- -user -commit d10172a -warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime. -exec -/bin/bash -lc 'git status --short && git show --stat --oneline --decorate --no-renames d10172a && git diff d10172a''^ d10172a --no-ext-diff --unified=80 --no-renames' in /home/nexxo/homeos - succeeded in 0ms: -?? .reviews/ -d10172a (HEAD -> feat/phase-1-bootstrap, origin/feat/phase-1-bootstrap) feat(automations): trigger→condition→action engine with cooldown + dry-run - app/Console/Commands/AutomationsTickCommand.php | 21 +++ - app/Listeners/EvaluateAutomations.php | 18 +++ - app/Livewire/Modals/CreateAutomation.php | 129 ++++++++++++++++ - app/Models/Automation.php | 3 +- - app/Providers/AppServiceProvider.php | 11 +- - app/Services/AutomationEngine.php | 95 ++++++++++++ - ...026_07_18_040001_add_dry_run_to_automations.php | 23 +++ - lang/de/automations.php | 19 +++ - lang/en/automations.php | 19 +++ - .../views/livewire/automations/index.blade.php | 9 +- - .../livewire/modals/create-automation.blade.php | 119 +++++++++++++++ - routes/console.php | 3 + - tests/Feature/AutomationTest.php | 168 +++++++++++++++++++++ - 13 files changed, 628 insertions(+), 9 deletions(-) -diff --git a/app/Console/Commands/AutomationsTickCommand.php b/app/Console/Commands/AutomationsTickCommand.php -new file mode 100644 -index 0000000..ab392d3 ---- /dev/null -+++ b/app/Console/Commands/AutomationsTickCommand.php -@@ -0,0 +1,21 @@ -+tick(); -+ -+ return self::SUCCESS; -+ } -+} -diff --git a/app/Listeners/EvaluateAutomations.php b/app/Listeners/EvaluateAutomations.php -new file mode 100644 -index 0000000..513a93a ---- /dev/null -+++ b/app/Listeners/EvaluateAutomations.php -@@ -0,0 +1,18 @@ -+engine->onStateChange($event->deviceUuid, $event->entityKey, $event->state); -+ } -+} -diff --git a/app/Livewire/Modals/CreateAutomation.php b/app/Livewire/Modals/CreateAutomation.php -new file mode 100644 -index 0000000..533094e ---- /dev/null -+++ b/app/Livewire/Modals/CreateAutomation.php -@@ -0,0 +1,129 @@ -+ */ -+ public function getEntityOptionsProperty(): array -+ { -+ return Entity::with('device')->get() -+ ->filter(fn (Entity $e) => $e->device !== null) -+ ->map(fn (Entity $e) => [ -+ 'value' => $e->device->uuid.'|'.$e->key, -+ 'label' => trim(($e->device->name ?? '?').' · '.($e->name ?: $e->key)), -+ 'controllable' => in_array($e->type, ['switch', 'light'], true), -+ ]) -+ ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) -+ ->values()->all(); -+ } -+ -+ public function save() -+ { -+ $rules = [ -+ 'name' => 'required|string|max:100', -+ 'triggerType' => 'required|in:state_change,time', -+ 'actionType' => 'required|in:switch,notify', -+ 'cooldownSeconds' => 'integer|min:0|max:86400', -+ ]; -+ -+ if ($this->triggerType === 'state_change') { -+ $rules['triggerEntity'] = 'required|string'; -+ } else { -+ $rules['triggerAt'] = ['required', 'regex:/^\d{2}:\d{2}$/']; -+ } -+ -+ if ($this->actionType === 'switch') { -+ $rules['actionEntity'] = 'required|string'; -+ } else { -+ $rules['actionMessage'] = 'required|string|max:200'; -+ } -+ -+ $this->validate($rules); -+ -+ Automation::create([ -+ 'name' => $this->name, -+ 'enabled' => true, -+ 'trigger_type' => $this->triggerType, -+ 'trigger_config' => $this->buildTriggerConfig(), -+ 'conditions' => [], -+ 'actions' => [$this->buildAction()], -+ 'cooldown_seconds' => $this->cooldownSeconds, -+ 'dry_run' => $this->dryRun, -+ ]); -+ -+ $this->closeModal(); -+ -+ return redirect()->route('automations.index'); -+ } -+ -+ /** @return array */ -+ private function buildTriggerConfig(): array -+ { -+ if ($this->triggerType === 'time') { -+ return ['at' => $this->triggerAt]; -+ } -+ -+ [$device, $key] = $this->split($this->triggerEntity); -+ -+ return ['device' => $device, 'entity_key' => $key, 'field' => 'on', 'equals' => $this->triggerEquals === '1']; -+ } -+ -+ /** @return array */ -+ private function buildAction(): array -+ { -+ if ($this->actionType === 'notify') { -+ return ['type' => 'notify', 'message' => $this->actionMessage]; -+ } -+ -+ [$device, $key] = $this->split($this->actionEntity); -+ -+ return ['type' => 'switch', 'device' => $device, 'entity_key' => $key, 'on' => $this->actionOn === '1']; -+ } -+ -+ /** @return array{0:?string,1:?string} */ -+ private function split(string $value): array -+ { -+ $parts = explode('|', $value, 2); -+ -+ return [$parts[0] ?? null, $parts[1] ?? null]; -+ } -+ -+ public function render() -+ { -+ return view('livewire.modals.create-automation'); -+ } -+} -diff --git a/app/Models/Automation.php b/app/Models/Automation.php -index 75d4919..9ffcaee 100644 ---- a/app/Models/Automation.php -+++ b/app/Models/Automation.php -@@ -1,24 +1,25 @@ - 'boolean', -+ 'dry_run' => 'boolean', - 'trigger_config' => 'array', - 'conditions' => 'array', - 'actions' => 'array', - 'last_triggered_at' => 'datetime', - ]; - } -diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php -index 452e6b6..68c7520 100644 ---- a/app/Providers/AppServiceProvider.php -+++ b/app/Providers/AppServiceProvider.php -@@ -1,24 +1,21 @@ - where('trigger_type', 'state_change')->get() -+ ->each(function (Automation $automation) use ($deviceUuid, $entityKey, $state) { -+ if ($this->matchesStateTrigger($automation, $deviceUuid, $entityKey, $state)) { -+ $this->maybeRun($automation); -+ } -+ }); -+ } -+ -+ /** Evaluate time-based automations (called every minute by the scheduler). */ -+ public function tick(): void -+ { -+ $hhmm = now()->format('H:i'); -+ -+ Automation::where('enabled', true)->where('trigger_type', 'time')->get() -+ ->each(function (Automation $automation) use ($hhmm) { -+ if (data_get($automation->trigger_config, 'at') === $hhmm) { -+ $this->maybeRun($automation); -+ } -+ }); -+ } -+ -+ private function matchesStateTrigger(Automation $automation, string $deviceUuid, string $entityKey, array $state): bool -+ { -+ $cfg = $automation->trigger_config ?? []; -+ -+ if (($cfg['device'] ?? null) !== $deviceUuid || ($cfg['entity_key'] ?? null) !== $entityKey) { -+ return false; -+ } -+ -+ $field = $cfg['field'] ?? 'on'; -+ -+ return array_key_exists($field, $state) && $state[$field] == ($cfg['equals'] ?? true); -+ } -+ -+ private function maybeRun(Automation $automation): void -+ { -+ if ($automation->cooldown_seconds > 0 -+ && $automation->last_triggered_at !== null -+ && $automation->last_triggered_at->gt(now()->subSeconds($automation->cooldown_seconds))) { -+ return; -+ } -+ -+ foreach ($automation->actions ?? [] as $action) { -+ $this->runAction($automation, $action); -+ } -+ -+ $automation->forceFill(['last_triggered_at' => now()])->save(); -+ } -+ -+ private function runAction(Automation $automation, array $action): void -+ { -+ $type = $action['type'] ?? null; -+ -+ if ($type === 'switch') { -+ $entity = Entity::query() -+ ->whereHas('device', fn ($q) => $q->where('uuid', $action['device'] ?? null)) -+ ->where('key', $action['entity_key'] ?? null) -+ ->with('device', 'state')->first(); -+ -+ if ($entity === null) { -+ return; -+ } -+ -+ if ($automation->dry_run) { -+ Log::info("[automation dry-run] {$automation->name}: switch {$entity->key}", $action); -+ -+ return; -+ } -+ -+ $this->commands->setOn($entity, (bool) ($action['on'] ?? true), 'automation'); -+ } elseif ($type === 'notify') { -+ Log::info("[automation".($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".($action['message'] ?? '')); -+ } -+ } -+} -diff --git a/database/migrations/2026_07_18_040001_add_dry_run_to_automations.php b/database/migrations/2026_07_18_040001_add_dry_run_to_automations.php -new file mode 100644 -index 0000000..644ed24 ---- /dev/null -+++ b/database/migrations/2026_07_18_040001_add_dry_run_to_automations.php -@@ -0,0 +1,23 @@ -+boolean('dry_run')->default(false)->after('cooldown_seconds'); -+ }); -+ } -+ -+ public function down(): void -+ { -+ Schema::table('automations', function (Blueprint $table) { -+ $table->dropColumn('dry_run'); -+ }); -+ } -+}; -diff --git a/lang/de/automations.php b/lang/de/automations.php -index 0f3aa0e..b6ae031 100644 ---- a/lang/de/automations.php -+++ b/lang/de/automations.php -@@ -1,9 +1,28 @@ - 'Regeln: Auslöser → Bedingung → Aktion.', - 'empty_title' => 'Noch keine Automationen', - 'empty_body' => 'Erstelle Regeln, die bei Zustandsänderungen oder zu bestimmten Zeiten Geräte schalten oder benachrichtigen.', - 'trigger_state_change' => 'Bei Zustandsänderung', - 'trigger_time' => 'Zeitgesteuert', -+ 'add' => 'Automation erstellen', -+ 'add_title' => 'Neue Automation', -+ 'name' => 'Name', -+ 'trigger' => 'Auslöser', -+ 'trigger_entity' => 'Gerät / Entität', -+ 'trigger_equals' => 'Zustand', -+ 'trigger_at' => 'Uhrzeit', -+ 'action' => 'Aktion', -+ 'action_switch' => 'Gerät schalten', -+ 'action_notify' => 'Benachrichtigen', -+ 'action_entity' => 'Ziel-Gerät', -+ 'action_on' => 'Schalten auf', -+ 'message' => 'Nachricht', -+ 'cooldown' => 'Abklingzeit (Sekunden)', -+ 'cooldown_hint' => 'Mindestabstand zwischen zwei Auslösungen. 0 = keine.', -+ 'dry_run' => 'Testlauf', -+ 'dry_run_hint' => 'Protokolliert die Aktion, ohne wirklich zu schalten.', -+ 'state_on' => 'An', -+ 'state_off' => 'Aus', - ]; -diff --git a/lang/en/automations.php b/lang/en/automations.php -index 826359d..ecdebb7 100644 ---- a/lang/en/automations.php -+++ b/lang/en/automations.php -@@ -1,9 +1,28 @@ - 'Rules: trigger → condition → action.', - 'empty_title' => 'No automations yet', - 'empty_body' => 'Create rules that switch devices or notify on state changes or at set times.', - 'trigger_state_change' => 'On state change', - 'trigger_time' => 'Time-based', -+ 'add' => 'Create automation', -+ 'add_title' => 'New automation', -+ 'name' => 'Name', -+ 'trigger' => 'Trigger', -+ 'trigger_entity' => 'Device / entity', -+ 'trigger_equals' => 'State', -+ 'trigger_at' => 'Time', -+ 'action' => 'Action', -+ 'action_switch' => 'Switch device', -+ 'action_notify' => 'Notify', -+ 'action_entity' => 'Target device', -+ 'action_on' => 'Switch to', -+ 'message' => 'Message', -+ 'cooldown' => 'Cooldown (seconds)', -+ 'cooldown_hint' => 'Minimum gap between two runs. 0 = none.', -+ 'dry_run' => 'Dry run', -+ 'dry_run_hint' => 'Logs the action without actually switching.', -+ 'state_on' => 'On', -+ 'state_off' => 'Off', - ]; -diff --git a/resources/views/livewire/automations/index.blade.php b/resources/views/livewire/automations/index.blade.php -index f01c648..1a83293 100644 ---- a/resources/views/livewire/automations/index.blade.php -+++ b/resources/views/livewire/automations/index.blade.php -@@ -1,32 +1,39 @@ -
-- -+ -+ -+ -+ -+ - -
- @if ($automations->isEmpty()) - -
- -
-
{{ __('automations.empty_title') }}
-
{{ __('automations.empty_body') }}
-
-
-
- @else - -
- @foreach ($automations as $a) -
- -
-
{{ $a->name }}
-
{{ __('automations.trigger_'.$a->trigger_type) }}
-
- -
- @endforeach -
-
- @endif -
-
-diff --git a/resources/views/livewire/modals/create-automation.blade.php b/resources/views/livewire/modals/create-automation.blade.php -new file mode 100644 -index 0000000..8406e69 ---- /dev/null -+++ b/resources/views/livewire/modals/create-automation.blade.php -@@ -0,0 +1,119 @@ -+
-+
-+ -+

{{ __('automations.add_title') }}

-+ -+
-+ -+
-+
-+ -+ -+ @error('name')

{{ $message }}

@enderror -+
-+ -+ {{-- Trigger --}} -+
-+ {{ __('automations.trigger') }} -+ -+
-+ -+
-+ -+ @if ($triggerType === 'state_change') -+
-+ -+ -+ @error('triggerEntity')

{{ $message }}

@enderror -+
-+
-+ -+ -+
-+ @else -+
-+ -+ -+ @error('triggerAt')

{{ $message }}

@enderror -+
-+ @endif -+
-+ -+ {{-- Action --}} -+
-+ {{ __('automations.action') }} -+ -+
-+ -+
-+ -+ @if ($actionType === 'switch') -+
-+ -+ -+ @error('actionEntity')

{{ $message }}

@enderror -+
-+
-+ -+ -+
-+ @else -+
-+ -+ -+ @error('actionMessage')

{{ $message }}

@enderror -+
-+ @endif -+
-+ -+
-+ -+ -+

{{ __('automations.cooldown_hint') }}

-+ @error('cooldownSeconds')

{{ $message }}

@enderror -+
-+ -+ -+ -+
-+ -+ -+
-+
-+
-diff --git a/routes/console.php b/routes/console.php -index eda4d86..ae228e1 100644 ---- a/routes/console.php -+++ b/routes/console.php -@@ -1,15 +1,18 @@ - comment(Inspiring::quote()); - })->purpose('Display an inspiring quote'); - - // Presence sweep — "home" immediately on association, "away" after a debounce (in the command). - Schedule::command('presence:poll')->everyMinute()->withoutOverlapping(); - - // Metrics sample for the charts (MQTT throughput + host CPU/memory). - Schedule::command('metrics:sample')->everyMinute()->withoutOverlapping(); -+ -+// Time-triggered automations (state-change ones fire off the DeviceStateChanged listener). -+Schedule::command('automations:tick')->everyMinute()->withoutOverlapping(); -diff --git a/tests/Feature/AutomationTest.php b/tests/Feature/AutomationTest.php -new file mode 100644 -index 0000000..86c8e5b ---- /dev/null -+++ b/tests/Feature/AutomationTest.php -@@ -0,0 +1,168 @@ -+app->bind(ShellyMqttDriver::class, fn () => new class implements DeviceDriver -+ { -+ public function turnOn(Entity $entity): void {} -+ -+ public function turnOff(Entity $entity): void {} -+ -+ public function setState(Entity $entity, array $state): void {} -+ -+ public function setLight(Entity $entity, array $params): void {} -+ -+ public function reboot(Device $device): void {} -+ -+ public function capabilities(): array -+ { -+ return []; -+ } -+ }); -+ } -+ -+ /** @return array{0:Device,1:Entity} */ -+ private function makeSwitch(): array -+ { -+ $device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active']); -+ $entity = Entity::create(['device_id' => $device->id, 'type' => 'switch', 'key' => 'switch:0']); -+ DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => true]]); -+ -+ return [$device, $entity]; -+ } -+ -+ public function test_state_change_trigger_runs_switch_action_and_audits_as_automation(): void -+ { -+ [$trigger, $triggerEntity] = $this->makeSwitch(); -+ [$target, $targetEntity] = $this->makeSwitch(); -+ -+ Automation::create([ -+ 'name' => 'Motion turns light off', -+ 'enabled' => true, -+ 'trigger_type' => 'state_change', -+ 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], -+ 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], -+ 'cooldown_seconds' => 0, -+ ]); -+ -+ app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); -+ -+ $this->assertDatabaseHas('commands', ['source' => 'automation', 'command' => 'turn_off', 'result' => 'ok']); -+ } -+ -+ public function test_trigger_does_not_fire_when_value_differs(): void -+ { -+ [$trigger, $triggerEntity] = $this->makeSwitch(); -+ [$target, $targetEntity] = $this->makeSwitch(); -+ -+ Automation::create([ -+ 'name' => 'On only', -+ 'enabled' => true, -+ 'trigger_type' => 'state_change', -+ 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], -+ 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], -+ 'cooldown_seconds' => 0, -+ ]); -+ -+ app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => false]); -+ -+ $this->assertDatabaseCount('commands', 0); -+ } -+ -+ public function test_cooldown_prevents_a_second_run(): void -+ { -+ [$trigger, $triggerEntity] = $this->makeSwitch(); -+ [$target, $targetEntity] = $this->makeSwitch(); -+ -+ Automation::create([ -+ 'name' => 'Debounced', -+ 'enabled' => true, -+ 'trigger_type' => 'state_change', -+ 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], -+ 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], -+ 'cooldown_seconds' => 300, -+ ]); -+ -+ $engine = app(AutomationEngine::class); -+ $engine->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); -+ $engine->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); -+ -+ $this->assertDatabaseCount('commands', 1); -+ } -+ -+ public function test_dry_run_does_not_issue_a_command(): void -+ { -+ [$trigger, $triggerEntity] = $this->makeSwitch(); -+ [$target, $targetEntity] = $this->makeSwitch(); -+ -+ Automation::create([ -+ 'name' => 'Dry', -+ 'enabled' => true, -+ 'trigger_type' => 'state_change', -+ 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], -+ 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], -+ 'cooldown_seconds' => 0, -+ 'dry_run' => true, -+ ]); -+ -+ app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); -+ -+ $this->assertDatabaseCount('commands', 0); -+ } -+ -+ public function test_time_trigger_fires_on_matching_minute(): void -+ { -+ [$target, $targetEntity] = $this->makeSwitch(); -+ -+ Automation::create([ -+ 'name' => 'Morning light', -+ 'enabled' => true, -+ 'trigger_type' => 'time', -+ 'trigger_config' => ['at' => now()->format('H:i')], -+ 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => true]], -+ 'cooldown_seconds' => 0, -+ ]); -+ -+ app(AutomationEngine::class)->tick(); -+ -+ $this->assertDatabaseHas('commands', ['source' => 'automation', 'command' => 'turn_on']); -+ } -+ -+ public function test_disabled_automation_never_runs(): void -+ { -+ [$trigger, $triggerEntity] = $this->makeSwitch(); -+ [$target, $targetEntity] = $this->makeSwitch(); -+ -+ Automation::create([ -+ 'name' => 'Off', -+ 'enabled' => false, -+ 'trigger_type' => 'state_change', -+ 'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true], -+ 'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]], -+ 'cooldown_seconds' => 0, -+ ]); -+ -+ app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]); -+ -+ $this->assertDatabaseCount('commands', 0); -+ } -+} - -exec -/bin/bash -lc "sed -n '1,160p' app/Events/DeviceStateChanged.php; sed -n '1,240p' app/Services/DeviceCommandService.php; grep -R \"DeviceStateChanged\" -n app tests | head -30; grep -R \"conditions\" -n app database tests | head -50; cat database/migrations/*create_automations*" in /home/nexxo/homeos - exited 1 in 0ms: - $this->deviceUuid, - 'entity' => $this->entityKey, - 'state' => $this->state, - ]; - } -} -state?->state, 'on', false); - - return $this->setOn($entity, ! $current, $source); - } - - public function setOn(Entity $entity, bool $on, string $source = 'user'): Command - { - return $this->run( - $entity->device, - $entity, - $on ? 'turn_on' : 'turn_off', - ['on' => $on], - $source, - fn (DeviceDriver $driver) => $on ? $driver->turnOn($entity) : $driver->turnOff($entity), - ); - } - - public function reboot(Device $device, string $source = 'user'): Command - { - return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device)); - } - - public function setLight(Entity $entity, array $params, string $source = 'user'): Command - { - return $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params)); - } - - private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command - { - $result = 'ok'; - - try { - $action($this->driverFor($device)); - } catch (\Throwable $e) { - report($e); - $result = 'error: '.$e->getMessage(); - } - - return Command::create([ - 'device_id' => $device->id, - 'entity_id' => $entity?->id, - 'source' => $source, - 'user_id' => Auth::id(), - 'command' => $command, - 'payload' => $payload, - 'result' => $result, - ]); - } - - private function driverFor(Device $device): DeviceDriver - { - return match (true) { - $device->vendor === 'Shelly' && $device->protocol === 'mqtt' => app(ShellyMqttDriver::class), - default => throw new \RuntimeException("No driver for {$device->vendor}/{$device->protocol}."), - }; - } -} -app/Listeners/EvaluateAutomations.php:5:use App\Events\DeviceStateChanged; -app/Listeners/EvaluateAutomations.php:14: public function handle(DeviceStateChanged $event): void -app/Providers/AppServiceProvider.php:5:use App\Events\DeviceStateChanged; -app/Providers/AppServiceProvider.php:19: Event::listen(DeviceStateChanged::class, EvaluateAutomations::class); -app/Events/DeviceStateChanged.php:15:class DeviceStateChanged implements ShouldBroadcastNow -app/Events/DeviceStateChanged.php:32: return 'DeviceStateChanged'; -app/Jobs/IngestShellyMessage.php:5:use App\Events\DeviceStateChanged; -app/Jobs/IngestShellyMessage.php:76: DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']); -app/Livewire/Panel.php:18: #[On('echo-private:home,.DeviceStateChanged')] -app/Livewire/Panel.php:19: public function onDeviceStateChanged(): void {} -app/Livewire/Devices/Show.php:61: #[On('echo-private:home,.DeviceStateChanged')] -app/Livewire/Devices/Show.php:62: public function onDeviceStateChanged(): void -app/Livewire/Rooms/Show.php:22: #[On('echo-private:home,.DeviceStateChanged')] -app/Livewire/Rooms/Show.php:23: public function onDeviceStateChanged(): void {} -app/Livewire/Rooms/Index.php:13: #[On('echo-private:home,.DeviceStateChanged')] -app/Livewire/Rooms/Index.php:14: public function onDeviceStateChanged(): void {} -app/Livewire/Dashboard.php:15: #[On('echo-private:home,.DeviceStateChanged')] -app/Livewire/Dashboard.php:16: public function onDeviceStateChanged(): void -app/Livewire/Windows.php:13: #[On('echo-private:home,.DeviceStateChanged')] -app/Livewire/Windows.php:14: public function onDeviceStateChanged(): void {} -app/Models/Automation.php:14: 'conditions', 'actions', 'cooldown_seconds', 'dry_run', 'last_triggered_at', -app/Models/Automation.php:21: 'conditions' => 'array', -app/Livewire/Modals/CreateAutomation.php:82: 'conditions' => [], -database/migrations/2026_07_18_000001_create_persons_discovery_automations_tables.php:47: $table->json('conditions')->nullable(); -database/seeders/DemoHomeSeeder.php:15: * conditions (an offline device, low batteries, an open window) to exercise the warnings. -cat: 'database/migrations/*create_automations*': No such file or directory - -exec -/bin/bash -lc "sed -n '35,90p' app/Jobs/IngestShellyMessage.php; sed -n '35,70p' database/migrations/2026_07_18_000001_create_persons_discovery_automations_tables.php; cat config/queue.php | sed -n '1,80p'; grep -R \"QUEUE_CONNECTION\" -n .env.example phpunit.xml*" in /home/nexxo/homeos - succeeded in 0ms: - try { - \Illuminate\Support\Facades\Redis::connection()->incr('metrics:mqtt:count'); - } catch (\Throwable) { - // metrics are best-effort - } - - $parsed = ShellyTopics::parseStatus($this->topic); - if ($parsed === null) { - return; - } - - [$prefix, $component] = $parsed; - - // Unknown devices are handled by discovery (Phase 4), not silently created here. - $device = Device::where('config->mqtt_prefix', $prefix)->first(); - if ($device === null) { - return; - } - - $data = json_decode($this->payload, true); - if (! is_array($data)) { - return; - } - - $observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000); - $observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000)); - - // Presence reflects when the message was RECEIVED, and only ever advances. The guard - // lives in the WHERE clause (one atomic UPDATE), so concurrent workers can't rewind it - // via read-then-write — queue latency, retries or stale messages never mark it wrong. - Device::whereKey($device->id) - ->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime)) - ->update(['last_seen_at' => $observedTime, 'updated_at' => now()]); - - foreach (ShellyNormalizer::normalize($component, $data) as $update) { - $entity = $device->entities()->firstOrCreate( - ['key' => $update['key']], - ['type' => $update['type']], - ); - - if ($this->applyState($entity->id, $update['state'], $observedAt)) { - DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']); - } - } - } - - /** - * Monotonic, race-safe upsert. Returns true when this message's state was applied, - * false when it was stale (an equal-or-newer state is already stored). - */ - private function applyState(int $entityId, array $state, int $observedAt): bool - { - $encoded = json_encode($state); - - // Overwrite an existing row only when this message is newer. - $updated = DeviceState::query() - $table->timestamps(); - - $table->unique(['source', 'identifier']); - }); - - Schema::create('automations', function (Blueprint $table) { - $table->id(); - $table->uuid()->unique(); - $table->string('name'); - $table->boolean('enabled')->default(true); - $table->string('trigger_type'); // state_change | time - $table->json('trigger_config')->nullable(); - $table->json('conditions')->nullable(); - $table->json('actions')->nullable(); - $table->unsignedInteger('cooldown_seconds')->default(0); - $table->timestamp('last_triggered_at')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('automations'); - Schema::dropIfExists('discovery_findings'); - Schema::dropIfExists('persons'); - } -}; - env('QUEUE_CONNECTION', 'database'), - - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection options for every queue backend - | used by your application. An example configuration is provided for - | each backend supported by Laravel. You're also free to add more. - | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", - | "deferred", "background", "failover", "null" - | - */ - - 'connections' => [ - - 'sync' => [ - 'driver' => 'sync', - ], - - 'database' => [ - 'driver' => 'database', - 'connection' => env('DB_QUEUE_CONNECTION'), - 'table' => env('DB_QUEUE_TABLE', 'jobs'), - 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), - 'after_commit' => false, - ], - - 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), - 'queue' => env('BEANSTALKD_QUEUE', 'default'), - 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), - 'block_for' => 0, - 'after_commit' => false, - ], - - 'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), - 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), - 'block_for' => null, - 'after_commit' => false, - ], - - 'deferred' => [ - 'driver' => 'deferred', - ], - - 'background' => [ -.env.example:40:QUEUE_CONNECTION=redis -phpunit.xml:30: - -codex -The engine ignores persisted automation conditions and its cooldown check is race-prone with the configured queued listener. Both issues can cause device actions to execute when they should not. - -Full review comments: - -- [P1] Evaluate conditions before executing actions — /home/nexxo/homeos/app/Services/AutomationEngine.php:62-64 - Automations with non-empty `conditions` currently execute whenever their trigger matches because `maybeRun()` never inspects the condition configuration. This turns a trigger→condition→action rule into an unconditional action and can switch devices when its stored conditions are false; evaluate all configured conditions before entering the action loop. - -- [P1] Claim the cooldown atomically before running actions — /home/nexxo/homeos/app/Services/AutomationEngine.php:54-58 - When multiple queue workers process rapid state changes, each job can read the same old `last_triggered_at`, pass this check, and issue the action before either saves the new timestamp. Thus the cooldown intended to suppress event floods does not prevent duplicate commands under concurrency; reserve the run with a transaction, lock, or conditional atomic update before executing actions. -The engine ignores persisted automation conditions and its cooldown check is race-prone with the configured queued listener. Both issues can cause device actions to execute when they should not. - -Full review comments: - -- [P1] Evaluate conditions before executing actions — /home/nexxo/homeos/app/Services/AutomationEngine.php:62-64 - Automations with non-empty `conditions` currently execute whenever their trigger matches because `maybeRun()` never inspects the condition configuration. This turns a trigger→condition→action rule into an unconditional action and can switch devices when its stored conditions are false; evaluate all configured conditions before entering the action loop. - -- [P1] Claim the cooldown atomically before running actions — /home/nexxo/homeos/app/Services/AutomationEngine.php:54-58 - When multiple queue workers process rapid state changes, each job can read the same old `last_triggered_at`, pass this check, and issue the action before either saves the new timestamp. Thus the cooldown intended to suppress event floods does not prevent duplicate commands under concurrency; reserve the run with a transaction, lock, or conditional atomic update before executing actions. diff --git a/app/Console/Commands/MqttListenCommand.php b/app/Console/Commands/MqttListenCommand.php index f6a9b7a..8ebd85a 100644 --- a/app/Console/Commands/MqttListenCommand.php +++ b/app/Console/Commands/MqttListenCommand.php @@ -3,7 +3,9 @@ namespace App\Console\Commands; use App\Jobs\IngestDiscoveryMessage; +use App\Jobs\IngestRingMessage; use App\Jobs\IngestShellyMessage; +use App\Support\Mqtt\RingTopics; use App\Support\Mqtt\ShellyTopics; use Illuminate\Console\Command; use PhpMqtt\Client\Facades\MQTT; @@ -45,7 +47,14 @@ class MqttListenCommand extends Command IngestDiscoveryMessage::dispatch($topic, $message); }, 0); - $this->info('[mqtt] connected + subscribed to '.implode(', ', ShellyTopics::subscriptions()).', homeos/discovery/#'); + // Ring devices via the ring-mqtt bridge (installable add-on). + foreach (RingTopics::subscriptions() as $topic) { + $this->client->subscribe($topic, function (string $topic, string $message): void { + IngestRingMessage::dispatch($topic, $message, (int) round(microtime(true) * 1_000_000)); + }, 0); + } + + $this->info('[mqtt] connected + subscribed to '.implode(', ', ShellyTopics::subscriptions()).', homeos/discovery/#, '.implode(', ', RingTopics::subscriptions())); $backoff = 1; $this->client->loop(true); // blocks until interrupt() diff --git a/app/Jobs/Concerns/AppliesDeviceState.php b/app/Jobs/Concerns/AppliesDeviceState.php new file mode 100644 index 0000000..74d95c1 --- /dev/null +++ b/app/Jobs/Concerns/AppliesDeviceState.php @@ -0,0 +1,54 @@ + $state + */ + protected function applyState(int $entityId, array $state, int $observedAt): bool + { + $encoded = json_encode($state); + + // Overwrite an existing row only when this message is newer. + $updated = DeviceState::query() + ->where('entity_id', $entityId) + ->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt)) + ->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]); + + if ($updated > 0) { + return true; + } + + // No row yet → insert (unique(entity_id) guards against a concurrent insert). + if (! DeviceState::where('entity_id', $entityId)->exists()) { + try { + DeviceState::create(['entity_id' => $entityId, 'state' => $state, 'observed_at' => $observedAt]); + + return true; + } catch (UniqueConstraintViolationException) { + // Lost the insert race — fall through and retry the conditional update. + return DeviceState::query() + ->where('entity_id', $entityId) + ->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt)) + ->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]) > 0; + } + } + + // Existing row is equal-or-newer → this message is stale. + return false; + } +} diff --git a/app/Jobs/IngestRingMessage.php b/app/Jobs/IngestRingMessage.php new file mode 100644 index 0000000..4ade85e --- /dev/null +++ b/app/Jobs/IngestRingMessage.php @@ -0,0 +1,105 @@ +incr('metrics:mqtt:count'); + } catch (\Throwable) { + // metrics are best-effort + } + + // The bridge's own health topic drives the add-on connection status. + if (RingTopics::isBridgeStatus($this->topic)) { + $online = strtolower(trim($this->payload)) === 'online'; + app(AddonService::class)->markStatus('ring', $online ? 'connected' : 'error', + $online ? null : 'bridge offline'); + + return; + } + + // Ignore everything unless the user actually installed Ring — stops a still-running + // bridge from recreating devices after an uninstall. Cached so motion floods stay cheap. + if (! $this->ringInstalled()) { + return; + } + + $parsed = RingTopics::parseState($this->topic); + if ($parsed === null) { + return; + } + + $updates = RingNormalizer::normalize($parsed['entity'], $this->payload); + if ($updates === []) { + return; + } + + $observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000); + $observedTime = Carbon::createFromTimestampMs(intdiv($observedAt, 1000)); + + $device = $this->resolveDevice($parsed['ring_id'], $parsed['category']); + + Device::whereKey($device->id) + ->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime)) + ->update(['last_seen_at' => $observedTime, 'updated_at' => now()]); + + foreach ($updates as $update) { + $entity = $device->entities()->firstOrCreate( + ['key' => $update['key']], + ['type' => $update['type']], + ); + + if ($this->applyState($entity->id, $update['state'], $observedAt)) { + DeviceStateChanged::dispatch($device->uuid, $entity->key, $update['state']); + } + } + } + + private function resolveDevice(string $ringId, string $category): Device + { + return Device::where('config->ring_id', $ringId)->first() + ?? Device::create([ + 'name' => Str::headline($category).' · '.Str::upper(Str::substr($ringId, -4)), + 'vendor' => 'Ring', + 'protocol' => 'mqtt', + 'status' => 'active', + 'last_seen_at' => now(), + 'config' => ['cloud' => true, 'ring_id' => $ringId, 'ring_category' => $category], + ]); + } + + private function ringInstalled(): bool + { + return Cache::remember('addon:ring:installed', 15, fn () => Addon::where('key', 'ring')->where('installed', true)->exists()); + } +} diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php index 9acf9f4..4118e56 100644 --- a/app/Jobs/IngestShellyMessage.php +++ b/app/Jobs/IngestShellyMessage.php @@ -3,13 +3,11 @@ namespace App\Jobs; use App\Events\DeviceStateChanged; +use App\Jobs\Concerns\AppliesDeviceState; use App\Models\Device; -use App\Models\DeviceState; -use App\Models\Entity; use App\Support\Mqtt\ShellyNormalizer; use App\Support\Mqtt\ShellyTopics; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Foundation\Queue\Queueable; /** @@ -22,6 +20,7 @@ use Illuminate\Foundation\Queue\Queueable; */ class IngestShellyMessage implements ShouldQueue { + use AppliesDeviceState; use Queueable; public function __construct( @@ -77,41 +76,4 @@ class IngestShellyMessage implements ShouldQueue } } } - - /** - * Monotonic, race-safe upsert. Returns true when this message's state was applied, - * false when it was stale (an equal-or-newer state is already stored). - */ - private function applyState(int $entityId, array $state, int $observedAt): bool - { - $encoded = json_encode($state); - - // Overwrite an existing row only when this message is newer. - $updated = DeviceState::query() - ->where('entity_id', $entityId) - ->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt)) - ->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]); - - if ($updated > 0) { - return true; - } - - // No row yet → insert (unique(entity_id) guards against a concurrent insert). - if (! DeviceState::where('entity_id', $entityId)->exists()) { - try { - DeviceState::create(['entity_id' => $entityId, 'state' => $state, 'observed_at' => $observedAt]); - - return true; - } catch (UniqueConstraintViolationException) { - // Lost the insert race — fall through and retry the conditional update. - return DeviceState::query() - ->where('entity_id', $entityId) - ->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt)) - ->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]) > 0; - } - } - - // Existing row is equal-or-newer → this message is stale. - return false; - } } diff --git a/app/Models/Device.php b/app/Models/Device.php index d41c7aa..d6b8d5a 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -49,4 +49,10 @@ class Device extends Model return $this->demo; } + + /** Cloud-bridged (e.g. Ring) — surfaced with a "Cloud" badge so the dependency is visible. */ + public function isCloud(): bool + { + return (bool) data_get($this->config, 'cloud', false); + } } diff --git a/app/Support/Mqtt/RingNormalizer.php b/app/Support/Mqtt/RingNormalizer.php new file mode 100644 index 0000000..e5b25c0 --- /dev/null +++ b/app/Support/Mqtt/RingNormalizer.php @@ -0,0 +1,61 @@ +}> + */ + public static function normalize(string $entity, string $rawPayload): array + { + return match ($entity) { + 'ding' => [['key' => 'ding', 'type' => 'ding', 'state' => ['on' => self::isOn($rawPayload)]]], + 'motion' => [['key' => 'motion', 'type' => 'motion', 'state' => ['on' => self::isOn($rawPayload)]]], + 'contact', 'contactSensor', 'contact_sensor' => [['key' => 'contact', 'type' => 'contact', 'state' => ['open' => self::isOpen($rawPayload)]]], + 'lock' => [['key' => 'lock', 'type' => 'lock', 'state' => ['locked' => self::isLocked($rawPayload)]]], + 'info' => self::fromInfo($rawPayload), + default => [], + }; + } + + /** The info topic carries a JSON blob with battery/signal attributes. */ + private static function fromInfo(string $rawPayload): array + { + $data = json_decode($rawPayload, true); + if (! is_array($data)) { + return []; + } + + $updates = []; + + $battery = $data['batteryLevel'] ?? $data['battery_level'] ?? null; + if ($battery !== null && is_numeric($battery)) { + $updates[] = ['key' => 'battery', 'type' => 'battery', 'state' => ['percent' => (int) $battery]]; + } + + return $updates; + } + + private static function isOn(string $payload): bool + { + return in_array(strtoupper(trim($payload)), ['ON', 'TRUE', '1'], true); + } + + private static function isOpen(string $payload): bool + { + return in_array(strtolower(trim($payload)), ['open', 'on', 'true', '1'], true); + } + + private static function isLocked(string $payload): bool + { + return strtolower(trim($payload)) === 'locked'; + } +} diff --git a/app/Support/Mqtt/RingTopics.php b/app/Support/Mqtt/RingTopics.php new file mode 100644 index 0000000..a8bf5b1 --- /dev/null +++ b/app/Support/Mqtt/RingTopics.php @@ -0,0 +1,58 @@ +////state` and a bridge health topic under + * `ring//status`. Vendor specifics stay here (H3). + * + * NOTE: the exact per-entity topic shape should be validated against a live ring-mqtt instance + * when the user connects their account; the parser is deliberately defensive so unrecognized + * topics are ignored rather than creating junk devices. + */ +class RingTopics +{ + /** Topics the listener subscribes to. */ + public static function subscriptions(): array + { + return ['ring/#']; + } + + /** + * Parse a device state topic. + * + * @return array{location:string,category:string,ring_id:string,entity:string}|null + */ + public static function parseState(string $topic): ?array + { + $parts = explode('/', $topic); + + if (($parts[0] ?? null) !== 'ring' || end($parts) !== 'state') { + return null; + } + + // ring/////state + if (count($parts) === 6) { + return ['location' => $parts[1], 'category' => $parts[2], 'ring_id' => $parts[3], 'entity' => $parts[4]]; + } + + // ring////state → device-level; treat category as the entity + if (count($parts) === 5) { + return ['location' => $parts[1], 'category' => $parts[2], 'ring_id' => $parts[3], 'entity' => $parts[2]]; + } + + return null; + } + + /** + * The bridge's own online/offline topic, e.g. "ring//status". + * Returns true when this topic is a bridge status topic. + */ + public static function isBridgeStatus(string $topic): bool + { + $parts = explode('/', $topic); + + return ($parts[0] ?? null) === 'ring' && count($parts) === 3 && $parts[2] === 'status'; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ea98f8a..91e0ca1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,29 @@ services: - "${MQTT_HOST_PORT:-1883}:1883" restart: unless-stopped + # Ring bridge — installable add-on, opt-in so a Ring-less setup never runs it: + # docker compose --profile addons up -d ring-mqtt + # Ring has no local API (handoff §12): the initial account login + 2FA is done in ring-mqtt's + # own web UI (http://:${RING_UI_PORT:-55123}), which mints the refresh token; the bridge + # then connects to our broker as the least-privileged `ring` user and publishes device state + # under ring/# for HomeOS to ingest. We do not build a custom Ring client. + ring-mqtt: + image: tsightler/ring-mqtt + profiles: ["addons"] + depends_on: + - mosquitto + environment: + MQTTHOST: mosquitto + MQTTPORT: "1883" + MQTTUSER: ring + MQTTPASSWORD: ${MQTT_RING_PASSWORD} + volumes: + - ring-data:/data + ports: + # ring-mqtt setup/token web UI. Port may differ by ring-mqtt version — adjust if needed. + - "${RING_UI_PORT:-55123}:55123" + restart: unless-stopped + # Discovery sidecar — host network so mDNS/SSDP multicast reaches the smart-home VLAN. discovery: build: @@ -104,3 +127,4 @@ volumes: db-data: redis-data: mosquitto-data: + ring-data: diff --git a/docker/mosquitto/config/acl b/docker/mosquitto/config/acl index 4b151f8..c688058 100644 --- a/docker/mosquitto/config/acl +++ b/docker/mosquitto/config/acl @@ -8,6 +8,11 @@ topic readwrite # user sidecar topic write homeos/discovery/# +# Ring bridge (ring-mqtt add-on): owns the ring/ namespace only. Cloud-bridged devices — +# it publishes their state and reads its own command topics, nothing else on the bus. +user ring +topic readwrite ring/# + # Physical devices: each authenticates with its OWN credentials (username = its topic # prefix, provisioned at onboarding) and is bound to its own prefix via %u — a compromised # or misconfigured device cannot publish status for, or control, another device. diff --git a/docker/mosquitto/gen-passwd.sh b/docker/mosquitto/gen-passwd.sh index 09d8542..5719a24 100755 --- a/docker/mosquitto/gen-passwd.sh +++ b/docker/mosquitto/gen-passwd.sh @@ -24,20 +24,22 @@ ensure_pw() { # at onboarding (username = topic prefix) — never a shared account. LPASS="$(ensure_pw MQTT_AUTH_PASSWORD)" DPASS="$(ensure_pw MQTT_SIDECAR_PASSWORD)" +RPASS="$(ensure_pw MQTT_RING_PASSWORD)" # Export + forward with bare `-e NAME` so docker passes the values verbatim (no host # interpolation). Read inside the container (single-quoted body) as env vars, so any # character in a password — quotes, $, ;, backticks — is handled safely. -export LPASS DPASS +export LPASS DPASS RPASS -docker run --rm -e LPASS -e DPASS \ +docker run --rm -e LPASS -e DPASS -e RPASS \ -v "$(pwd)/docker/mosquitto/config:/cfg" \ eclipse-mosquitto:2 sh -c ' rm -f /cfg/passwd mosquitto_passwd -c -b /cfg/passwd laravel "$LPASS" mosquitto_passwd -b /cfg/passwd sidecar "$DPASS" + mosquitto_passwd -b /cfg/passwd ring "$RPASS" # Owned by the app uid so the backend can add per-device credentials at onboarding; # world-readable so the mosquitto user can read it. chown '"$(id -u)"':'"$(id -g)"' /cfg/passwd && chmod 0644 /cfg/passwd ' -echo 'Mosquitto passwd generated (users: laravel, sidecar).' +echo 'Mosquitto passwd generated (users: laravel, sidecar, ring).' diff --git a/resources/views/livewire/addons/index.blade.php b/resources/views/livewire/addons/index.blade.php index bfef573..dd26253 100644 --- a/resources/views/livewire/addons/index.blade.php +++ b/resources/views/livewire/addons/index.blade.php @@ -53,6 +53,10 @@ class="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-2 text-[12.5px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors"> {{ __('common.docs') }} + {{-- to: 'addons' — Livewire names App\Livewire\Addons\Index as "addons" (the Index + suffix is dropped for index components), NOT "addons.index". Verified against the + rendered wire:snapshot; "addons.index" silently drops the event. Use $wire.$dispatch + (Livewire), not Alpine's $dispatch, so wire-elements' modal listener receives it. --}}