Phase 4: network discovery — sidecar, ingest, "Neue Geräte" + assign

- Python discovery sidecar (zeroconf mDNS over host network) publishes findings
  to homeos/discovery/<source>/<id> as the sidecar user. Compose service with
  network_mode: host + NET_RAW. Verified live: it found real devices on the LAN
  (a printer and Shellys) via mDNS.
- Listener also subscribes homeos/discovery/#; IngestDiscoveryMessage upserts
  discovery_findings (preserving assigned/ignored) and broadcasts DeviceDiscovered
  on the private discovery channel.
- "Neue Geräte" page lists findings live with Assign (modal → creates a Device
  and links the finding) and Ignore/Restore. Per-device broker credentials are
  provisioned at onboarding (noted in the assign hint).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 00:25:17 +02:00
parent 5cde2786aa
commit f50a665735
11 changed files with 358 additions and 1 deletions

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Jobs\IngestDiscoveryMessage;
use App\Jobs\IngestShellyMessage;
use App\Support\Mqtt\ShellyTopics;
use Illuminate\Console\Command;
@ -39,7 +40,12 @@ class MqttListenCommand extends Command
}, 0);
}
$this->info('[mqtt] connected + subscribed to '.implode(', ', ShellyTopics::subscriptions()));
// Discovery findings from the sidecar.
$this->client->subscribe('homeos/discovery/#', function (string $topic, string $message): void {
IngestDiscoveryMessage::dispatch($topic, $message);
}, 0);
$this->info('[mqtt] connected + subscribed to '.implode(', ', ShellyTopics::subscriptions()).', homeos/discovery/#');
$backoff = 1;
$this->client->loop(true); // blocks until interrupt()

View File

