diff --git a/.env.example b/.env.example index e4cf6ad..41ebd19 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php index 4118e56..dc0bead 100644 --- a/app/Jobs/IngestShellyMessage.php +++ b/app/Jobs/IngestShellyMessage.php @@ -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 $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(); + } + } } diff --git a/app/Livewire/Devices/Show.php b/app/Livewire/Devices/Show.php index 1d41b35..a3845bc 100644 --- a/app/Livewire/Devices/Show.php +++ b/app/Livewire/Devices/Show.php @@ -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 diff --git a/app/Livewire/Modals/AssignDevice.php b/app/Livewire/Modals/AssignDevice.php index 50afe44..a7a9ad0 100644 --- a/app/Livewire/Modals/AssignDevice.php +++ b/app/Livewire/Modals/AssignDevice.php @@ -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); diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 67ef5ad..8f57e5f 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -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'), + ], ]); } } diff --git a/app/Support/Mqtt/ShellyNormalizer.php b/app/Support/Mqtt/ShellyNormalizer.php index d4dc6a9..46c8fdd 100644 --- a/app/Support/Mqtt/ShellyNormalizer.php +++ b/app/Support/Mqtt/ShellyNormalizer.php @@ -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 $payload * @return array}> diff --git a/config/homeos.php b/config/homeos.php new file mode 100644 index 0000000..3cad6af --- /dev/null +++ b/config/homeos.php @@ -0,0 +1,25 @@ + [ + // 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), + ], +]; diff --git a/database/migrations/2026_07_18_070001_add_mqtt_prefix_unique_index_to_devices.php b/database/migrations/2026_07_18_070001_add_mqtt_prefix_unique_index_to_devices.php new file mode 100644 index 0000000..712c336 --- /dev/null +++ b/database/migrations/2026_07_18_070001_add_mqtt_prefix_unique_index_to_devices.php @@ -0,0 +1,43 @@ +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'); + } +}; diff --git a/docker/mosquitto/config/acl b/docker/mosquitto/config/acl index c688058..51e693e 100644 --- a/docker/mosquitto/config/acl +++ b/docker/mosquitto/config/acl @@ -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/#` = /status/…, `+/rpc` covers both +# the device's /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 diff --git a/docker/mosquitto/gen-passwd.sh b/docker/mosquitto/gen-passwd.sh index 5719a24..5288848 100755 --- a/docker/mosquitto/gen-passwd.sh +++ b/docker/mosquitto/gen-passwd.sh @@ -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).' diff --git a/lang/de/devices.php b/lang/de/devices.php index 9c69082..70702a7 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -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', ]; diff --git a/lang/de/settings.php b/lang/de/settings.php index cb635d0..b73435f 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -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.', ]; diff --git a/lang/en/devices.php b/lang/en/devices.php index 76c8a56..34cd7eb 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -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', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 8071f83..a5e5c19 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -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.', ]; diff --git a/resources/views/livewire/devices/show.blade.php b/resources/views/livewire/devices/show.blade.php index 88e6516..c1a6e36 100644 --- a/resources/views/livewire/devices/show.blade.php +++ b/resources/views/livewire/devices/show.blade.php @@ -28,7 +28,8 @@
{{ __('devices.mqtt_credential_title') }}

{{ __('devices.mqtt_credential_hint') }}

-
+
+ {{ $mqttCredential['host'] }} {{ $mqttCredential['username'] }} {{ $mqttCredential['password'] }} {{ $mqttCredential['port'] }} @@ -146,6 +147,20 @@
+ + {{-- Advanced: per-device MQTT credential (default is the shared account) --}} + @if ($device->protocol === 'mqtt' && data_get($device->config, 'mqtt_prefix')) +
+
+
{{ __('devices.mqtt_own_cred') }}
+
{{ __('devices.mqtt_own_cred_hint') }}
+
+ +
+ @endif diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index ee1fd4d..fa13383 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -2,6 +2,70 @@
+ {{-- Device MQTT — the one shared credential every Shelly uses (HA-style) --}} + +

{{ __('settings.device_mqtt_intro') }}

+ + @php + $rows = [ + ['label' => __('settings.device_mqtt_server'), 'value' => $deviceMqtt['host'] ?: '⟨'.__('settings.device_mqtt_host_hint').'⟩'], + ['label' => __('settings.device_mqtt_user'), 'value' => $deviceMqtt['username']], + ]; + @endphp +
+ @foreach ($rows as $row) +
+
+
{{ $row['label'] }}
+
{{ $row['value'] }}
+
+ +
+ @endforeach + + {{-- password: hidden by default, reveal + copy --}} +
+
+
{{ __('settings.device_mqtt_pass') }}
+ @if ($deviceMqtt['password']) +
+ {{ $deviceMqtt['password'] }} + •••••••••••••••• +
+ @else +
{{ __('settings.device_mqtt_missing') }}
+ @endif +
+ @if ($deviceMqtt['password']) + + + @endif +
+
+ +
+

{{ __('settings.device_mqtt_steps_title') }}

+
    +
  1. {{ __('settings.device_mqtt_step1') }}
  2. +
  3. {{ __('settings.device_mqtt_step2') }}
  4. +
  5. {{ __('settings.device_mqtt_step3') }}
  6. +
  7. {{ __('settings.device_mqtt_step4') }}
  8. +
+
+
+
@foreach ($integrations as $i) diff --git a/tests/Feature/MqttTest.php b/tests/Feature/MqttTest.php index 506ccd8..d8d07fc 100644 --- a/tests/Feature/MqttTest.php +++ b/tests/Feature/MqttTest.php @@ -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