feat(mqtt): Home-Assistant-style onboarding — shared account + auto-create

Addresses "I have to configure MQTT per device; HA just works":
- Shared device account `shelly` (one credential every Shelly uses) with a
  SCOPED ACL (status/events/online/rpc for any prefix; cannot touch homeos/,
  ring/ or $SYS). gen-passwd seeds it from MQTT_SHELLY_PASSWORD.
- Auto-onboarding: IngestShellyMessage creates the device on the first
  recognizable component (sys/wifi/cloud noise ignored), so pointing a Shelly
  at the broker is all it takes — no manual "Zuweisen". Partial unique index on
  config->>'mqtt_prefix' + race-safe create (merge-dedup migration).
- Settings → Geräte-MQTT card: server, username, reveal/copy password + steps
  (config/homeos.php, MQTT_DEVICE_HOST). Discovery "Zuweisen" now upserts by
  prefix (names/rooms an already-onboarded device, no duplicate) and no longer
  forces per-device creds.
- Per-device credentials kept as opt-in hardening: a "generate" button on the
  device page (pattern %u ACL retained).

Live-verified: publishing as `shelly` to a new prefix auto-creates the device
with switch+power state; a sys topic creates nothing. Suite 46 green, 12/12
tabs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 08:20:57 +02:00
parent 9c566492ab
commit fef393954c
17 changed files with 347 additions and 37 deletions

View File

@ -75,8 +75,14 @@ MQTT_CLIENT_ID=
MQTT_AUTO_RECONNECT_ENABLED=false
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=
# Shared DEVICE account — the ONE credential every Shelly uses (username `shelly`).
# Home-Assistant-style onboarding: point each device at the broker with these, done.
# Shown to you under Settings → Geräte-MQTT. Filled by gen-passwd.sh if empty.
MQTT_SHELLY_PASSWORD=
# Address your Shellys should connect to = your HomeOS server's LAN IP:port (NOT `mosquitto`,
# which only resolves inside Docker). Shown in Settings. Leave blank to display a hint.
MQTT_DEVICE_HOST=
# 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.

View File

@ -8,12 +8,17 @@ use App\Models\Device;
use App\Support\Mqtt\ShellyNormalizer;
use App\Support\Mqtt\ShellyTopics;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Queue\Queueable;
/**
* The single ingest path for Shelly status messages (H4). The MQTT callback only
* dispatches this (H2); here we resolve the device, upsert current state and broadcast.
*
* Devices auto-onboard: the first time a prefix publishes a recognizable component the device is
* created (Home-Assistant-style), so pointing a Shelly at the broker is all it takes no manual
* assignment. Housekeeping topics (sys/wifi/cloud) never create a device.
*
* `observedAt` is the µs receive time from the listener. Because concurrent Horizon
* workers can finish out of order, state is only written when this message is newer than
* what is stored an older message can never overwrite a newer one.
@ -44,17 +49,22 @@ class IngestShellyMessage implements ShouldQueue
[$prefix, $component] = $parsed;
// Unknown devices are handled by discovery (Phase 4), not silently created here.
$device = Device::where('config->mqtt_prefix', $prefix)->first();
if ($device === null) {
return;
}
$data = json_decode($this->payload, true);
if (! is_array($data)) {
return;
}
$updates = ShellyNormalizer::normalize($component, $data);
$device = Device::where('config->mqtt_prefix', $prefix)->first();
if ($device === null) {
// Auto-onboard on the first recognizable component; ignore sys/wifi/cloud noise.
if (! config('homeos.mqtt.auto_onboard') || ! $this->hasPrimaryType($updates)) {
return;
}
$device = $this->onboard($prefix);
}
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
$observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
@ -65,7 +75,7 @@ class IngestShellyMessage implements ShouldQueue
->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime))
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
foreach (ShellyNormalizer::normalize($component, $data) as $update) {
foreach ($updates as $update) {
$entity = $device->entities()->firstOrCreate(
['key' => $update['key']],
['type' => $update['type']],
@ -76,4 +86,27 @@ class IngestShellyMessage implements ShouldQueue
}
}
}
/** @param array<int, array{type:string}> $updates */
private function hasPrimaryType(array $updates): bool
{
return array_intersect(array_column($updates, 'type'), ShellyNormalizer::PRIMARY_TYPES) !== [];
}
private function onboard(string $prefix): Device
{
try {
return Device::create([
'name' => $prefix, // user renames on the device detail page
'vendor' => 'Shelly',
'protocol' => 'mqtt',
'status' => 'active',
'last_seen_at' => now(),
'config' => ['mqtt_prefix' => $prefix, 'auto_onboarded' => true],
]);
} catch (UniqueConstraintViolationException) {
// A concurrent worker onboarded it first (devices_mqtt_prefix_unique) — use the winner.
return Device::where('config->mqtt_prefix', $prefix)->firstOrFail();
}
}
}

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Devices;
use App\Models\Device;
use App\Models\Room;
use App\Services\DeviceCommandService;
use App\Services\MqttCredentialProvisioner;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
@ -24,7 +25,7 @@ class Show extends Component
public bool $flashOk = true;
/** One-time MQTT credential to display after provisioning at assignment. */
/** One-time per-device MQTT credential, shown after the user generates one (advanced). */
public ?array $mqttCredential = null;
public function mount(Device $device): void
@ -32,7 +33,31 @@ class Show extends Component
$this->device = $device;
$this->name = $device->name;
$this->roomId = $device->room_id;
$this->mqttCredential = session('mqtt_credential');
}
/**
* Advanced: generate a dedicated MQTT credential for THIS device (username = its prefix),
* bound to just its own topics by the `pattern %u` ACL. The default onboarding uses the
* shared `shelly` account (Settings Geräte-MQTT); this is opt-in hardening.
*/
public function generateMqttCredential(): void
{
$prefix = data_get($this->device->config, 'mqtt_prefix');
if (blank($prefix)) {
$this->flashMessage(__('devices.mqtt_no_prefix'));
return;
}
$password = app(MqttCredentialProvisioner::class)->provision($prefix);
$this->mqttCredential = [
'username' => $prefix,
'password' => $password,
'host' => config('homeos.mqtt.device_host') ?: __('devices.mqtt_host_hint'),
'port' => (int) config('mqtt-client.connections.default.port', 1883),
];
}
public function saveName(): void

