LucasUn commited on
Commit
7e45a1e
·
verified ·
1 Parent(s): 67eb04e

Reverse glasses pairing: enter code on phone

Browse files
Files changed (1) hide show
  1. app.py +83 -8
app.py CHANGED
@@ -29,6 +29,7 @@ SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "14400"))
29
  MAX_BACKGROUND_CHARS = int(os.getenv("MAX_BACKGROUND_CHARS", "20000"))
30
  PREPARE_TIMEOUT_SECONDS = int(os.getenv("PREPARE_TIMEOUT_SECONDS", "600"))
31
  PREPARE_RETRY_SECONDS = int(os.getenv("PREPARE_RETRY_SECONDS", "15"))
 
32
 
33
  logger = logging.getLogger("asq_gateway")
34
 
@@ -51,6 +52,8 @@ class SuggestRequest(BaseModel):
51
 
52
  sessions: dict[str, dict[str, Any]] = {}
53
  interview_codes: dict[str, str] = {}
 
 
54
  preparation_tasks: set[asyncio.Task[Any]] = set()
55
 
56
 
@@ -75,6 +78,13 @@ def _cleanup_expired() -> None:
75
  session = sessions.pop(sid, None)
76
  if session and session.get("interview_code"):
77
  interview_codes.pop(session["interview_code"], None)
 
 
 
 
 
 
 
78
 
79
 
80
  def _new_interview_code() -> str:
@@ -86,6 +96,15 @@ def _new_interview_code() -> str:
86
  raise RuntimeError("Could not allocate an interview code")
87
 
88
 
 
 
 
 
 
 
 
 
 
89
  def create_token(session_id: str, role: Literal["owner", "glasses"], expires_at: int) -> str:
