11"""
2- flask_server.py — Flask app, all routes, and the thread runner.
2+ flask_server.py — Flask app + Zeroconf mesh discovery.
3+ KIDA registers itself on the local network and discovers other robots/nodes
4+ automatically. The dashboard at / shows live status of all found peers.
35"""
46
57import logging
6- from flask import Flask , jsonify , request , render_template
8+ import socket
9+ import threading
10+ import requests
11+ from flask import Flask , jsonify , request , render_template , render_template_string
12+ from zeroconf import ServiceInfo , Zeroconf , ServiceBrowser
713
814from shared_state import (
915 command_queue ,
1218 _face_results , _face_lock ,
1319)
1420
15- app = Flask (__name__ , static_folder = "static" , template_folder = "templates" )
21+ # ── Config ─────────────────────────────────────────────────────────────────────
22+ THIS_NAME = "KIDA00"
23+ THIS_PORT = 5003
24+ TYPE = "_flask-link._tcp.local."
1625
1726logger = logging .getLogger ("kida.flask" )
27+ app = Flask (__name__ , static_folder = "static" , template_folder = "templates" )
1828
29+ # ── Network discovery ──────────────────────────────────────────────────────────
30+ found_servers : dict = {}
31+ _found_lock = threading .Lock ()
1932
33+
34+ def get_ip () -> str :
35+ s = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
36+ try :
37+ s .connect (("10.255.255.255" , 1 ))
38+ return s .getsockname ()[0 ]
39+ except Exception :
40+ return "127.0.0.1"
41+ finally :
42+ s .close ()
43+
44+
45+ my_ip = get_ip ()
46+
47+
48+ class _Listener :
49+ def remove_service (self , zc , type_ , name ):
50+ short = name .split ("." )[0 ]
51+ with _found_lock :
52+ found_servers .pop (short , None )
53+ logger .info ("Peer left: %s" , short )
54+
55+ def add_service (self , zc , type_ , name ):
56+ self .update_service (zc , type_ , name )
57+
58+ def update_service (self , zc , type_ , name ):
59+ info = zc .get_service_info (type_ , name )
60+ if info :
61+ addresses = [socket .inet_ntoa (a ) for a in info .addresses ]
62+ if addresses :
63+ short = name .split ("." )[0 ]
64+ if short != THIS_NAME :
65+ url = f"http://{ addresses [0 ]} :{ info .port } "
66+ with _found_lock :
67+ found_servers [short ] = url
68+ logger .info ("Peer found: %s @ %s" , short , url )
69+
70+
71+ # Zeroconf — started once at import time
72+ _zeroconf = Zeroconf ()
73+ _zc_info = ServiceInfo (
74+ TYPE ,
75+ f"{ THIS_NAME } .{ TYPE } " ,
76+ addresses = [socket .inet_aton (my_ip )],
77+ port = THIS_PORT ,
78+ properties = {"version" : "1.0" },
79+ )
80+ _zeroconf .register_service (_zc_info )
81+ ServiceBrowser (_zeroconf , TYPE , _Listener ())
82+ logger .info ("Zeroconf registered: %s on %s:%d" , THIS_NAME , my_ip , THIS_PORT )
83+
84+
85+ def shutdown_zeroconf () -> None :
86+ """Call this during app shutdown to cleanly deregister from the network."""
87+ logger .info ("Unregistering Zeroconf service..." )
88+ _zeroconf .unregister_service (_zc_info )
89+ _zeroconf .close ()
90+
91+
92+ # ── Routes ─────────────────────────────────────────────────────────────────────
2093@app .route ("/" )
21- def home ():
22- return render_template ("index.html" )
94+ def dashboard ():
95+ # Serve index.html from templates first; fall back to live network page
96+ try :
97+ return render_template ("index.html" )
98+ except Exception :
99+ pass
100+
101+ # Live network dashboard (fallback if no index.html)
102+ status_html = (
103+ f'<div class="peer self">'
104+ f'<span class="dot green">●</span>'
105+ f'<b>{ THIS_NAME } </b> (this robot — { my_ip } :{ THIS_PORT } )</div>'
106+ )
107+ with _found_lock :
108+ peers = dict (found_servers )
109+
110+ for name , url in peers .items ():
111+ try :
112+ r = requests .get (f"{ url } /ping" , timeout = 0.5 )
113+ colour = "green" if r .status_code == 200 else "orange"
114+ label = "Online" if r .status_code == 200 else f"HTTP { r .status_code } "
115+ except Exception :
116+ colour , label = "red" , "Unreachable"
117+ status_html += (
118+ f'<div class="peer">'
119+ f'<span class="dot { colour } ">●</span>'
120+ f'<b>{ name } </b> { label } — '
121+ f'<a href="{ url } ">{ url } </a></div>'
122+ )
123+
124+ return render_template_string ("""
125+ <!DOCTYPE html>
126+ <html>
127+ <head>
128+ <title>{{ name }} — Network</title>
129+ <meta charset="utf-8">
130+ <script>setTimeout(()=>location.reload(),3000);</script>
131+ <style>
132+ body{font-family:sans-serif;background:#0d0e14;color:#e6e6e1;
133+ display:flex;justify-content:center;padding-top:60px;margin:0}
134+ .card{background:#0e0f16;border:1px solid #20233a;border-radius:14px;
135+ padding:32px 40px;min-width:340px;box-shadow:0 6px 24px #0007}
136+ h1{margin:0 0 4px;color:#ff1e64;letter-spacing:2px}
137+ p.sub{color:#3c3e4b;font-size:.75em;margin:0 0 20px}
138+ hr{border-color:#20233a;margin:16px 0}
139+ .peer{padding:10px 0;border-bottom:1px solid #1a1c28;font-size:.95em}
140+ .peer:last-child{border-bottom:none}
141+ .dot{font-size:1.1em;margin-right:8px}
142+ .green{color:#1dc878}.orange{color:#ffa028}.red{color:#e24b4a}
143+ a{color:#378add;text-decoration:none}
144+ </style>
145+ </head>
146+ <body>
147+ <div class="card">
148+ <h1>KIDA NETWORK</h1>
149+ <p class="sub">Zeroconf · {{ type }} · refreshes every 3 s</p>
150+ <hr>
151+ {{ status|safe }}
152+ </div>
153+ </body>
154+ </html>
155+ """ , name = THIS_NAME , type = TYPE , status = status_html )
156+
157+
158+ @app .route ("/ping" )
159+ def ping ():
160+ return f"{ THIS_NAME } alive" , 200
23161
24162
25163@app .route ("/status" )
@@ -79,7 +217,17 @@ def face_results():
79217 return jsonify ({"results" : _face_results .copy ()})
80218
81219
220+ @app .route ("/peers" )
221+ def peers_route ():
222+ """Returns all currently discovered peers as JSON."""
223+ with _found_lock :
224+ return jsonify ({"self" : THIS_NAME , "peers" : dict (found_servers )})
225+
226+
227+ # ── Runner ─────────────────────────────────────────────────────────────────────
82228def run_flask () -> None :
83- """Call this in a daemon thread."""
229+ """Call this in a daemon thread from main.py ."""
84230 logging .getLogger ("werkzeug" ).setLevel (logging .ERROR )
85- app .run (host = "0.0.0.0" , port = 5000 , debug = False , use_reloader = False )
231+ logger .info ("Flask starting on %s:%d" , my_ip , THIS_PORT )
232+ app .run (host = "0.0.0.0" , port = THIS_PORT , debug = False ,
233+ use_reloader = False , threaded = True )
0 commit comments