all
This commit is contained in:
206
bridge_core/pairing_ui.py
Normal file
206
bridge_core/pairing_ui.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Pairing UI (port 8088): 6-digit code entry, submits to cloud via WebSocket; when claimed shows connection info."""
|
||||
import asyncio
|
||||
import html
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
# IPv4 with optional :port (same as main app)
|
||||
HOST_IP_PATTERN = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?::\d+)?$")
|
||||
|
||||
|
||||
def _connected_html(tenant_name: str, is_online: bool) -> str:
|
||||
"""HTML when bridge is claimed: status (Online/Offline) + tenant; persists on refresh until unpaired."""
|
||||
safe_name = html.escape(tenant_name.strip() or "Standort")
|
||||
if is_online:
|
||||
badge_class = "badge badge-online"
|
||||
badge_label = "Online"
|
||||
badge_dot = '<span class="badge-dot" aria-hidden="true"></span>'
|
||||
else:
|
||||
badge_class = "badge badge-offline"
|
||||
badge_label = "Offline"
|
||||
badge_dot = '<span class="badge-dot badge-dot-offline" aria-hidden="true"></span>'
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OrderPy Bridge – Status</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b; padding: 1rem; }}
|
||||
.card {{ background: white; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); padding: 2rem; max-width: 24rem; width: 100%; text-align: center; }}
|
||||
.badge {{ display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; margin-bottom: 1rem; }}
|
||||
.badge-online {{ background: #dcfce7; color: #166534; }}
|
||||
.badge-offline {{ background: #f1f5f9; color: #64748b; }}
|
||||
.badge-dot {{ width: 0.5rem; height: 0.5rem; border-radius: 50%; background: #22c55e; }}
|
||||
.badge-dot-offline {{ background: #94a3b8; }}
|
||||
h1 {{ font-size: 1.25rem; font-weight: 700; margin: 0 0 1rem 0; }}
|
||||
.tenant {{ font-size: 1rem; color: #64748b; }}
|
||||
.tenant strong {{ color: #1e293b; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="{badge_class}" role="status">{badge_dot} {badge_label}</div>
|
||||
<h1>Bridge-Status</h1>
|
||||
<p class="tenant">Verbunden mit <strong>{safe_name}</strong></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
PAIRING_HTML = """<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OrderPy Bridge – Verbinden</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b; padding: 1rem; }
|
||||
.card { background: white; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); padding: 2rem; max-width: 24rem; width: 100%; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; margin: 0 0 0.5rem 0; }
|
||||
p { font-size: 0.875rem; color: #64748b; margin: 0 0 1.5rem 0; line-height: 1.5; }
|
||||
.digits { display: flex; gap: 0.5rem; justify-content: center; margin-bottom: 1.5rem; }
|
||||
.digits input { width: 2.5rem; height: 3rem; text-align: center; font-size: 1.5rem; font-weight: 600; border: 2px solid #e2e8f0; border-radius: 0.5rem; }
|
||||
.digits input:focus { outline: none; border-color: #f97316; }
|
||||
.btn { width: 100%; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; color: white; background: #ea580c; border: none; border-radius: 0.5rem; cursor: pointer; }
|
||||
.btn:hover { background: #c2410c; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.msg { font-size: 0.875rem; margin-top: 1rem; padding: 0.75rem; border-radius: 0.5rem; display: none; }
|
||||
.msg.success { background: #dcfce7; color: #166534; display: block; }
|
||||
.msg.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.success-card { display: none; text-align: center; padding: 2rem 0; }
|
||||
.success-card.visible { display: block; }
|
||||
.success-card .icon { width: 4rem; height: 4rem; margin: 0 auto 1rem; border-radius: 50%; background: #dcfce7; color: #166534; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; }
|
||||
.success-card h2 { font-size: 1.5rem; font-weight: 700; color: #166534; margin: 0 0 0.5rem 0; }
|
||||
.success-card p { color: #15803d; margin: 0; }
|
||||
.pairing-form { display: block; }
|
||||
.pairing-form.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div id="successCard" class="success-card" role="status" aria-live="polite">
|
||||
<div class="icon">✓</div>
|
||||
<h2>Verbunden!</h2>
|
||||
<p>Das Gerät wurde erfolgreich mit dem Standort verbunden.</p>
|
||||
<p style="margin-top: 0.5rem; font-size: 0.875rem; color: #64748b;">Sie können dieses Fenster schließen.</p>
|
||||
</div>
|
||||
<div id="pairingForm" class="pairing-form">
|
||||
<h1>Bridge mit OrderPy verbinden</h1>
|
||||
<p>Geben Sie den 6-stelligen Code ein, der in der OrderPy-App angezeigt wird.</p>
|
||||
<form id="pairForm">
|
||||
<div class="digits" role="group" aria-label="6-stelliger Code">
|
||||
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 1" data-idx="0">
|
||||
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 2" data-idx="1">
|
||||
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 3" data-idx="2">
|
||||
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 4" data-idx="3">
|
||||
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 5" data-idx="4">
|
||||
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 6" data-idx="5">
|
||||
</div>
|
||||
<button type="submit" class="btn" id="submitBtn">Verbinden</button>
|
||||
</form>
|
||||
<div id="msg" class="msg" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const inputs = document.querySelectorAll('.digits input');
|
||||
const form = document.getElementById('pairForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const msgEl = document.getElementById('msg');
|
||||
const successCard = document.getElementById('successCard');
|
||||
const pairingForm = document.getElementById('pairingForm');
|
||||
function getCode() { return Array.from(inputs).map(i => i.value).join('').replace(/\\D/g, '').slice(0, 6); }
|
||||
function setMsg(text, isError) { msgEl.textContent = text; msgEl.className = 'msg ' + (isError ? 'error' : 'success'); }
|
||||
function showSuccess() { successCard.classList.add('visible'); pairingForm.classList.add('hidden'); }
|
||||
inputs.forEach((inp, i) => {
|
||||
inp.addEventListener('input', () => { if (inp.value.length >= 1) inputs[i + 1]?.focus(); });
|
||||
inp.addEventListener('keydown', (e) => { if (e.key === 'Backspace' && !inp.value && i > 0) inputs[i - 1].focus(); });
|
||||
inp.addEventListener('paste', (e) => { e.preventDefault(); const p = (e.clipboardData?.getData('text') || '').replace(/\\D/g, '').slice(0, 6); p.split('').forEach((c, j) => { if (inputs[j]) inputs[j].value = c; }); inputs[Math.min(p.length, 5)].focus(); });
|
||||
});
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const code = getCode();
|
||||
if (code.length !== 6) { setMsg('Bitte 6 Ziffern eingeben.', true); return; }
|
||||
submitBtn.disabled = true;
|
||||
setMsg('Verbindung wird hergestellt…', false);
|
||||
try {
|
||||
const r = await fetch('/api/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
|
||||
const data = await r.json();
|
||||
if (data.ok) { form.reset(); showSuccess(); }
|
||||
else {
|
||||
var msg = data.reason === 'code_expired_or_used' ? 'Code abgelaufen oder bereits verwendet.' : data.reason === 'already_connected' ? 'Die Bridge ist bereits mit einem Standort verbunden.' : (data.reason === 'connection_lost' || data.reason === 'send_failed') ? 'Verbindung zum Server unterbrochen. Bitte in wenigen Sekunden erneut versuchen.' : (data.reason || 'Fehler.');
|
||||
setMsg(msg, true);
|
||||
}
|
||||
} catch (err) { setMsg('Netzwerkfehler. Bitte erneut versuchen.', true); }
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class PairRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
def create_pairing_app(
|
||||
pairing_queue: asyncio.Queue,
|
||||
get_connection_info: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create FastAPI app for pairing UI. pairing_queue sends code to cloud client.
|
||||
If get_connection_info is set and returns claimed=True, index shows connection info (online + tenant name) instead of the code form."""
|
||||
app = FastAPI(title="OrderPy Bridge Pairing", version="1.0")
|
||||
|
||||
@app.middleware("http")
|
||||
async def host_header_check(request: Request, call_next):
|
||||
host = request.headers.get("host", "")
|
||||
if not HOST_IP_PATTERN.match(host):
|
||||
return Response("Forbidden: Direct IP access only", status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> Response:
|
||||
# Always reflect current state; prevent cached pairing form when already connected
|
||||
no_cache = {"Cache-Control": "no-store, no-cache, must-revalidate"}
|
||||
if get_connection_info:
|
||||
info = get_connection_info()
|
||||
if info.get("claimed"):
|
||||
html_content = _connected_html(
|
||||
info.get("tenant_name") or "",
|
||||
info.get("is_online", False),
|
||||
)
|
||||
return HTMLResponse(html_content, headers=no_cache)
|
||||
return HTMLResponse(PAIRING_HTML, headers=no_cache)
|
||||
|
||||
@app.post("/api/pair")
|
||||
async def pair(body: PairRequest) -> Response:
|
||||
if get_connection_info:
|
||||
info = get_connection_info()
|
||||
if info.get("claimed"):
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"ok": False, "reason": "already_connected"},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
code = "".join(c for c in (body.code or "").strip() if c.isdigit())[:6]
|
||||
if len(code) != 6 or not code.isdigit():
|
||||
return JSONResponse({"ok": False, "reason": "invalid_code"})
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
try:
|
||||
pairing_queue.put_nowait((code, future))
|
||||
result = await asyncio.wait_for(future, timeout=15.0)
|
||||
return JSONResponse({"ok": result.get("ok", False), "reason": result.get("reason")})
|
||||
except asyncio.TimeoutError:
|
||||
return JSONResponse({"ok": False, "reason": "timeout"})
|
||||
except asyncio.QueueFull:
|
||||
return JSONResponse({"ok": False, "reason": "busy"})
|
||||
|
||||
return app
|
||||
Reference in New Issue
Block a user