homeos/sidecar/discover.py

111 lines
3.4 KiB
Python

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