fix(addons): honest Ring setup guide instead of fake "connecting"

Issue 2: Ring sat on "verbinde…" forever because nothing ever connected — the
ring-mqtt bridge (opt-in container) wasn't running, and HomeOS was collecting
Ring credentials it never used. Per the handoff the BRIDGE owns the Ring login
(email/pw/2FA) in its own web UI; HomeOS must not handle Ring credentials.
- Replaced the credential modal with a RingSetup guide: start the bridge
  (`docker compose --profile addons up -d ring-mqtt`), open its web UI (linked
  at the auto-detected host:port), log in there; devices then appear.
- Status is now honest — it reflects the bridge's REAL MQTT presence
  (IngestRingMessage.markStatus), showing "Nicht verbunden" with next-steps
  until the bridge actually reports, instead of a stuck "connecting".
- Removed the RingConnect cred modal + fields; reset any stuck status.

Suite green, 12/12 tabs clean; setup modal browser-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 10:26:27 +02:00
parent 60d5275819
commit 60030c3f61
9 changed files with 126 additions and 125 deletions

View File

@ -1,61 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Services\AddonService;
use LivewireUI\Modal\ModalComponent;
/**
* Captures the Ring account login. We never call Ring ourselves (no local API; the ring-mqtt
* sidecar owns the OAuth + 2FA exchange, per the handoff) the entered credentials are stored
* encrypted and handed to that backend, which completes the connection and reports over MQTT.
*/
class RingConnect extends ModalComponent
{
public string $email = '';
public string $password = '';
public string $code = '';
public bool $hasStoredPassword = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
$addon = Addon::where('key', 'ring')->first();
$config = $addon?->config ?? [];
$this->email = $config['email'] ?? '';
$this->hasStoredPassword = filled($config['password'] ?? null);
}
public function save()
{
$this->validate([
'email' => 'required|email|max:190',
// Password may be left blank to keep a previously stored one.
'password' => $this->hasStoredPassword ? 'nullable|string|max:190' : 'required|string|max:190',
'code' => 'nullable|string|max:12',
]);
app(AddonService::class)->saveConnection('ring', [
'email' => $this->email,
'password' => $this->password,
'code' => $this->code,
]);
$this->closeModal();
return redirect()->route('addons.index');
}
public function render()
{
return view('livewire.modals.ring-connect');
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Support\HostAddress;
use LivewireUI\Modal\ModalComponent;
/**
* Ring setup guide. Ring has no local API, so the ring-mqtt bridge (a separate container) owns
* the Ring login + 2FA in its OWN web UI HomeOS never handles Ring credentials. This modal just
* walks the user through starting the bridge and links to it, and reflects the bridge's real MQTT
* status (updated by IngestRingMessage when the bridge publishes online/offline).
*/
class RingSetup extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function render()
{
$addon = Addon::where('key', 'ring')->first();
// The bridge UI runs on the HomeOS host at its configured port.
$host = explode(':', HostAddress::broker())[0];
return view('livewire.modals.ring-setup', [
'status' => $addon?->status ?? 'disconnected',
'statusDetail' => $addon?->status_detail,
'connectedAt' => $addon?->connected_at,
'bridgeUrl' => 'http://'.$host.':'.config('homeos.ring.ui_port', 55123),
]);
}
}

View File

@ -21,18 +21,15 @@ class AddonRegistry
'vendor' => 'Ring',
'icon' => 'doorbell',
// Ring has no local API — the established bridge is ring-mqtt (cloud, token auth).
// The BRIDGE does the Ring login (email/password/2FA) in its own web UI; HomeOS never
// handles Ring credentials. So the addon's job is to guide setup + reflect the
// bridge's real MQTT status — not to collect a password.
'cloud' => true,
'summary_key' => 'addons.ring.summary',
'body_key' => 'addons.ring.body',
// The ring-mqtt sidecar generates a refresh token from an interactive Ring login
// (email + password + 2FA). We store that token; the sidecar does the rest.
'backend' => 'ring-mqtt',
'docs' => 'https://github.com/tsightler/ring-mqtt',
'fields' => [
['name' => 'email', 'type' => 'email', 'label_key' => 'addons.ring.email', 'secret' => false],
['name' => 'password', 'type' => 'password', 'label_key' => 'addons.ring.password', 'secret' => true],
['name' => 'code', 'type' => 'text', 'label_key' => 'addons.ring.code', 'secret' => false, 'optional' => true],
],
'setup_modal' => 'modals.ring-setup',
],
];
}

View File

@ -38,4 +38,10 @@ return [
// rides out brief phone WLAN sleeps without leaving you "home" long after you left.
'away_debounce_minutes' => (int) env('PRESENCE_AWAY_DEBOUNCE', 3),
],
'ring' => [
// Port of the ring-mqtt bridge's own setup/login web UI (where the Ring account login
// and 2FA happen). Linked from the Ring add-on setup guide.
'ui_port' => (int) env('RING_UI_PORT', 55123),
],
];

