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