View File

@ -5,7 +5,6 @@ namespace App\Livewire\Modals;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use App\Models\Room;
use App\Services\MqttCredentialProvisioner;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
@ -49,29 +48,25 @@ class AssignDevice extends ModalComponent
// (finding->name), not the slugified topic identifier which carries the _shelly._tcp suffix.
$prefix = $isShelly ? ($finding->name ?: $finding->identifier) : null;
$device = Device::create([
// The device may already have auto-onboarded from its MQTT traffic — reuse that row by
// prefix so assigning just names it and puts it in a room (no duplicate). Devices sign in
// with the shared `shelly` account, so no per-device credential is provisioned here.
$device = ($prefix ? Device::where('config->mqtt_prefix', $prefix)->first() : null)
?? new Device;
$device->fill([
'name' => $this->name,
'vendor' => $finding->vendor,
'model' => $finding->model,
'protocol' => $isShelly ? 'mqtt' : null,
'config' => $prefix ? ['mqtt_prefix' => $prefix] : null,
'model' => $finding->model ?: $device->model,
'protocol' => $isShelly ? 'mqtt' : $device->protocol,
'room_id' => $this->roomId ?: null,
'status' => 'active',
]);
$device->config = array_merge($device->config ?? [], $prefix ? ['mqtt_prefix' => $prefix] : []);
$device->save();
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
// Provision a per-device MQTT credential (bound to its own prefix by the ACL) and
// surface it once so the user can enter it into the Shelly.
if ($isShelly && $prefix) {
$password = app(MqttCredentialProvisioner::class)->provision($prefix);
session()->flash('mqtt_credential', [
'username' => $prefix,
'password' => $password,
'port' => (int) config('mqtt-client.connections.default.port', 1883),
]);
}
$this->closeModal();
return redirect()->route('devices.show', $device);

View File

@ -31,6 +31,12 @@ class Index extends Component
return view('livewire.settings.index', [
'integrations' => $integrations,
'version' => app()->version(),
'deviceMqtt' => [
'host' => config('homeos.mqtt.device_host'),
'username' => config('homeos.mqtt.device_username'),
'password' => config('homeos.mqtt.device_password'),
'port' => config('homeos.mqtt.port'),
],
]);
}
}

View File

