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
parent
5cde2786aa
commit
f50a665735
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
paho-mqtt==2.1.0
|
||||
zeroconf==0.132.2
|
||||
Loading…
Reference in New Issue