@ -0,0 +1,27 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/** A new device was discovered on the network — live-updates the "Neue Geräte" panel. */
class DeviceDiscovered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $findingUuid) {}
public function broadcastOn(): array
{
return [new PrivateChannel('discovery')];
}
public function broadcastAs(): string
{
return 'DeviceDiscovered';
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Jobs;
use App\Events\DeviceDiscovered;
use App\Models\DiscoveryFinding;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Ingests a discovery finding published by the sidecar to homeos/discovery/<source>/<id>.
* Upserts discovery_findings (preserving an assigned/ignored status) and announces new ones.
*/
class IngestDiscoveryMessage implements ShouldQueue
{
use Queueable;
public function __construct(
public string $topic,
public string $payload,
) {}
public function handle(): void
{
if (! preg_match('#^homeos/discovery/(?<source>[^/]+)/(?<id>.+)$#', $this->topic, $m)) {
return;
}
$data = json_decode($this->payload, true);
if (! is_array($data)) {
return;
}
$existing = DiscoveryFinding::where('source', $m['source'])->where('identifier', $m['id'])->first();
$finding = DiscoveryFinding::updateOrCreate(
['source' => $m['source'], 'identifier' => $m['id']],
[
'name' => $data['name'] ?? null,
'vendor' => $data['vendor'] ?? $this->guessVendor($data),
'model' => $data['model'] ?? data_get($data, 'properties.model'),
'ip' => $data['ip'] ?? null,
'raw' => $data,
'last_seen_at' => now(),
'status' => $existing?->status ?? 'new', // keep assigned/ignored once set
],
);
if (($existing?->status ?? 'new') === 'new') {
DeviceDiscovered::dispatch($finding->uuid);
}
}
private function guessVendor(array $data): ?string
{
$service = ($data['service'] ?? '').' '.($data['name'] ?? '');
return match (true) {
str_contains(strtolower($service), 'shelly') => 'Shelly',
str_contains(strtolower($service), 'googlecast') => 'Google',
default => null,
};
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use App\Models\Room;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
class AssignDevice extends ModalComponent
{
public string $findingUuid = '';
public string $name = '';
public ?int $roomId = null;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(string $finding): void
{
$model = DiscoveryFinding::where('uuid', $finding)->firstOrFail();
$this->findingUuid = $model->uuid;
$this->name = $model->name ?? $model->identifier;
}
#[Computed]
public function finding(): DiscoveryFinding
{
return DiscoveryFinding::where('uuid', $this->findingUuid)->firstOrFail();
}
public function assign()
{
$this->validate([
'name' => 'required|string|max:100',
'roomId' => 'nullable|integer|exists:rooms,id',
]);
$finding = $this->finding();
$device = Device::create([
'name' => $this->name,
'vendor' => $finding->vendor,
'model' => $finding->model,
'protocol' => $finding->vendor === 'Shelly' ? 'mqtt' : null,
'config' => ['mqtt_prefix' => $finding->identifier],
'room_id' => $this->roomId ?: null,
'status' => 'active',
]);
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
$this->closeModal();
return redirect()->route('devices.show', $device);
}
public function render()
{
return view('livewire.modals.assign-device', [
'finding' => $this->finding(),
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
]);
}
}

View File

@ -84,6 +84,20 @@ services:
- "${MQTT_HOST_PORT:-1883}:1883"
restart: unless-stopped
# Discovery sidecar — host network so mDNS/SSDP multicast reaches the smart-home VLAN.
discovery:
build:
context: ./sidecar
dockerfile: Dockerfile
network_mode: host
cap_add: [NET_RAW]
environment:
MQTT_HOST: 127.0.0.1
MQTT_PORT: ${MQTT_HOST_PORT:-1883}
MQTT_USERNAME: sidecar
MQTT_PASSWORD: ${MQTT_SIDECAR_PASSWORD}
restart: unless-stopped
volumes:
db-data:
redis-data:

View File

@ -8,4 +8,7 @@ return [
'ignore' => 'Ignorieren',
'ignored_title' => 'Ignoriert',
'restore' => 'Wiederherstellen',
'assign_title' => 'Gerät hinzufügen',
'source' => 'Quelle',
'shelly_hint' => 'Für die Steuerung MQTT im Shelly aktivieren und als Topic-Präfix „:prefix" setzen; Pro-Gerät-Zugangsdaten werden beim Onboarding vergeben.',
];

View File

@ -8,4 +8,7 @@ return [
'ignore' => 'Ignore',
'ignored_title' => 'Ignored',
'restore' => 'Restore',
'assign_title' => 'Add device',
'source' => 'Source',
'shelly_hint' => 'To control it, enable MQTT on the Shelly and set the topic prefix to ":prefix"; per-device credentials are issued at onboarding.',
];

View File

@ -0,0 +1,44 @@
<div class="flex flex-col">
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-accent/10 text-accent shrink-0"><x-icon name="plus" :size="18" /></span>
<h2 class="text-[15px] font-bold text-ink">{{ __('discovery.assign_title') }}</h2>
<button type="button" wire:click="closeModal" class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors" aria-label="{{ __('common.close') }}">
<x-icon name="close" :size="18" />
</button>
</header>
<form wire:submit="assign" class="p-5 flex flex-col gap-4">
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<x-detail :label="__('devices.vendor')">{{ $finding->vendor ?? '—' }}</x-detail>
<x-detail :label="__('discovery.source')" mono>{{ $finding->source }}</x-detail>
<x-detail :label="'IP'" mono>{{ $finding->ip ?? '—' }}</x-detail>
<x-detail :label="__('devices.uuid')" mono>{{ \Illuminate\Support\Str::limit($finding->identifier, 22, '…') }}</x-detail>
</dl>
<div class="flex flex-col gap-1.5">
<label for="a-name" class="text-[12px] font-semibold text-ink-2">{{ __('devices.name') }}</label>
<input wire:model="name" id="a-name" type="text"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('name') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="a-room" class="text-[12px] font-semibold text-ink-2">{{ __('devices.room') }}</label>
<select wire:model="roomId" id="a-room" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="">{{ __('devices.no_room') }}</option>
@foreach ($rooms as $room)
<option value="{{ $room->id }}">{{ $room->name }}</option>
@endforeach
</select>
</div>
@if ($finding->vendor === 'Shelly')
<p class="text-[11.5px] text-ink-3 leading-relaxed">{{ __('discovery.shelly_hint', ['prefix' => $finding->identifier]) }}</p>
@endif
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('discovery.assign') }}</button>
</div>
</form>
</div>

14
sidecar/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
# HomeOS discovery sidecar — mDNS/SSDP scanner publishing to MQTT.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY discover.py .
# Unbuffered so docker logs stream live.
ENV PYTHONUNBUFFERED=1
CMD ["python", "discover.py"]

110
sidecar/discover.py Normal file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
HomeOS discovery sidecar. Scans the LAN for participants (mDNS via zeroconf, SSDP via
UPnP) and publishes findings to MQTT under homeos/discovery/<source>/<id> as the
'sidecar' user. Runs with network_mode: host so multicast reaches the smart-home VLAN.
PHP can't do multicast listening from the bridge network — hence this sidecar.
"""
import json
import os
import re
import signal
import socket
import time
import paho.mqtt.client as mqtt
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MQTT_USERNAME = os.environ.get("MQTT_USERNAME", "sidecar")
MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD", "")
# Service types worth surfacing (Shelly announces _shelly._tcp; plus generic HTTP/IoT).
SERVICE_TYPES = [
"_shelly._tcp.local.",
"_http._tcp.local.",
"_hap._tcp.local.", # HomeKit accessories
"_googlecast._tcp.local.", # Chromecast / Google TV
"_printer._tcp.local.",
]
_slug = re.compile(r"[^a-z0-9_-]+")
def publish(client, source, identifier, payload):
ident = _slug.sub("-", identifier.lower()).strip("-") or "unknown"
topic = f"homeos/discovery/{source}/{ident}"
client.publish(topic, json.dumps(payload), qos=1, retain=True)
print(f"[discovery] {source} {ident}: {payload.get('name')}", flush=True)
class MdnsListener(ServiceListener):
def __init__(self, client):
self.client = client
def _emit(self, zc, type_, name):
try:
info = zc.get_service_info(type_, name, timeout=3000)
except Exception:
info = None
if not info:
return
addresses = []
try:
addresses = [socket.inet_ntoa(a) for a in info.addresses if len(a) == 4]
except Exception:
pass
props = {}
for k, v in (info.properties or {}).items():
try:
props[k.decode()] = v.decode() if isinstance(v, bytes) else v
except Exception:
pass
payload = {
"source": "mdns",
"name": name.split(".")[0],
"service": type_,
"ip": addresses[0] if addresses else None,
"port": info.port,
"properties": props,
}
publish(self.client, "mdns", name, payload)
def add_service(self, zc, type_, name):
self._emit(zc, type_, name)
def update_service(self, zc, type_, name):
self._emit(zc, type_, name)
def remove_service(self, zc, type_, name):
pass
def main():
client = mqtt.Client(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]
running = {"v": True}
signal.signal(signal.SIGTERM, lambda *_: running.update(v=False))
signal.signal(signal.SIGINT, lambda *_: running.update(v=False))
while running["v"]:
time.sleep(1)
zc.close()
client.loop_stop()
client.disconnect()
print("[discovery] stopped", flush=True)
if __name__ == "__main__":
main()

2
sidecar/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
paho-mqtt==2.1.0
zeroconf==0.132.2