@ -9,6 +9,13 @@ namespace App\Support\Mqtt;
*/
class ShellyNormalizer
{
/**
* Entity types that identify a real device component (vs. housekeeping topics like sys/wifi).
* A new prefix is auto-onboarded only when one of these arrives, so `sys`/`cloud`/`mqtt`
* status noise never creates a junk device.
*/
public const PRIMARY_TYPES = ['switch', 'light', 'cover', 'contact', 'temperature', 'humidity', 'battery', 'power', 'motion'];
/**
* @param array<string,mixed> $payload
* @return array<int, array{key:string,type:string,state:array<string,mixed>}>

25
config/homeos.php Normal file
View File

@ -0,0 +1,25 @@
<?php
return [
/*
|----------------------------------------------------------------------
| Device MQTT onboarding (Shelly)
|----------------------------------------------------------------------
| Home-Assistant-style: every Shelly signs in with ONE shared account and
| is created automatically on its first message. These values are shown to
| the user under Settings Geräte-MQTT so they can enter them into a device.
*/
'mqtt' => [
// Address the DEVICES connect to = this server's LAN IP:port (not the internal
// `mosquitto` hostname). Empty → the settings screen shows a hint instead.
'device_host' => env('MQTT_DEVICE_HOST'),
// The shared device account. Username is fixed (matches the `shelly` ACL block).
'device_username' => 'shelly',
'device_password' => env('MQTT_SHELLY_PASSWORD'),
'port' => (int) env('MQTT_PORT', 1883),
// Auto-create a device the first time an unknown prefix publishes a real component.
'auto_onboard' => (bool) env('MQTT_AUTO_ONBOARD', true),
],
];

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Auto-onboarding creates a device the first time a Shelly publishes. Concurrent workers
// on a burst of retained status topics must not create two rows for one prefix. Reparent
// any pre-existing duplicates onto the earliest row (keeping the survivor's entity on a
// key collision; the rest cascade-delete), then add a partial unique index so the second
// concurrent insert fails and the ingest job re-fetches the winner. Portable PG/SQLite.
$rows = DB::table('devices')
->whereRaw("config->>'mqtt_prefix' IS NOT NULL")
->selectRaw("id, config->>'mqtt_prefix' as prefix")
->orderBy('id')
->get();
foreach (collect($rows)->groupBy('prefix') as $group) {
if ($group->count() < 2) {
continue;
}
$survivorId = $group->first()->id;
$survivorKeys = DB::table('entities')->where('device_id', $survivorId)->pluck('key')->all();
foreach ($group->slice(1) as $dup) {
DB::table('entities')->where('device_id', $dup->id)->whereIn('key', $survivorKeys)->delete();
DB::table('entities')->where('device_id', $dup->id)->update(['device_id' => $survivorId]);
DB::table('devices')->where('id', $dup->id)->delete();
}
}
DB::statement("CREATE UNIQUE INDEX devices_mqtt_prefix_unique ON devices ((config->>'mqtt_prefix')) WHERE config->>'mqtt_prefix' IS NOT NULL");
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS devices_mqtt_prefix_unique');
}
};

View File

@ -13,9 +13,23 @@ topic write homeos/discovery/#
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.
# Shared device account (default onboarding): every Shelly signs in as `shelly` with one
# password (Home-Assistant style, no per-device setup). Scoped to device topics only — it can
# publish status/events for any prefix and do RPC, but cannot touch homeos/, ring/ or $SYS.
# `+` matches exactly one prefix level, so `+/status/#` = <prefix>/status/…, `+/rpc` covers both
# the device's <prefix>/rpc request topic and its homeos/rpc reply topic (src=homeos).
user shelly
topic write +/status/#
topic write +/events/#
topic write +/online
topic write +/debug/#
topic read +/command/#
topic read +/settings/#
topic readwrite +/rpc
# Per-device credentials (optional hardening): a device can instead authenticate with its OWN
# username = topic prefix (provisioned on the device detail page) and be bound to just its
# prefix via %u — a compromised device then cannot publish for, or control, another.
pattern write %u/status/#
pattern write %u/events/#
pattern write %u/online

View File

@ -25,21 +25,25 @@ ensure_pw() {
LPASS="$(ensure_pw MQTT_AUTH_PASSWORD)"
DPASS="$(ensure_pw MQTT_SIDECAR_PASSWORD)"
RPASS="$(ensure_pw MQTT_RING_PASSWORD)"
# Shared device account — the ONE credential every Shelly uses (Home-Assistant-style, no
# per-device setup). Scoped to device topics by the `shelly` ACL block.
SPASS="$(ensure_pw MQTT_SHELLY_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 RPASS
export LPASS DPASS RPASS SPASS
docker run --rm -e LPASS -e DPASS -e RPASS \
docker run --rm -e LPASS -e DPASS -e RPASS -e SPASS \
-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"
mosquitto_passwd -b /cfg/passwd shelly "$SPASS"
# 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, ring).'
echo 'Mosquitto passwd generated (users: laravel, sidecar, ring, shelly).'

View File

@ -63,4 +63,9 @@ return [
'mqtt_credential_hint' => 'Im Shelly MQTT aktivieren und diese Zugangsdaten sowie den Broker (Host der HomeOS-VM) eintragen. Das Passwort wird nicht erneut angezeigt.',
'mqtt_username' => 'Benutzer',
'mqtt_password' => 'Passwort',
'mqtt_no_prefix' => 'Kein MQTT-Prefix hinterlegt.',
'mqtt_host_hint' => 'IP:Port deines HomeOS-Servers',
'mqtt_own_cred' => 'Eigene MQTT-Zugangsdaten',
'mqtt_own_cred_hint' => 'Optional: nur für dieses Gerät statt des geteilten Kontos (mehr Sicherheit).',
'mqtt_own_cred_btn' => 'Erzeugen',
];

View File

@ -12,4 +12,21 @@ return [
'locale' => 'Sprache',
'timezone' => 'Zeitzone',
'host' => 'Host',
'device_mqtt' => 'Geräte-MQTT (Shelly)',
'device_mqtt_intro' => 'Trage diese Werte einmalig bei jedem Shelly unter Einstellungen → MQTT ein. Danach erscheint das Gerät automatisch — kein Zuweisen nötig.',
'device_mqtt_server' => 'Server',
'device_mqtt_user' => 'Benutzername',
'device_mqtt_pass' => 'Passwort',
'device_mqtt_host_hint'=> 'IP:Port deines HomeOS-Servers',
'device_mqtt_reveal' => 'Passwort anzeigen',
'device_mqtt_hide' => 'Passwort verbergen',
'device_mqtt_copy' => 'Kopieren',
'device_mqtt_copied' => 'Kopiert',
'device_mqtt_steps_title' => 'So verbindest du einen Shelly',
'device_mqtt_step1' => 'Shelly-Weboberfläche öffnen → Einstellungen → MQTT.',
'device_mqtt_step2' => '„Enable“ aktivieren, „Generic status update over MQTT“ und „RPC over MQTT“ einschalten.',
'device_mqtt_step3' => 'Server, Benutzername und Passwort von oben eintragen. MQTT-Prefix so lassen.',
'device_mqtt_step4' => 'Speichern — das Gerät erscheint innerhalb weniger Sekunden unter „Geräte“.',
'device_mqtt_missing' => 'Passwort noch nicht gesetzt. Führe docker/mosquitto/gen-passwd.sh aus.',
];

View File

@ -63,4 +63,9 @@ return [
'mqtt_credential_hint' => 'Enable MQTT on the Shelly and enter these credentials plus the broker (the HomeOS VM host). The password is not shown again.',
'mqtt_username' => 'Username',
'mqtt_password' => 'Password',
'mqtt_no_prefix' => 'No MQTT prefix configured.',
'mqtt_host_hint' => 'your HomeOS server IP:port',
'mqtt_own_cred' => 'Own MQTT credentials',
'mqtt_own_cred_hint' => 'Optional: for this device only instead of the shared account (more secure).',
'mqtt_own_cred_btn' => 'Generate',
];

View File

@ -12,4 +12,21 @@ return [
'locale' => 'Language',
'timezone' => 'Timezone',
'host' => 'Host',
'device_mqtt' => 'Device MQTT (Shelly)',
'device_mqtt_intro' => 'Enter these once on every Shelly under Settings → MQTT. The device then appears automatically — no assigning needed.',
'device_mqtt_server' => 'Server',
'device_mqtt_user' => 'Username',
'device_mqtt_pass' => 'Password',
'device_mqtt_host_hint'=> 'your HomeOS server IP:port',
'device_mqtt_reveal' => 'Show password',
'device_mqtt_hide' => 'Hide password',
'device_mqtt_copy' => 'Copy',
'device_mqtt_copied' => 'Copied',
'device_mqtt_steps_title' => 'Connecting a Shelly',
'device_mqtt_step1' => 'Open the Shelly web UI → Settings → MQTT.',
'device_mqtt_step2' => 'Tick “Enable”, then “Generic status update over MQTT” and “RPC over MQTT”.',
'device_mqtt_step3' => 'Enter the Server, Username and Password above. Leave the MQTT prefix as-is.',
'device_mqtt_step4' => 'Save — the device shows up under “Devices” within a few seconds.',
'device_mqtt_missing' => 'Password not set yet. Run docker/mosquitto/gen-passwd.sh.',
];

View File

@ -28,7 +28,8 @@
<div class="rounded-card border border-accent/30 bg-accent/10 p-4 flex flex-col gap-2">
<div class="flex items-center gap-2 text-[13px] font-bold text-ink"><x-icon name="lock" :size="16" class="text-accent" /> {{ __('devices.mqtt_credential_title') }}</div>
<p class="text-[12px] text-ink-2 max-w-2xl">{{ __('devices.mqtt_credential_hint') }}</p>
<dl class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 mt-1">
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 mt-1">
<x-detail :label="__('settings.device_mqtt_server')" mono>{{ $mqttCredential['host'] }}</x-detail>
<x-detail :label="__('devices.mqtt_username')" mono>{{ $mqttCredential['username'] }}</x-detail>
<x-detail :label="__('devices.mqtt_password')" mono>{{ $mqttCredential['password'] }}</x-detail>
<x-detail label="Port" mono>{{ $mqttCredential['port'] }}</x-detail>
@ -146,6 +147,20 @@
</div>
<x-toggle :on="$device->status === 'active'" wire:click="toggleStatus" :label="__('devices.active')" class="ml-auto" />
</div>
{{-- Advanced: per-device MQTT credential (default is the shared account) --}}
@if ($device->protocol === 'mqtt' && data_get($device->config, 'mqtt_prefix'))
<div class="flex items-center gap-3 border-t border-line-soft pt-3">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-ink">{{ __('devices.mqtt_own_cred') }}</div>
<div class="text-[11.5px] text-ink-3">{{ __('devices.mqtt_own_cred_hint') }}</div>
</div>
<button type="button" wire:click="generateMqttCredential"
class="ml-auto shrink-0 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">
{{ __('devices.mqtt_own_cred_btn') }}
</button>
</div>
@endif
</div>
</x-panel>
</div>

View File

@ -2,6 +2,70 @@
<x-topbar :title="__('nav.settings')" :subtitle="__('settings.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
{{-- Device MQTT the one shared credential every Shelly uses (HA-style) --}}
<x-panel :title="__('settings.device_mqtt')">
<p class="text-[12.5px] text-ink-2 mb-4 max-w-3xl">{{ __('settings.device_mqtt_intro') }}</p>
@php
$rows = [
['label' => __('settings.device_mqtt_server'), 'value' => $deviceMqtt['host'] ?: '⟨'.__('settings.device_mqtt_host_hint').'⟩'],
['label' => __('settings.device_mqtt_user'), 'value' => $deviceMqtt['username']],
];
@endphp
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@foreach ($rows as $row)
<div x-data="{ copied: false }" class="flex items-center gap-2 rounded-lg border border-line-soft bg-raised px-3 py-2.5">
<div class="min-w-0">
<div class="text-[11px] font-semibold text-ink-3">{{ $row['label'] }}</div>
<div class="text-[13px] font-mono text-ink truncate">{{ $row['value'] }}</div>
</div>
<button type="button"
x-on:click="navigator.clipboard.writeText(@js($row['value'])); copied = true; setTimeout(() => copied = false, 1500)"
class="ml-auto shrink-0 rounded-md border border-line px-2 py-1 text-[11px] font-semibold text-ink-2 hover:text-ink hover:bg-inset transition-colors">
<span x-show="!copied">{{ __('settings.device_mqtt_copy') }}</span>
<span x-show="copied" x-cloak class="text-online">{{ __('settings.device_mqtt_copied') }}</span>
</button>
</div>
@endforeach
{{-- password: hidden by default, reveal + copy --}}
<div x-data="{ show: false, copied: false }" class="sm:col-span-2 flex items-center gap-2 rounded-lg border border-line-soft bg-raised px-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="text-[11px] font-semibold text-ink-3">{{ __('settings.device_mqtt_pass') }}</div>
@if ($deviceMqtt['password'])
<div class="text-[13px] font-mono text-ink truncate">
<span x-show="show">{{ $deviceMqtt['password'] }}</span>
<span x-show="!show">••••••••••••••••</span>
</div>
@else
<div class="text-[12px] text-warning">{{ __('settings.device_mqtt_missing') }}</div>
@endif
</div>
@if ($deviceMqtt['password'])
<button type="button" x-on:click="show = !show"
class="shrink-0 rounded-md border border-line px-2 py-1 text-[11px] font-semibold text-ink-2 hover:text-ink hover:bg-inset transition-colors"
x-text="show ? @js(__('settings.device_mqtt_hide')) : @js(__('settings.device_mqtt_reveal'))"></button>
<button type="button"
x-on:click="navigator.clipboard.writeText(@js($deviceMqtt['password'])); copied = true; setTimeout(() => copied = false, 1500)"
class="shrink-0 rounded-md border border-line px-2 py-1 text-[11px] font-semibold text-ink-2 hover:text-ink hover:bg-inset transition-colors">
<span x-show="!copied">{{ __('settings.device_mqtt_copy') }}</span>
<span x-show="copied" x-cloak class="text-online">{{ __('settings.device_mqtt_copied') }}</span>
</button>
@endif
</div>
</div>
<div class="mt-4 pt-4 border-t border-line-soft">
<h3 class="text-[12px] font-bold text-ink-2 mb-2">{{ __('settings.device_mqtt_steps_title') }}</h3>
<ol class="flex flex-col gap-1.5 text-[12.5px] text-ink-2 list-decimal list-inside marker:text-ink-3 marker:font-mono">
<li>{{ __('settings.device_mqtt_step1') }}</li>
<li>{{ __('settings.device_mqtt_step2') }}</li>
<li>{{ __('settings.device_mqtt_step3') }}</li>
<li>{{ __('settings.device_mqtt_step4') }}</li>
</ol>
</div>
</x-panel>
<x-panel :title="__('settings.integrations')">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
@foreach ($integrations as $i)

View File

@ -50,12 +50,41 @@ class MqttTest extends TestCase
$this->assertTrue(data_get($switch->state->state, 'on'));
}
public function test_ingest_ignores_unknown_device(): void
public function test_unknown_device_auto_onboards_on_a_recognized_component(): void
{
(new IngestShellyMessage('shelly-nope/status/switch:0', json_encode(['output' => true])))->handle();
(new IngestShellyMessage('shelly1minig3-abc/status/switch:0', json_encode(['output' => true])))->handle();
$this->assertDatabaseCount('entities', 0);
$this->assertDatabaseCount('device_states', 0);
$device = Device::where('config->mqtt_prefix', 'shelly1minig3-abc')->first();
$this->assertNotNull($device);
$this->assertSame('Shelly', $device->vendor);
$this->assertSame('mqtt', $device->protocol);
$this->assertTrue((bool) data_get($device->config, 'auto_onboarded'));
$this->assertTrue(data_get($device->entities()->where('key', 'switch:0')->first()->state->state, 'on'));
}
public function test_housekeeping_topic_does_not_onboard_a_device(): void
{
// `sys`/`wifi`/`cloud` status is noise — it must not create a device on its own.
(new IngestShellyMessage('shelly1minig3-abc/status/sys', json_encode(['restart_required' => false])))->handle();
$this->assertDatabaseCount('devices', 0);
}
public function test_auto_onboard_can_be_disabled(): void
{
config()->set('homeos.mqtt.auto_onboard', false);
(new IngestShellyMessage('shelly1minig3-abc/status/switch:0', json_encode(['output' => true])))->handle();
$this->assertDatabaseCount('devices', 0);
}
public function test_concurrent_onboarding_does_not_duplicate(): void
{
(new IngestShellyMessage('shelly-dup/status/switch:0', json_encode(['output' => true])))->handle();
(new IngestShellyMessage('shelly-dup/status/switch:0', json_encode(['output' => false]), 2_000))->handle();
$this->assertSame(1, Device::where('config->mqtt_prefix', 'shelly-dup')->count());
}
public function test_command_service_audits_every_command(): void