From f50a665735807eb1dce866a3f4d1ab0469eef63d Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 00:25:17 +0200 Subject: [PATCH] =?UTF-8?q?Phase=204:=20network=20discovery=20=E2=80=94=20?= =?UTF-8?q?sidecar,=20ingest,=20"Neue=20Ger=C3=A4te"=20+=20assign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Python discovery sidecar (zeroconf mDNS over host network) publishes findings to homeos/discovery// 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 --- app/Console/Commands/MqttListenCommand.php | 8 +- app/Events/DeviceDiscovered.php | 27 +++++ app/Jobs/IngestDiscoveryMessage.php | 64 ++++++++++ app/Livewire/Modals/AssignDevice.php | 70 +++++++++++ docker-compose.yml | 14 +++ lang/de/discovery.php | 3 + lang/en/discovery.php | 3 + .../livewire/modals/assign-device.blade.php | 44 +++++++ sidecar/Dockerfile | 14 +++ sidecar/discover.py | 110 ++++++++++++++++++ sidecar/requirements.txt | 2 + 11 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 app/Events/DeviceDiscovered.php create mode 100644 app/Jobs/IngestDiscoveryMessage.php create mode 100644 app/Livewire/Modals/AssignDevice.php create mode 100644 resources/views/livewire/modals/assign-device.blade.php create mode 100644 sidecar/Dockerfile create mode 100644 sidecar/discover.py create mode 100644 sidecar/requirements.txt diff --git a/app/Console/Commands/MqttListenCommand.php b/app/Console/Commands/MqttListenCommand.php index 162236a..f6a9b7a 100644 --- a/app/Console/Commands/MqttListenCommand.php +++ b/app/Console/Commands/MqttListenCommand.php @@ -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() diff --git a/app/Events/DeviceDiscovered.php b/app/Events/DeviceDiscovered.php new file mode 100644 index 0000000..2174eaf --- /dev/null +++ b/app/Events/DeviceDiscovered.php @@ -0,0 +1,27 @@ +/. + * 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/(?[^/]+)/(?.+)$#', $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, + }; + } +} diff --git a/app/Livewire/Modals/AssignDevice.php b/app/Livewire/Modals/AssignDevice.php new file mode 100644 index 0000000..4e65682 --- /dev/null +++ b/app/Livewire/Modals/AssignDevice.php @@ -0,0 +1,70 @@ +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(), + ]); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 243635d..df8babe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/lang/de/discovery.php b/lang/de/discovery.php index 405136c..9cbabba 100644 --- a/lang/de/discovery.php +++ b/lang/de/discovery.php @@ -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.', ]; diff --git a/lang/en/discovery.php b/lang/en/discovery.php index 26afeb0..42ee26e 100644 --- a/lang/en/discovery.php +++ b/lang/en/discovery.php @@ -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.', ]; diff --git a/resources/views/livewire/modals/assign-device.blade.php b/resources/views/livewire/modals/assign-device.blade.php new file mode 100644 index 0000000..ce82691 --- /dev/null +++ b/resources/views/livewire/modals/assign-device.blade.php @@ -0,0 +1,44 @@ +
+
+ +

{{ __('discovery.assign_title') }}

+ +
+ +
+
+ {{ $finding->vendor ?? '—' }} + {{ $finding->source }} + {{ $finding->ip ?? '—' }} + {{ \Illuminate\Support\Str::limit($finding->identifier, 22, '…') }} +
+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + +
+ + @if ($finding->vendor === 'Shelly') +

{{ __('discovery.shelly_hint', ['prefix' => $finding->identifier]) }}

+ @endif + +
+ + +
+
+
diff --git a/sidecar/Dockerfile b/sidecar/Dockerfile new file mode 100644 index 0000000..90711cc --- /dev/null +++ b/sidecar/Dockerfile @@ -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"] diff --git a/sidecar/discover.py b/sidecar/discover.py new file mode 100644 index 0000000..d04812c --- /dev/null +++ b/sidecar/discover.py @@ -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// 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() diff --git a/sidecar/requirements.txt b/sidecar/requirements.txt new file mode 100644 index 0000000..b8f1a48 --- /dev/null +++ b/sidecar/requirements.txt @@ -0,0 +1,2 @@ +paho-mqtt==2.1.0 +zeroconf==0.132.2