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
HomeOS Bootstrap 2026-07-18 01:58:40 +02:00
parent 52c1c5d3ac
commit a4489ac7d3
18 changed files with 444 additions and 1981 deletions

View File

@ -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

3
.gitignore vendored
View File

@ -37,3 +37,6 @@ Thumbs.db
/docker/mosquitto/config/reload
/docker/mosquitto/data/
/.claude/
# Codex R15 review outputs (local scratch)
.reviews/

View File

@ -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

View File

@ -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()

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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';
}
}

View File

@ -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';
}
}

View File

@ -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:

View File

@ -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.

View File

@ -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).'

View File

@ -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">

View File

@ -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>

View File

@ -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">

View File

@ -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'));
}
}