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
parent
60d5275819
commit
60030c3f61
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue