diff --git a/app/Livewire/Devices/Show.php b/app/Livewire/Devices/Show.php index fab834e..b65a626 100644 --- a/app/Livewire/Devices/Show.php +++ b/app/Livewire/Devices/Show.php @@ -56,8 +56,8 @@ class Show extends Component $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), + 'host' => \App\Support\HostAddress::broker(), + 'port' => \App\Support\HostAddress::port(), ]; } @@ -110,6 +110,22 @@ class Show extends Component $this->flashCommand($command->result); } + /** Delete the device (cascades entities/state) and free its discovery finding to be re-added. */ + #[On('deleteDevice')] + public function deleteDevice() + { + $prefix = data_get($this->device->config, 'mqtt_prefix'); + + \App\Models\DiscoveryFinding::query() + ->where('device_id', $this->device->id) + ->when($prefix, fn ($q) => $q->orWhere('name', $prefix)->orWhere('identifier', $prefix)) + ->update(['status' => 'new', 'device_id' => null]); + + $this->device->delete(); + + return redirect()->route('devices.index'); + } + /** * Assign a Shelly input a role: '' = plain input, 'window'/'door' = a contact sensor. * Stored in device config so the ingest maps future messages accordingly; the entity is diff --git a/app/Livewire/Discovery/Index.php b/app/Livewire/Discovery/Index.php index 50ccbb0..f93ba96 100644 --- a/app/Livewire/Discovery/Index.php +++ b/app/Livewire/Discovery/Index.php @@ -2,14 +2,18 @@ namespace App\Livewire\Discovery; +use App\Models\Device; use App\Models\DiscoveryFinding; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Component; +use PhpMqtt\Client\Facades\MQTT; #[Layout('layouts.app')] class Index extends Component { + public bool $rescanning = false; + #[On('echo-private:discovery,.DeviceDiscovered')] public function onDiscovered(): void {} @@ -23,12 +27,32 @@ class Index extends Component DiscoveryFinding::where('uuid', $uuid)->update(['status' => 'new']); } + /** Ask the discovery sidecar to re-query the network for participants. */ + public function rescan(): void + { + try { + MQTT::connection()->publish('homeos/sidecar/rescan', (string) now()->getTimestamp(), 0); + $this->rescanning = true; + } catch (\Throwable $e) { + report($e); + } + } + public function render() { $findings = DiscoveryFinding::query()->orderByDesc('last_seen_at')->orderByDesc('id')->get(); + // A finding is only "new" if it hasn't already become a device — either linked at assign + // (device_id) or auto-onboarded by MQTT (matched by topic prefix). This stops a device + // showing up both under "Neue Geräte" and under Geräte. + $knownPrefixes = Device::query()->whereNotNull('config->mqtt_prefix')->get() + ->map(fn ($d) => data_get($d->config, 'mqtt_prefix'))->filter()->values()->all(); + + $isAdded = fn (DiscoveryFinding $f) => $f->device_id !== null + || in_array($f->name ?: $f->identifier, $knownPrefixes, true); + return view('livewire.discovery.index', [ - 'new' => $findings->where('status', 'new')->values(), + 'new' => $findings->where('status', 'new')->reject($isAdded)->values(), 'ignored' => $findings->where('status', 'ignored')->values(), ]); } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 8f57e5f..71d5950 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -32,7 +32,7 @@ class Index extends Component 'integrations' => $integrations, 'version' => app()->version(), 'deviceMqtt' => [ - 'host' => config('homeos.mqtt.device_host'), + 'host' => \App\Support\HostAddress::broker(), 'username' => config('homeos.mqtt.device_username'), 'password' => config('homeos.mqtt.device_password'), 'port' => config('homeos.mqtt.port'), diff --git a/app/Support/HostAddress.php b/app/Support/HostAddress.php new file mode 100644 index 0000000..b950348 --- /dev/null +++ b/app/Support/HostAddress.php @@ -0,0 +1,30 @@ +getHost().':'.self::port(); + } +} diff --git a/config/homeos.php b/config/homeos.php index 5a437d1..184b0a4 100644 --- a/config/homeos.php +++ b/config/homeos.php @@ -10,8 +10,9 @@ return [ | 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. + // Optional override for the address DEVICES connect to. Normally left null and + // auto-detected from the request host (the LAN IP you open HomeOS at) — see + // App\Support\HostAddress. Set MQTT_DEVICE_HOST only to force a specific value. 'device_host' => env('MQTT_DEVICE_HOST'), // The shared device account. Username is fixed (matches the `shelly` ACL block). diff --git a/docker/mosquitto/config/acl b/docker/mosquitto/config/acl index fc9c34b..d92aa35 100644 --- a/docker/mosquitto/config/acl +++ b/docker/mosquitto/config/acl @@ -4,9 +4,10 @@ user laravel topic readwrite # -# Discovery sidecar (Phase 4): only publishes discovery findings. +# Discovery sidecar (Phase 4): publishes discovery findings + listens for a rescan command. user sidecar topic write homeos/discovery/# +topic read homeos/sidecar/rescan # Ring bridge (ring-mqtt add-on): owns the ring/ namespace only. Cloud-bridged devices — # it publishes their state and reads its own command topics, nothing else on the bus. diff --git a/lang/de/devices.php b/lang/de/devices.php index efb56eb..beeae96 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -16,6 +16,9 @@ return [ 'role_window' => 'Fensterkontakt', 'role_door' => 'Türkontakt', 'role_invert' => 'Invertiert', + 'delete' => 'Gerät entfernen', + 'delete_confirm_title' => 'Gerät entfernen?', + 'delete_confirm_body' => ':name wird mit allen Werten gelöscht. Meldet sich das Gerät erneut, taucht es wieder unter „Neue Geräte“ auf.', 'battery' => 'Batterie', 'motion' => 'Bewegung', 'no_motion' => 'Ruhe', diff --git a/lang/de/discovery.php b/lang/de/discovery.php index 9cbabba..2f028dd 100644 --- a/lang/de/discovery.php +++ b/lang/de/discovery.php @@ -4,6 +4,8 @@ return [ 'subtitle' => 'Neue Teilnehmer im Netzwerk.', 'new_title' => 'Neue Geräte', 'scanning' => 'Netzwerk wird durchsucht… neue Geräte erscheinen hier automatisch.', + 'rescan' => 'Neu scannen', + 'rescanning' => 'Netzwerk wird neu durchsucht…', 'assign' => 'Zuweisen', 'ignore' => 'Ignorieren', 'ignored_title' => 'Ignoriert', diff --git a/lang/en/devices.php b/lang/en/devices.php index 0313913..c8cdea0 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -16,6 +16,9 @@ return [ 'role_window' => 'Window contact', 'role_door' => 'Door contact', 'role_invert' => 'Inverted', + 'delete' => 'Remove device', + 'delete_confirm_title' => 'Remove device?', + 'delete_confirm_body' => ':name and all its values are deleted. If the device reports again it reappears under “New devices”.', 'battery' => 'Battery', 'motion' => 'Motion', 'no_motion' => 'Idle', diff --git a/lang/en/discovery.php b/lang/en/discovery.php index 42ee26e..98de375 100644 --- a/lang/en/discovery.php +++ b/lang/en/discovery.php @@ -4,6 +4,8 @@ return [ 'subtitle' => 'New participants on the network.', 'new_title' => 'New devices', 'scanning' => 'Scanning the network… new devices appear here automatically.', + 'rescan' => 'Rescan', + 'rescanning' => 'Rescanning the network…', 'assign' => 'Assign', 'ignore' => 'Ignore', 'ignored_title' => 'Ignored', diff --git a/resources/views/livewire/devices/show.blade.php b/resources/views/livewire/devices/show.blade.php index bb79396..808cb8a 100644 --- a/resources/views/livewire/devices/show.blade.php +++ b/resources/views/livewire/devices/show.blade.php @@ -137,6 +137,11 @@ class="flex items-center gap-2.5 rounded-lg border border-line-soft bg-raised px-3 py-2.5 text-[13px] font-semibold text-ink hover:border-line transition-colors"> {{ __('devices.check_update') }} +

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

