138 lines
4.2 KiB
Python
138 lines
4.2 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
|
|
|
|
|
|
RESCAN_TOPIC = "homeos/sidecar/rescan"
|
|
|
|
|
|
def main():
|
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1, client_id="homeos-discovery")
|
|
if MQTT_USERNAME:
|
|
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
|
|
|
zc = Zeroconf()
|
|
listener = MdnsListener(client)
|
|
state = {"browsers": []}
|
|
|
|
def start_browsers():
|
|
# Cancel any running browsers and start fresh ones — creating a ServiceBrowser issues a
|
|
# new mDNS query, so devices that already announced re-respond and their findings refresh.
|
|
for b in state["browsers"]:
|
|
try:
|
|
b.cancel()
|
|
except Exception:
|
|
pass
|
|
state["browsers"] = [ServiceBrowser(zc, t, listener) for t in SERVICE_TYPES]
|
|
print("[discovery] scanning…", flush=True)
|
|
|
|
def on_connect(cli, userdata, flags, rc):
|
|
cli.subscribe(RESCAN_TOPIC, qos=1)
|
|
|
|
def on_message(cli, userdata, msg):
|
|
if msg.topic == RESCAN_TOPIC:
|
|
print("[discovery] rescan requested", flush=True)
|
|
start_browsers()
|
|
|
|
client.on_connect = on_connect
|
|
client.on_message = on_message
|
|
client.connect(MQTT_HOST, MQTT_PORT, keepalive=30)
|
|
client.loop_start()
|
|
print(f"[discovery] connected to mqtt {MQTT_HOST}:{MQTT_PORT}", flush=True)
|
|
|
|
start_browsers()
|
|
|
|
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()
|