View File

@ -9,6 +9,7 @@ return [
'uninstall_title' => ':name entfernen?',
'uninstall_body' => 'Die Integration wird deaktiviert und gespeicherte Zugangsdaten werden gelöscht. Geräte bleiben erhalten, empfangen aber keine Aktualisierungen mehr.',
'connect' => 'Konto verbinden',
'setup' => 'Einrichten',
'reconnect' => 'Erneut verbinden',
'cloud' => 'Cloud',
'cloud_hint' => 'Diese Integration benötigt eine Cloud-Verbindung zum Hersteller.',
@ -20,13 +21,16 @@ return [
'ring' => [
'summary' => 'Türklingel, Kameras und Sensoren über die ring-mqtt-Bridge.',
'body' => 'Ring bietet keine lokale Schnittstelle. Die Anmeldung läuft über dein Ring-Konto; die Verbindung stellt der ring-mqtt-Dienst im Hintergrund her.',
'email' => 'Ring E-Mail',
'password' => 'Ring Passwort',
'code' => '2FA-Code (falls aktiv)',
'password_stored' => 'Ein Passwort ist gespeichert. Leer lassen, um es zu behalten.',
'connect_title' => 'Ring-Konto verbinden',
'backend_note' => 'Zugangsdaten werden verschlüsselt gespeichert und an den ring-mqtt-Dienst übergeben. Der eigentliche Login inkl. 2FA passiert im Backend.',
'save' => 'Speichern & verbinden',
'body' => 'Ring bietet keine lokale Schnittstelle. Die Anmeldung inkl. 2FA passiert in der eigenen Weboberfläche der ring-mqtt-Bridge — HomeOS speichert keine Ring-Zugangsdaten.',
'setup' => 'Einrichten',
'setup_title' => 'Ring einrichten',
'setup_intro' => 'Ring wird über die separate ring-mqtt-Bridge angebunden. So richtest du sie ein:',
'step1' => 'Bridge starten (einmalig, am Server):',
'step2' => 'Bridge-Oberfläche öffnen und mit deinem Ring-Konto anmelden (inkl. 2FA). Die Bridge speichert das Token.',
'step3' => 'Fertig — deine Ring-Geräte erscheinen automatisch unter „Geräte" (Badge „Cloud").',
'open_bridge' => 'Bridge-Oberfläche öffnen',
'status_title' => 'Bridge-Status',
'status_hint_disconnected' => 'Noch keine Verbindung von der Bridge empfangen. Starte die Bridge und melde dich an.',
'connected_since' => 'Verbunden seit',
],
];

View File

@ -9,6 +9,7 @@ return [
'uninstall_title' => 'Remove :name?',
'uninstall_body' => 'The integration is disabled and stored credentials are deleted. Devices are kept but stop receiving updates.',
'connect' => 'Connect account',
'setup' => 'Set up',
'reconnect' => 'Reconnect',
'cloud' => 'Cloud',
'cloud_hint' => 'This integration needs a cloud connection to the vendor.',
@ -20,13 +21,16 @@ return [
'ring' => [
'summary' => 'Doorbell, cameras and sensors via the ring-mqtt bridge.',
'body' => 'Ring has no local API. Sign in with your Ring account; the connection is established by the ring-mqtt service in the background.',
'email' => 'Ring email',
'password' => 'Ring password',
'code' => '2FA code (if enabled)',
'password_stored' => 'A password is stored. Leave blank to keep it.',
'connect_title' => 'Connect Ring account',
'backend_note' => 'Credentials are stored encrypted and handed to the ring-mqtt service. The actual login incl. 2FA happens in the backend.',
'save' => 'Save & connect',
'body' => 'Ring has no local API. The login incl. 2FA happens in the ring-mqtt bridge\'s own web UI — HomeOS never stores Ring credentials.',
'setup' => 'Set up',
'setup_title' => 'Set up Ring',
'setup_intro' => 'Ring connects through the separate ring-mqtt bridge. Set it up like this:',
'step1' => 'Start the bridge (once, on the server):',
'step2' => 'Open the bridge UI and sign in with your Ring account (incl. 2FA). The bridge stores the token.',
'step3' => 'Done — your Ring devices appear automatically under “Devices” (with a “Cloud” badge).',
'open_bridge' => 'Open bridge UI',
'status_title' => 'Bridge status',
'status_hint_disconnected' => 'No connection received from the bridge yet. Start the bridge and sign in.',
'connected_since' => 'Connected since',
],
];

View File

@ -45,9 +45,9 @@
<x-icon name="plus" :size="15" /> {{ __('addons.install') }}
</button>
@else
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.ring-connect' })"
<button type="button" wire:click="$dispatch('openModal', { component: '{{ $addon['setup_modal'] ?? 'modals.ring-setup' }}' })"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">
<x-icon name="user" :size="15" /> {{ $addon['status'] === 'connected' ? __('addons.reconnect') : __('addons.connect') }}
<x-icon name="settings" :size="15" /> {{ __('addons.setup') }}
</button>
<a href="{{ $addon['docs'] }}" target="_blank" rel="noopener noreferrer"
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">

View File

@ -1,39 +0,0 @@
<form wire:submit="save">
<x-modal :title="__('addons.ring.connect_title')" icon="doorbell">
<div class="p-5 flex flex-col gap-4">
<p class="text-[12px] text-ink-3 leading-relaxed flex items-start gap-2">
<x-icon name="cloud" :size="15" class="mt-0.5 shrink-0" />
<span>{{ __('addons.ring.backend_note') }}</span>
</p>
<div class="flex flex-col gap-1.5">
<label for="ring-email" class="text-[12px] font-semibold text-ink-2">{{ __('addons.ring.email') }}</label>
<input wire:model="email" id="ring-email" type="email" autocomplete="off" autofocus
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('email') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="ring-password" class="text-[12px] font-semibold text-ink-2">{{ __('addons.ring.password') }}</label>
<input wire:model="password" id="ring-password" type="password" autocomplete="new-password"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@if ($hasStoredPassword)
<p class="text-[11.5px] text-ink-3">{{ __('addons.ring.password_stored') }}</p>
@endif
@error('password') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="ring-code" class="text-[12px] font-semibold text-ink-2">{{ __('addons.ring.code') }}</label>
<input wire:model="code" id="ring-code" type="text" inputmode="numeric" autocomplete="one-time-code"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('code') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
</div>
<x-slot:footer>
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('addons.ring.save') }}</button>
</x-slot:footer>
</x-modal>
</form>

View File

@ -0,0 +1,54 @@
<x-modal :title="__('addons.ring.setup_title')" icon="doorbell">
<div class="p-5 flex flex-col gap-4">
<p class="text-[12.5px] text-ink-2 leading-relaxed">{{ __('addons.ring.setup_intro') }}</p>
<ol class="flex flex-col gap-3">
<li class="flex gap-3">
<span class="grid place-items-center w-6 h-6 rounded-full bg-inset text-ink-2 font-mono text-[12px] shrink-0">1</span>
<div class="min-w-0 flex-1">
<p class="text-[12.5px] text-ink-2">{{ __('addons.ring.step1') }}</p>
<code class="mt-1 block rounded-lg bg-base border border-line-soft px-3 py-2 text-[12px] font-mono text-ink overflow-x-auto">docker compose --profile addons up -d ring-mqtt</code>
</div>
</li>
<li class="flex gap-3">
<span class="grid place-items-center w-6 h-6 rounded-full bg-inset text-ink-2 font-mono text-[12px] shrink-0">2</span>
<p class="text-[12.5px] text-ink-2 pt-0.5">{{ __('addons.ring.step2') }}</p>
</li>
<li class="flex gap-3">
<span class="grid place-items-center w-6 h-6 rounded-full bg-inset text-ink-2 font-mono text-[12px] shrink-0">3</span>
<p class="text-[12.5px] text-ink-2 pt-0.5">{{ __('addons.ring.step3') }}</p>
</li>
</ol>
<a href="{{ $bridgeUrl }}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent px-3.5 py-2.5 text-[13px] font-bold text-base hover:brightness-110 transition-[filter] w-max">
<x-icon name="network" :size="15" /> {{ __('addons.ring.open_bridge') }}
<span class="font-mono font-normal opacity-80">{{ $bridgeUrl }}</span>
</a>
{{-- Real bridge status --}}
<div class="rounded-lg border border-line-soft bg-raised p-3.5 flex items-center gap-3">
@php
$pill = match ($status) { 'connected' => 'online', 'connecting' => 'warning', 'error' => 'offline', default => 'neutral' };
@endphp
<div class="min-w-0">
<div class="text-[11px] font-semibold text-ink-3">{{ __('addons.ring.status_title') }}</div>
<div class="mt-1"><x-status-pill :state="$pill">{{ __('addons.status_'.$status) }}</x-status-pill></div>
</div>
<div class="ml-auto text-right">
@if ($status === 'connected' && $connectedAt)
<div class="text-[11px] text-ink-3">{{ __('addons.ring.connected_since') }}</div>
<div class="text-[12px] font-mono text-ink">{{ $connectedAt->diffForHumans() }}</div>
@elseif ($statusDetail)
<div class="text-[11.5px] text-offline max-w-[16rem]">{{ $statusDetail }}</div>
@else
<div class="text-[11.5px] text-ink-3 max-w-[18rem]">{{ __('addons.ring.status_hint_disconnected') }}</div>
@endif
</div>
</div>
</div>
<x-slot:footer>
<button type="button" wire:click="closeModal" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('common.close') }}</button>
</x-slot:footer>
</x-modal>