diff --git a/resources/views/livewire/discovery/index.blade.php b/resources/views/livewire/discovery/index.blade.php index bf36f57..3587869 100644 --- a/resources/views/livewire/discovery/index.blade.php +++ b/resources/views/livewire/discovery/index.blade.php @@ -1,7 +1,22 @@
- + + + + +
+ @if ($rescanning) +
+ {{ __('discovery.rescanning') }} +
+ @endif + {{ $new->count() }} @if ($new->isEmpty()) diff --git a/sidecar/discover.py b/sidecar/discover.py index d04812c..536ffce 100644 --- a/sidecar/discover.py +++ b/sidecar/discover.py @@ -81,17 +81,44 @@ class MdnsListener(ServiceListener): pass +RESCAN_TOPIC = "homeos/sidecar/rescan" + + def main(): - client = mqtt.Client(client_id="homeos-discovery") + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1, client_id="homeos-discovery") if MQTT_USERNAME: client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD) - client.connect(MQTT_HOST, MQTT_PORT, keepalive=30) - client.loop_start() - print(f"[discovery] connected to mqtt {MQTT_HOST}:{MQTT_PORT}, scanning…", flush=True) zc = Zeroconf() listener = MdnsListener(client) - browsers = [ServiceBrowser(zc, t, listener) for t in SERVICE_TYPES] + state = {"browsers": []} + + def start_browsers(): + # Cancel any running browsers and start fresh ones — creating a ServiceBrowser issues a + # new mDNS query, so devices that already announced re-respond and their findings refresh. + for b in state["browsers"]: + try: + b.cancel() + except Exception: + pass + state["browsers"] = [ServiceBrowser(zc, t, listener) for t in SERVICE_TYPES] + print("[discovery] scanning…", flush=True) + + def on_connect(cli, userdata, flags, rc): + cli.subscribe(RESCAN_TOPIC, qos=1) + + def on_message(cli, userdata, msg): + if msg.topic == RESCAN_TOPIC: + print("[discovery] rescan requested", flush=True) + start_browsers() + + client.on_connect = on_connect + client.on_message = on_message + client.connect(MQTT_HOST, MQTT_PORT, keepalive=30) + client.loop_start() + print(f"[discovery] connected to mqtt {MQTT_HOST}:{MQTT_PORT}", flush=True) + + start_browsers() running = {"v": True} signal.signal(signal.SIGTERM, lambda *_: running.update(v=False)) diff --git a/tests/Feature/DeviceManagementTest.php b/tests/Feature/DeviceManagementTest.php new file mode 100644 index 0000000..c93a4a2 --- /dev/null +++ b/tests/Feature/DeviceManagementTest.php @@ -0,0 +1,70 @@ +set('homeos.mqtt.device_host', '10.0.0.5:1883'); + $this->assertSame('10.0.0.5:1883', HostAddress::broker()); + + // Override without a port gets the configured port appended. + config()->set('homeos.mqtt.device_host', '10.0.0.5'); + config()->set('homeos.mqtt.port', 1883); + $this->assertSame('10.0.0.5:1883', HostAddress::broker()); + } + + public function test_host_address_falls_back_to_the_request_host(): void + { + config()->set('homeos.mqtt.device_host', null); + config()->set('homeos.mqtt.port', 1883); + + $this->actingAs(User::factory()->create()); + Livewire::test(\App\Livewire\Settings\Index::class) + ->assertViewHas('deviceMqtt', fn ($m) => str_ends_with($m['host'], ':1883') && $m['host'] !== ':1883'); + } + + public function test_added_device_is_hidden_from_new_discovery_findings(): void + { + $this->actingAs(User::factory()->create()); + + DiscoveryFinding::create(['identifier' => 'shelly1-abc', 'name' => 'shelly1-abc', 'source' => 'mdns', 'status' => 'new']); + DiscoveryFinding::create(['identifier' => 'shelly1-new', 'name' => 'shelly1-new', 'source' => 'mdns', 'status' => 'new']); + + // A device already exists for the first finding's prefix. + Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'config' => ['mqtt_prefix' => 'shelly1-abc']]); + + Livewire::test(DiscoveryIndex::class) + ->assertViewHas('new', fn ($new) => $new->count() === 1 && $new->first()->identifier === 'shelly1-new'); + } + + public function test_deleting_a_device_frees_its_discovery_finding(): void + { + $this->actingAs(User::factory()->create()); + + $device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'config' => ['mqtt_prefix' => 'shelly1-abc']]); + $finding = DiscoveryFinding::create(['identifier' => 'shelly1-abc', 'name' => 'shelly1-abc', 'source' => 'mdns', 'status' => 'assigned', 'device_id' => $device->id]); + + Livewire::test(Show::class, ['device' => $device]) + ->call('deleteDevice') + ->assertRedirect(route('devices.index')); + + $this->assertModelMissing($device); + $finding->refresh(); + $this->assertSame('new', $finding->status); + $this->assertNull($finding->device_id); + } +}