#!/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()