Files
orderpy-bridge/bridge_core/pairing_ui.py
2026-03-14 13:01:24 +01:00

207 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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