90
  payload = _b64encode(
91
  json.dumps(
@@ -243,7 +262,7 @@ def session_view(session: dict[str, Any], role: str) -> dict[str, Any]:
243
  return response
244
 
245
 
246
- api = FastAPI(title="ASQ Rokid Gateway", version="0.2.0")
247
  api.add_middleware(
248
  CORSMiddleware,
249
  allow_origins=["*"],
@@ -263,7 +282,7 @@ def health() -> dict[str, Any]:
263
  "ok": True,
264
  "configured": bool(ACCESS_CODE and SESSION_SECRET and HF_TOKEN),
265
  "target": TARGET_SPACE_ID,
266
- "version": "0.2.0",
267
  }
268
 
269
 
@@ -321,6 +340,62 @@ async def prepare(
321
  return session_view(session, "owner")
322
 
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  @api.post("/join")
325
  def join(request: JoinRequest) -> dict[str, Any]:
326
  _cleanup_expired()
@@ -376,20 +451,20 @@ body{margin:0;padding:24px 16px}.card{max-width:760px;margin:auto;background:#ff
376
  h1{margin:0 0 6px;font-size:1.55rem}.muted{color:#65716d;margin:0 0 24px}.step{border-top:1px solid #e4ebe7;padding-top:20px;margin-top:20px}
377
  label{display:block;font-weight:650;margin-bottom:8px}input,textarea,button{font:inherit;width:100%;border-radius:12px}input,textarea{border:1px solid #b9c9c1;padding:13px}textarea{min-height:220px;resize:vertical}
378
  button{border:0;background:#087a54;color:#fff;padding:14px;font-weight:700;margin-top:12px}button:disabled{background:#9cada5}.hidden{display:none}.status{padding:14px;border-radius:12px;background:#edf7f2;margin-top:14px;white-space:pre-wrap}
379
- .code{font-size:2.2rem;letter-spacing:.28em;font-weight:800;text-align:center;color:#087a54;margin:16px 0}.hypo{white-space:pre-wrap;line-height:1.5;background:#f7faf8;padding:14px;border-radius:12px;max-height:320px;overflow:auto}
380
  </style></head><body><main class="card">
381
- <h1>ASQ Interview Setup</h1><p class="muted">Prepare the case on this phone, then join from the Rokid glasses.</p>
382
  <section id="login"><label for="access">Researcher access code</label><input id="access" inputmode="numeric" maxlength="6" placeholder="Six digits"><button id="loginBtn">Continue</button></section>
383
- <section id="prepare" class="step hidden"><label for="background">Case and interview background</label><textarea id="background" placeholder="Enter the case facts, context, existing evidence, interview goals, and any constraints..."></textarea><button id="prepareBtn">Generate hypotheses and start interview</button></section>
384
- <section id="progress" class="step hidden"><div id="status" class="status">Starting the model…</div><div id="codeWrap" class="hidden"><p class="muted">Enter this interview code on the glasses:</p><div id="code" class="code"></div></div><div id="hypoWrap" class="hidden"><h2>Initial hypotheses</h2><div id="hypotheses" class="hypo"></div></div></section>
385
  </main><script>
386
  let token="", timer=null; const $=id=>document.getElementById(id);
387
  async function api(path, options={}){const headers={"Content-Type":"application/json",...(options.headers||{})};if(token)headers.Authorization=`Bearer ${token}`;const r=await fetch(path,{...options,headers});let data={};try{data=await r.json()}catch{}if(!r.ok)throw new Error(data.detail||`Request failed (${r.status})`);return data}
388
  function errorMessage(e){$("status").textContent=e.message;$("progress").classList.remove("hidden")}
389
  $("loginBtn").onclick=async()=>{try{const data=await api("/auth",{method:"POST",body:JSON.stringify({code:$("access").value.trim()})});token=data.token;$("login").classList.add("hidden");$("prepare").classList.remove("hidden")}catch(e){errorMessage(e)}};
390
- $("prepareBtn").onclick=async()=>{const background=$("background").value.trim();if(background.length<20){errorMessage("Please provide at least 20 characters of background.");return}$("prepareBtn").disabled=true;$("progress").classList.remove("hidden");$("status").textContent="Starting the model and generating initial hypotheses… This cold start may take several minutes.";try{const data=await api("/prepare",{method:"POST",body:JSON.stringify({background})});show(data);timer=setInterval(poll,3000)}catch(e){errorMessage(e);$("prepareBtn").disabled=false}};
391
  async function poll(){try{show(await api("/session"))}catch(e){clearInterval(timer);errorMessage(e)}}
392
- function show(data){if(data.interviewCode){$("code").textContent=data.interviewCode;$("codeWrap").classList.remove("hidden")}if(data.status==="ready"){clearInterval(timer);$("status").textContent="Ready. The glasses can now begin the interview.";$("hypotheses").textContent=data.hypotheses||"No hypotheses returned.";$("hypoWrap").classList.remove("hidden")}else if(data.status==="error"){clearInterval(timer);$("status").textContent=data.error||"Model startup failed.";$("prepareBtn").disabled=false}else{$("status").textContent="Starting the model and generating initial hypotheses… This cold start may take several minutes."}}
393
  </script></body></html>"""
394
 
395
 
 
29
  MAX_BACKGROUND_CHARS = int(os.getenv("MAX_BACKGROUND_CHARS", "20000"))
30
  PREPARE_TIMEOUT_SECONDS = int(os.getenv("PREPARE_TIMEOUT_SECONDS", "600"))
31
  PREPARE_RETRY_SECONDS = int(os.getenv("PREPARE_RETRY_SECONDS", "15"))
32
+ PAIRING_TTL_SECONDS = int(os.getenv("PAIRING_TTL_SECONDS", "600"))
33
 
34
  logger = logging.getLogger("asq_gateway")
35
 
 
52
 
53
  sessions: dict[str, dict[str, Any]] = {}
54
  interview_codes: dict[str, str] = {}
55
+ pairing_codes: dict[str, str] = {}
56
+ pairings_by_token: dict[str, dict[str, Any]] = {}
57
  preparation_tasks: set[asyncio.Task[Any]] = set()
58
 
59
 
 
78
  session = sessions.pop(sid, None)
79
  if session and session.get("interview_code"):
80
  interview_codes.pop(session["interview_code"], None)
81
+ expired_pair_tokens = [
82
+ token for token, pairing in pairings_by_token.items() if pairing["expires_at"] <= now
83
+ ]
84
+ for pair_token in expired_pair_tokens:
85
+ pairing = pairings_by_token.pop(pair_token, None)
86
+ if pairing:
87
+ pairing_codes.pop(pairing["code"], None)
88
 
89
 
90
  def _new_interview_code() -> str:
 
96
  raise RuntimeError("Could not allocate an interview code")
97
 
98
 
99
+ def _new_pairing_code() -> str:
100
+ _cleanup_expired()
101
+ for _ in range(100):
102
+ code = f"{secrets.randbelow(1_000_000):06d}"
103
+ if code != ACCESS_CODE and code not in pairing_codes and code not in interview_codes:
104
+ return code
105
+ raise RuntimeError("Could not allocate a pairing code")
106
+
107
+
108
  def create_token(session_id: str, role: Literal["owner", "glasses"], expires_at: int) -> str:
109
  payload = _b64encode(
110
  json.dumps(
 
262
  return response
263
 
264
 
265
+ api = FastAPI(title="ASQ Rokid Gateway", version="0.3.0")
266
  api.add_middleware(
267
  CORSMiddleware,
268
  allow_origins=["*"],
 
282
  "ok": True,
283
  "configured": bool(ACCESS_CODE and SESSION_SECRET and HF_TOKEN),
284
  "target": TARGET_SPACE_ID,
285
+ "version": "0.3.0",
286
  }
287
 
288
 
 
340
  return session_view(session, "owner")
341
 
342
 
343
+ @api.post("/pair")
344
+ def create_pairing() -> dict[str, Any]:
345
+ _cleanup_expired()
346
+ code = _new_pairing_code()
347
+ pair_token = secrets.token_urlsafe(24)
348
+ expires_at = int(time.time()) + PAIRING_TTL_SECONDS
349
+ pairings_by_token[pair_token] = {
350
+ "code": code,
351
+ "session_id": None,
352
+ "expires_at": expires_at,
353
+ }
354
+ pairing_codes[code] = pair_token
355
+ return {
356
+ "pairingCode": code,
357
+ "pairingToken": pair_token,
358
+ "expiresIn": PAIRING_TTL_SECONDS,
359
+ }
360
+
361
+
362
+ @api.get("/pair/status")
363
+ def pairing_status(
364
+ x_pairing_token: str | None = Header(default=None, alias="X-Pairing-Token"),
365
+ ) -> dict[str, Any]:
366
+ _cleanup_expired()
367
+ pairing = pairings_by_token.get(x_pairing_token or "")
368
+ if not pairing:
369
+ raise HTTPException(status_code=401, detail="Invalid or expired pairing")
370
+ session_id = pairing.get("session_id")
371
+ if not session_id:
372
+ return {"paired": False, "expiresAt": pairing["expires_at"]}
373
+ session = sessions.get(session_id)
374
+ if not session:
375
+ raise HTTPException(status_code=410, detail="Interview session expired")
376
+ return {
377
+ "paired": True,
378
+ "token": create_token(session_id, "glasses", session["expires_at"]),
379
+ **session_view(session, "glasses"),
380
+ }
381
+
382
+
383
+ @api.post("/pair/claim")
384
+ def claim_pairing(
385
+ request: JoinRequest,
386
+ authorization: str | None = Header(default=None),
387
+ ) -> dict[str, bool]:
388
+ session, _ = _bearer(authorization, {"owner"})
389
+ _cleanup_expired()
390
+ pair_token = pairing_codes.get(request.code)
391
+ pairing = pairings_by_token.get(pair_token or "")
392
+ if not pairing or not hmac.compare_digest(request.code, pairing.get("code") or ""):
393
+ raise HTTPException(status_code=401, detail="Incorrect or expired glasses pairing code")
394
+ session_id = next(sid for sid, value in sessions.items() if value is session)
395
+ pairing["session_id"] = session_id
396
+ return {"paired": True}
397
+
398
+
399
  @api.post("/join")
400
  def join(request: JoinRequest) -> dict[str, Any]:
401
  _cleanup_expired()
 
451
  h1{margin:0 0 6px;font-size:1.55rem}.muted{color:#65716d;margin:0 0 24px}.step{border-top:1px solid #e4ebe7;padding-top:20px;margin-top:20px}
452
  label{display:block;font-weight:650;margin-bottom:8px}input,textarea,button{font:inherit;width:100%;border-radius:12px}input,textarea{border:1px solid #b9c9c1;padding:13px}textarea{min-height:220px;resize:vertical}
453
  button{border:0;background:#087a54;color:#fff;padding:14px;font-weight:700;margin-top:12px}button:disabled{background:#9cada5}.hidden{display:none}.status{padding:14px;border-radius:12px;background:#edf7f2;margin-top:14px;white-space:pre-wrap}
454
+ .hypo{white-space:pre-wrap;line-height:1.5;background:#f7faf8;padding:14px;border-radius:12px;max-height:320px;overflow:auto}
455
  </style></head><body><main class="card">
456
+ <h1>ASQ Interview Setup</h1><p class="muted">Open ASQ on the Rokid glasses first. The glasses will display a six-digit pairing code; enter that code on this phone.</p>
457
  <section id="login"><label for="access">Researcher access code</label><input id="access" inputmode="numeric" maxlength="6" placeholder="Six digits"><button id="loginBtn">Continue</button></section>
458
+ <section id="prepare" class="step hidden"><label for="pairingCode">Code shown on the glasses</label><input id="pairingCode" inputmode="numeric" maxlength="6" placeholder="Six digits"><label for="background" style="margin-top:16px">Case and interview background</label><textarea id="background" placeholder="Enter the case facts, context, existing evidence, interview goals, and any constraints..."></textarea><button id="prepareBtn">Connect glasses and prepare interview</button></section>
459
+ <section id="progress" class="step hidden"><div id="status" class="status">Connecting the glasses…</div><div id="hypoWrap" class="hidden"><h2>Initial hypotheses</h2><div id="hypotheses" class="hypo"></div></div></section>
460
  </main><script>
461
  let token="", timer=null; const $=id=>document.getElementById(id);
462
  async function api(path, options={}){const headers={"Content-Type":"application/json",...(options.headers||{})};if(token)headers.Authorization=`Bearer ${token}`;const r=await fetch(path,{...options,headers});let data={};try{data=await r.json()}catch{}if(!r.ok)throw new Error(data.detail||`Request failed (${r.status})`);return data}
463
  function errorMessage(e){$("status").textContent=e.message;$("progress").classList.remove("hidden")}
464
  $("loginBtn").onclick=async()=>{try{const data=await api("/auth",{method:"POST",body:JSON.stringify({code:$("access").value.trim()})});token=data.token;$("login").classList.add("hidden");$("prepare").classList.remove("hidden")}catch(e){errorMessage(e)}};
465
+ $("prepareBtn").onclick=async()=>{const pairingCode=$("pairingCode").value.trim();const background=$("background").value.trim();if(!/^\d{6}$/.test(pairingCode)){errorMessage("Enter the six-digit code shown on the glasses.");return}if(background.length<20){errorMessage("Please provide at least 20 characters of background.");return}$("prepareBtn").disabled=true;$("progress").classList.remove("hidden");$("status").textContent="Connecting the glasses and generating initial hypotheses…";try{await api("/pair/claim",{method:"POST",body:JSON.stringify({code:pairingCode})});const data=await api("/prepare",{method:"POST",body:JSON.stringify({background})});show(data);timer=setInterval(poll,3000)}catch(e){errorMessage(e);$("prepareBtn").disabled=false}};
466
  async function poll(){try{show(await api("/session"))}catch(e){clearInterval(timer);errorMessage(e)}}
467
+ function show(data){if(data.status==="ready"){clearInterval(timer);$("status").textContent="Ready. The glasses are connected and can begin the interview.";$("hypotheses").textContent=data.hypotheses||"No hypotheses returned.";$("hypoWrap").classList.remove("hidden")}else if(data.status==="error"){clearInterval(timer);$("status").textContent=data.error||"Model startup failed.";$("prepareBtn").disabled=false}else{$("status").textContent="Glasses connected. Starting the model and generating initial hypotheses…"}}
468
  </script></body></html>"""
469
 
470