storeos / storeos_robot /display.py
Cybire's picture
Add StoreOS runtime secret config
32d2efa
Raw
History Blame Contribute Delete
8.79 kB
"""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">&#8358;${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">&#127908;</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">&#129300;</div><div>Thinking...</div></div>';
st.textContent='Processing...';
st.className='status pulse';
}else{
d.innerHTML='<div class="idle"><div class="icon">&#128722;</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}")