feat(devices): auto-detect host IP, discovery dedup, delete + rescan

Addresses the device-management feedback:
- The MQTT server address is no longer hardcoded (was 10.10.90.110). It's
  auto-detected from the address you open HomeOS at (App\Support\HostAddress —
  request host + port), so it follows a DHCP/changed IP. MQTT_DEVICE_HOST is now
  an optional override only. Settings + device page use it.
- Discovery dedup: a finding whose prefix already belongs to a device (assigned
  OR auto-onboarded) no longer shows under "Neue Geräte" — fixes the device
  appearing both in discovery and in Geräte.
- Delete device: confirm-guarded button on the device page; deleting frees the
  discovery finding so the device can be re-added.
- "Neu scannen" button on the network page → publishes homeos/sidecar/rescan;
  the sidecar (now a subscriber) re-queries mDNS. ACL grants it read on that
  topic. paho-mqtt pinned to the v2 callback API.

+4 tests (host override/fallback, dedup, delete frees finding). Suite 62 green,
12/12 clean. Live-verified: rescan reaches sidecar; duplicate Shelly gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 09:34:07 +02:00
parent 449533b60d
commit ecfc49e665
14 changed files with 212 additions and 13 deletions

View File

@ -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

View File

@ -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(),
]);
}

View File

@ -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'),

View File

@ -0,0 +1,30 @@
<?php
namespace App\Support;
/**
* The address smart-home devices should point at to reach this server's MQTT broker.
*
* We can't read the Docker host's LAN IP from inside the container reliably, so we derive it
* from the request host the address the user opened HomeOS at is, by definition, this server's
* reachable LAN address. `MQTT_DEVICE_HOST` overrides it only if that ever guesses wrong.
*/
class HostAddress
{
public static function port(): int
{
return (int) config('homeos.mqtt.port', 1883);
}
/** "host:port" for the device's MQTT server field. */
public static function broker(): string
{
$override = config('homeos.mqtt.device_host');
if (filled($override)) {
return str_contains($override, ':') ? $override : $override.':'.self::port();
}
return request()->getHost().':'.self::port();
}
}

View File

@ -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).

View File

@ -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.

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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">
<x-icon name="wifi" :size="16" class="text-ink-2" /> {{ __('devices.check_update') }}
</button>
<button type="button"
wire:click="$dispatch('openModal', { component: 'modals.confirm', arguments: { title: @js(__('devices.delete_confirm_title')), body: @js(__('devices.delete_confirm_body', ['name' => $device->name])), confirmLabel: @js(__('devices.delete')), event: 'deleteDevice', to: 'devices.show', danger: true } })"
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-3 hover:text-offline hover:border-offline/40 transition-colors">
<x-icon name="close" :size="16" /> {{ __('devices.delete') }}
</button>
</div>
<p class="mt-3 text-[11.5px] text-ink-3">{{ __('devices.actions_demo_hint') }}</p>
</x-panel>

View File

@ -1,7 +1,22 @@
<div wire:poll.15s>
<x-topbar :title="__('nav.network')" :subtitle="__('discovery.subtitle')" />
<x-topbar :title="__('nav.network')" :subtitle="__('discovery.subtitle')">
<x-slot:actions>
<button type="button" wire:click="rescan" wire:loading.attr="disabled" wire:target="rescan"
class="inline-flex items-center gap-1.5 rounded-lg border border-line-soft bg-raised px-3 py-1.5 text-[12px] font-semibold text-ink-2 hover:text-ink transition-colors disabled:opacity-60">
<span wire:loading.remove wire:target="rescan"><x-icon name="network" :size="15" /></span>
<span wire:loading wire:target="rescan"><x-icon name="network" :size="15" class="pulse-live" /></span>
{{ __('discovery.rescan') }}
</button>
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($rescanning)
<div class="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/10 px-3.5 py-2.5 text-[12.5px] text-accent">
<x-icon name="network" :size="15" class="pulse-live" /> {{ __('discovery.rescanning') }}
</div>
@endif
<x-panel :title="__('discovery.new_title')">
<x-slot:actions><x-badge :variant="$new->count() > 0 ? 'accent' : 'default'">{{ $new->count() }}</x-badge></x-slot:actions>
@if ($new->isEmpty())

View File

@ -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))

View File

@ -0,0 +1,70 @@
<?php
namespace Tests\Feature;
use App\Livewire\Devices\Show;
use App\Livewire\Discovery\Index as DiscoveryIndex;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use App\Models\User;
use App\Support\HostAddress;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class DeviceManagementTest extends TestCase
{
use RefreshDatabase;
public function test_host_address_uses_the_override_when_set(): void
{
config()->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);
}
}