feat(addons): Ring backend bridge — ring-mqtt ingest, cloud devices, badge
Second half of the Ring integration (handoff §12): - RingTopics + RingNormalizer (H3): parse ring/<loc>/<cat>/<id>/<entity>/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 <noreply@anthropic.com>feat/phase-1-bootstrap
parent
52c1c5d3ac
commit
a4489ac7d3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,3 +37,6 @@ Thumbs.db
|
|||
/docker/mosquitto/config/reload
|
||||
/docker/mosquitto/data/
|
||||
/.claude/
|
||||
|
||||
# Codex R15 review outputs (local scratch)
|
||||
.reviews/
|
||||
|
|
|
|||
|
|
@ -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 <homeos@localhost>
|
||||
AuthorDate: Sat Jul 18 01:25:24 2026 +0200
|
||||
Commit: HomeOS Bootstrap <homeos@localhost>
|
||||
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 <noreply@anthropic.com>
|
||||
|
||||
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 @@
|
||||
+<!doctype html>
|
||||
+<html lang="de">
|
||||
+<head>
|
||||
+ <meta charset="utf-8">
|
||||
+ <meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
+ <title>Offline · HomeOS</title>
|
||||
+ <style>
|
||||
+ :root { color-scheme: dark; }
|
||||
+ * { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
+ body {
|
||||
+ min-height: 100dvh;
|
||||
+ display: grid;
|
||||
+ place-items: center;
|
||||
+ background: #080D18;
|
||||
+ color: #EAF0FB;
|
||||
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
+ padding: 24px;
|
||||
+ text-align: center;
|
||||
+ }
|
||||
+ .card { max-width: 340px; display: flex; flex-direction: column; align-items: center; gap: 18px; }
|
||||
+ .badge {
|
||||
+ width: 72px; height: 72px; border-radius: 20px; background: #0D1424;
|
||||
+ display: grid; place-items: center;
|
||||
+ }
|
||||
+ h1 { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
+ p { font-size: 14px; line-height: 1.5; color: #93A1BD; }
|
||||
+ button {
|
||||
+ margin-top: 4px; border: 0; cursor: pointer;
|
||||
+ background: #4FC1FF; color: #080D18; font-weight: 700; font-size: 13.5px;
|
||||
+ padding: 10px 18px; border-radius: 10px;
|
||||
+ }
|
||||
+ button:hover { filter: brightness(1.08); }
|
||||
+ </style>
|
||||
+</head>
|
||||
+<body>
|
||||
+ <div class="card">
|
||||
+ <div class="badge">
|
||||
+ <svg width="40" height="40" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
+ <rect x="6" y="6" width="9" height="12" rx="2" fill="#4FC1FF"/>
|
||||
+ <rect x="18" y="6" width="8" height="6" rx="2" fill="#4FC1FF" opacity="0.75"/>
|
||||
+ <rect x="18" y="15" width="8" height="11" rx="2" fill="#4FC1FF"/>
|
||||
+ <rect x="6" y="21" width="9" height="5" rx="2" fill="#4FC1FF" opacity="0.75"/>
|
||||
+ </svg>
|
||||
+ </div>
|
||||
+ <h1>Keine Verbindung</h1>
|
||||
+ <p>HomeOS ist gerade nicht erreichbar. Prüfe deine Netzwerkverbindung zum Server.</p>
|
||||
+ <button onclick="location.reload()">Erneut versuchen</button>
|
||||
+ </div>
|
||||
+</body>
|
||||
+</html>
|
||||
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 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
+ <link rel="icon" href="{{ asset('favicon.ico') }}" sizes="any">
|
||||
+
|
||||
+ {{-- PWA: installable on tablet/phone (§ tablet control) --}}
|
||||
+ <link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
|
||||
+ <meta name="theme-color" content="#0D1424">
|
||||
+ <meta name="mobile-web-app-capable" content="yes">
|
||||
+ <meta name="apple-mobile-web-app-capable" content="yes">
|
||||
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
+ <meta name="apple-mobile-web-app-title" content="HomeOS">
|
||||
+ <link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
|
||||
+
|
||||
<title>{{ $title ?? __('common.app_name') }} · {{ __('common.app_name') }}</title>
|
||||
@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 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
+ <link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
|
||||
+ <meta name="theme-color" content="#0D1424">
|
||||
+ <meta name="apple-mobile-web-app-capable" content="yes">
|
||||
+ <meta name="apple-mobile-web-app-title" content="HomeOS">
|
||||
+ <link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
|
||||
<title>{{ __('auth.title') }} · {{ __('common.app_name') }}</title>
|
||||
@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/<key>,
|
||||
# Laravel publishes to /apps/<id>/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()`.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Concerns;
|
||||
|
||||
use App\Models\DeviceState;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
|
||||
/**
|
||||
* Monotonic, race-safe upsert of a single entity's state. Shared by every ingest path (H4) so
|
||||
* the ordering guarantee is written once: concurrent Horizon workers can finish out of order,
|
||||
* and an older message must never overwrite a newer one. The guard lives in the WHERE clause,
|
||||
* so it holds without row locks.
|
||||
*/
|
||||
trait AppliesDeviceState
|
||||
{
|
||||
/**
|
||||
* Returns true when this message's state was applied, false when it was stale
|
||||
* (an equal-or-newer state is already stored).
|
||||
*
|
||||
* @param array<string,mixed> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\DeviceStateChanged;
|
||||
use App\Jobs\Concerns\AppliesDeviceState;
|
||||
use App\Models\Addon;
|
||||
use App\Models\Device;
|
||||
use App\Services\AddonService;
|
||||
use App\Support\Mqtt\RingNormalizer;
|
||||
use App\Support\Mqtt\RingTopics;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The single ingest path for ring-mqtt messages (H4). The MQTT callback only dispatches this
|
||||
* (H2). Ring devices are cloud-bridged, so — unlike locally-discovered Shelly — they are
|
||||
* auto-created here on first sight and flagged `cloud` for the "Cloud" badge (handoff §12).
|
||||
*/
|
||||
class IngestRingMessage implements ShouldQueue
|
||||
{
|
||||
use AppliesDeviceState;
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public string $topic,
|
||||
public string $payload,
|
||||
public ?int $observedAt = null,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
\Illuminate\Support\Facades\Redis::connection()->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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Mqtt;
|
||||
|
||||
/**
|
||||
* Normalizes a ring-mqtt state payload into protocol-agnostic entity updates
|
||||
* ({key, type, state}), the same shape the Shelly path produces. Vendor specifics stay here (H3).
|
||||
*
|
||||
* Covers the common Ring doorbell/camera signals (ding, motion, battery, contact, lock). The
|
||||
* bridge sends plain "ON"/"OFF"/"open"/"closed" for binary sensors and JSON for the info topic.
|
||||
*/
|
||||
class RingNormalizer
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{key:string,type:string,state:array<string,mixed>}>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Mqtt;
|
||||
|
||||
/**
|
||||
* ring-mqtt (tsightler/ring-mqtt) topic conventions. The bridge publishes device state under
|
||||
* `ring/<location>/<category>/<deviceId>/<entity>/state` and a bridge health topic under
|
||||
* `ring/<location>/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/<location>/<category>/<deviceId>/<entity>/state
|
||||
if (count($parts) === 6) {
|
||||
return ['location' => $parts[1], 'category' => $parts[2], 'ring_id' => $parts[3], 'entity' => $parts[4]];
|
||||
}
|
||||
|
||||
// ring/<location>/<category>/<deviceId>/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/<location>/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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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://<host>:${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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).'
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
</a>
|
||||
{{-- 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. --}}
|
||||
<button type="button"
|
||||
x-on:click="$wire.set('pendingUninstall', @js($addon['key'])); $wire.$dispatch('openModal', { component: 'modals.confirm', arguments: { title: @js(__('addons.uninstall_title', ['name' => $addon['name']])), body: @js(__('addons.uninstall_body')), confirmLabel: @js(__('addons.uninstall')), event: 'addon-uninstall', to: 'addons', danger: true } })"
|
||||
class="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-2 text-[12.5px] font-semibold text-ink-3 hover:text-offline hover:border-offline/40 transition-colors">
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@
|
|||
class="flex items-center gap-3 py-2.5 -mx-4 px-4 rounded-lg hover:bg-raised/50 transition-colors">
|
||||
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
|
||||
<span class="text-[13px] font-semibold text-ink truncate">{{ $device->name }}</span>
|
||||
@if ($device->isCloud())
|
||||
<span title="{{ __('addons.cloud_hint') }}" class="inline-flex items-center gap-1 rounded-full bg-inset px-1.5 py-[1px] text-[9.5px] font-bold text-ink-3 shrink-0"><x-icon name="cloud" :size="11" /> {{ __('addons.cloud') }}</span>
|
||||
@endif
|
||||
<span class="ml-auto text-[11px] font-mono text-ink-3 truncate shrink-0">{{ $device->model }}</span>
|
||||
<x-icon name="chevron" :size="14" class="text-ink-3 shrink-0" />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@
|
|||
@if ($device->status !== 'active')
|
||||
<x-status-pill state="neutral">{{ __('devices.status_'.$device->status) }}</x-status-pill>
|
||||
@endif
|
||||
@if ($device->isCloud())
|
||||
<x-status-pill state="neutral" title="{{ __('addons.cloud_hint') }}"><x-icon name="cloud" :size="12" /> {{ __('addons.cloud') }}</x-status-pill>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\IngestRingMessage;
|
||||
use App\Models\Addon;
|
||||
use App\Models\Device;
|
||||
use App\Support\Mqtt\RingNormalizer;
|
||||
use App\Support\Mqtt\RingTopics;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RingIngestTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function installRing(): void
|
||||
{
|
||||
Addon::create(['key' => 'ring', 'installed' => true]);
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_topic_parser_reads_entity_topics(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['location' => 'loc1', 'category' => 'camera', 'ring_id' => 'abc123', 'entity' => 'motion'],
|
||||
RingTopics::parseState('ring/loc1/camera/abc123/motion/state'),
|
||||
);
|
||||
$this->assertTrue(RingTopics::isBridgeStatus('ring/loc1/status'));
|
||||
$this->assertNull(RingTopics::parseState('ring/loc1/camera/abc123/motion/command'));
|
||||
}
|
||||
|
||||
public function test_normalizer_maps_common_entities(): void
|
||||
{
|
||||
$this->assertSame(['on' => true], RingNormalizer::normalize('ding', 'ON')[0]['state']);
|
||||
$this->assertSame(['on' => false], RingNormalizer::normalize('motion', 'OFF')[0]['state']);
|
||||
$this->assertSame(['open' => true], RingNormalizer::normalize('contact', 'open')[0]['state']);
|
||||
$this->assertSame(['percent' => 87], RingNormalizer::normalize('info', json_encode(['batteryLevel' => 87]))[0]['state']);
|
||||
$this->assertSame([], RingNormalizer::normalize('unknown', 'ON'));
|
||||
}
|
||||
|
||||
public function test_bridge_status_updates_addon(): void
|
||||
{
|
||||
$this->installRing();
|
||||
|
||||
(new IngestRingMessage('ring/loc1/status', 'online'))->handle();
|
||||
$this->assertSame('connected', Addon::where('key', 'ring')->first()->status);
|
||||
|
||||
(new IngestRingMessage('ring/loc1/status', 'offline'))->handle();
|
||||
$this->assertSame('error', Addon::where('key', 'ring')->first()->status);
|
||||
}
|
||||
|
||||
public function test_doorbell_ding_auto_creates_cloud_device_and_state(): void
|
||||
{
|
||||
$this->installRing();
|
||||
|
||||
(new IngestRingMessage('ring/loc1/camera/frontdoor01/ding/state', 'ON'))->handle();
|
||||
|
||||
$device = Device::where('config->ring_id', 'frontdoor01')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame('Ring', $device->vendor);
|
||||
$this->assertTrue($device->isCloud());
|
||||
|
||||
$ding = $device->entities()->where('key', 'ding')->first();
|
||||
$this->assertTrue(data_get($ding->state->state, 'on'));
|
||||
}
|
||||
|
||||
public function test_ingest_is_ignored_when_addon_not_installed(): void
|
||||
{
|
||||
Cache::flush(); // no ring addon row
|
||||
|
||||
(new IngestRingMessage('ring/loc1/camera/frontdoor01/motion/state', 'ON'))->handle();
|
||||
|
||||
$this->assertDatabaseCount('devices', 0);
|
||||
}
|
||||
|
||||
public function test_unknown_entity_does_not_create_a_device(): void
|
||||
{
|
||||
$this->installRing();
|
||||
|
||||
(new IngestRingMessage('ring/loc1/camera/frontdoor01/wibble/state', 'ON'))->handle();
|
||||
|
||||
$this->assertDatabaseCount('devices', 0);
|
||||
}
|
||||
|
||||
public function test_out_of_order_messages_do_not_overwrite_newer_state(): void
|
||||
{
|
||||
$this->installRing();
|
||||
|
||||
(new IngestRingMessage('ring/loc1/camera/cam1/motion/state', 'ON', 2_000))->handle();
|
||||
(new IngestRingMessage('ring/loc1/camera/cam1/motion/state', 'OFF', 1_000))->handle();
|
||||
|
||||
$device = Device::where('config->ring_id', 'cam1')->first();
|
||||
$this->assertTrue(data_get($device->entities()->where('key', 'motion')->first()->state->state, 'on'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue