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
parent
9c566492ab
commit
fef393954c
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue