| """Product display server — shows current product on a tablet next to the robot. |
| |
| Serves a minimal page at http://robot-ip:7860 that auto-updates |
| when the robot recommends a product. Just open it on a tablet. |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| from http.server import HTTPServer, BaseHTTPRequestHandler |
| import threading |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _CONFIG_KEYS = { |
| "ZG_API_KEY", |
| "ZG_BASE_URL", |
| "ZG_MODEL", |
| "ZG_WHISPER_MODEL", |
| "ZG_CONSUMER_EMAIL", |
| "STORE_NAME", |
| "KOKORO_VOICE", |
| "CHAIN_RPC", |
| "CHAIN_PRIVATE_KEY", |
| "CHAIN_ID", |
| "TG_BOT_TOKEN", |
| "TG_CHAT_ID", |
| } |
|
|
|
|
| def _config_status() -> dict: |
| from . import config |
|
|
| return { |
| "env_file": str(config.env_file_path()), |
| "zg_api_key": bool(config.ZG_API_KEY), |
| "zg_consumer_email": bool(config.ZG_CONSUMER_EMAIL), |
| "chain_private_key": bool(config.CHAIN_PRIVATE_KEY), |
| } |
|
|
|
|
| def _save_config(payload: dict) -> dict: |
| from . import config |
|
|
| values = {} |
| for key in _CONFIG_KEYS: |
| value = payload.get(key) |
| if value is None: |
| continue |
| value = str(value).strip() |
| if value: |
| values[key] = value |
| if not values: |
| raise ValueError("No valid StoreOS config keys provided") |
|
|
| path = config.write_env_file(values) |
| config.apply_runtime_config(values) |
| return { |
| "saved": sorted(values.keys()), |
| "env_file": str(path), |
| "status": _config_status(), |
| } |
|
|
| _current = { |
| "mode": "idle", |
| "product": None, |
| "message": "Waiting for customers...", |
| "store_name": "The Good Store", |
| } |
|
|
| _listeners: list[asyncio.Queue] = [] |
|
|
|
|
| def set_store_name(name: str): |
| _current["store_name"] = name |
|
|
|
|
| def show_product(product: dict, message: str = ""): |
| _current["mode"] = "product" |
| _current["product"] = product |
| _current["message"] = message |
| _notify() |
|
|
|
|
| def show_idle(message: str = "Waiting for customers..."): |
| _current["mode"] = "idle" |
| _current["product"] = None |
| _current["message"] = message |
| _notify() |
|
|
|
|
| def show_listening(): |
| _current["mode"] = "listening" |
| _current["message"] = "Listening..." |
| _notify() |
|
|
|
|
| def show_thinking(): |
| _current["mode"] = "thinking" |
| _current["message"] = "Thinking..." |
| _notify() |
|
|
|
|
| def _notify(): |
| for q in _listeners: |
| try: |
| q.put_nowait(True) |
| except asyncio.QueueFull: |
| pass |
|
|
|
|
| PAGE = """<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>StoreOS Display</title> |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); |
| *{margin:0;padding:0;box-sizing:border-box} |
| body{font-family:'Inter',sans-serif;background:#F5F3F0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px} |
| .store-name{font-size:14px;font-weight:700;color:#9200E1;letter-spacing:2px;text-transform:uppercase;margin-bottom:24px} |
| .card{background:#fff;border-radius:20px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.1);max-width:480px;width:100%;transition:all 0.5s cubic-bezier(0.22,1,0.36,1)} |
| .card img{width:100%;height:320px;object-fit:cover;display:block} |
| .card .body{padding:28px} |
| .card .name{font-size:24px;font-weight:800;letter-spacing:-0.5px;margin-bottom:4px} |
| .card .origin{font-size:13px;color:#888;margin-bottom:12px} |
| .card .desc{font-size:15px;color:#444;line-height:1.6;margin-bottom:16px} |
| .card .price{font-size:28px;font-weight:800;color:#9200E1} |
| .card .stock{font-size:13px;color:#10B981;font-weight:600;margin-left:12px} |
| .card .footer{display:flex;align-items:center} |
| .idle{text-align:center;color:#999;font-size:18px} |
| .idle .icon{font-size:64px;margin-bottom:16px;animation:float 3s ease-in-out infinite} |
| .status{font-size:14px;color:#9200E1;font-weight:600;margin-top:16px;min-height:24px;text-align:center} |
| .pulse{animation:pulse 1.5s ease-in-out infinite} |
| .msg{background:#fff;border-radius:16px;padding:16px 24px;margin-top:16px;max-width:480px;width:100%;font-size:15px;color:#333;line-height:1.5;box-shadow:0 2px 8px rgba(0,0,0,0.06)} |
| @keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}} |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}} |
| </style> |
| </head> |
| <body> |
| <div class="store-name" id="store-name">THE GOOD STORE</div> |
| <div id="display"></div> |
| <div class="status" id="status"></div> |
| <div class="msg" id="msg" style="display:none"></div> |
| <script> |
| async function poll(){ |
| try{ |
| const r=await fetch('/api/state'); |
| const s=await r.json(); |
| document.getElementById('store-name').textContent=s.store_name.toUpperCase(); |
| const d=document.getElementById('display'); |
| const st=document.getElementById('status'); |
| const mg=document.getElementById('msg'); |
| if(s.mode==='product'&&s.product){ |
| const p=s.product; |
| d.innerHTML=`<div class="card"> |
| ${p.image?`<img src="${p.image}">`:''} |
| <div class="body"> |
| <div class="name">${p.name}</div> |
| <div class="origin">${p.origin||''}</div> |
| <div class="desc">${p.desc}</div> |
| <div class="footer"> |
| <div class="price">₦${p.price.toLocaleString()}</div> |
| <div class="stock">${p.stock>0?p.stock+' in stock':'Sold out'}</div> |
| </div> |
| </div> |
| </div>`; |
| st.textContent=''; |
| st.className='status'; |
| }else if(s.mode==='listening'){ |
| d.innerHTML='<div class="idle"><div class="icon">🎤</div><div>Listening...</div></div>'; |
| st.textContent='Speak to the robot'; |
| st.className='status pulse'; |
| }else if(s.mode==='thinking'){ |
| d.innerHTML='<div class="idle"><div class="icon">🤔</div><div>Thinking...</div></div>'; |
| st.textContent='Processing...'; |
| st.className='status pulse'; |
| }else{ |
| d.innerHTML='<div class="idle"><div class="icon">🛒</div><div>'+s.message+'</div></div>'; |
| st.textContent='Talk to the robot to browse products'; |
| st.className='status'; |
| } |
| if(s.message&&s.mode==='product'){ |
| mg.style.display='block'; |
| mg.textContent=s.message; |
| }else{ |
| mg.style.display='none'; |
| } |
| }catch(e){} |
| setTimeout(poll,1000); |
| } |
| poll(); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| class DisplayHandler(BaseHTTPRequestHandler): |
| def do_GET(self): |
| if self.path == "/api/state": |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| self.wfile.write(json.dumps(_current).encode()) |
| elif self.path == "/api/config/status": |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| self.wfile.write(json.dumps(_config_status()).encode()) |
| elif self.path == "/api/log": |
| self.send_response(200) |
| self.send_header("Content-Type", "text/plain") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| try: |
| with open("/tmp/storeos.log", "r") as f: |
| self.wfile.write(f.read().encode()) |
| except: |
| self.wfile.write(b"No log file yet") |
| else: |
| self.send_response(200) |
| self.send_header("Content-Type", "text/html") |
| self.end_headers() |
| self.wfile.write(PAGE.encode()) |
|
|
| def do_POST(self): |
| if self.path != "/api/config": |
| self.send_response(404) |
| self.end_headers() |
| return |
|
|
| try: |
| length = int(self.headers.get("Content-Length", "0")) |
| payload = json.loads(self.rfile.read(length).decode() or "{}") |
| result = _save_config(payload) |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| self.wfile.write(json.dumps(result).encode()) |
| except Exception as e: |
| self.send_response(400) |
| self.send_header("Content-Type", "application/json") |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.end_headers() |
| self.wfile.write(json.dumps({"error": str(e)}).encode()) |
|
|
| def log_message(self, *args): |
| pass |
|
|
|
|
| def start_server(port: int = 7860): |
| server = HTTPServer(("0.0.0.0", port), DisplayHandler) |
| t = threading.Thread(target=server.serve_forever, daemon=True) |
| t.start() |
| logger.info(f"Display server at http://0.0.0.0:{port}") |
|
|