207 lines
11 KiB
Python
207 lines
11 KiB
Python
"""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
|