1
0

router.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """
  2. FastAPI router for WiFi management endpoints.
  3. """
  4. from fastapi import APIRouter, HTTPException, Request
  5. from fastapi.responses import HTMLResponse
  6. from pydantic import BaseModel
  7. from typing import Optional
  8. import logging
  9. from . import manager
  10. logger = logging.getLogger(__name__)
  11. router = APIRouter(prefix="/api/wifi", tags=["wifi"])
  12. # Separate router for the captive portal handler (no prefix)
  13. captive_portal_router = APIRouter(tags=["wifi"])
  14. class WiFiConnectRequest(BaseModel):
  15. ssid: str
  16. password: Optional[str] = ""
  17. class WiFiForgetRequest(BaseModel):
  18. ssid: str
  19. class HotspotPasswordRequest(BaseModel):
  20. password: Optional[str] = ""
  21. @router.get("/status")
  22. async def wifi_status():
  23. """Get current WiFi mode, connection, and IP."""
  24. return manager.get_wifi_status()
  25. @router.get("/networks")
  26. async def wifi_networks():
  27. """Scan for available WiFi networks."""
  28. return manager.scan_networks()
  29. @router.post("/connect")
  30. async def wifi_connect(req: WiFiConnectRequest):
  31. """Connect to a WiFi network and reboot."""
  32. if not req.ssid:
  33. raise HTTPException(status_code=400, detail="SSID is required")
  34. result = await manager.connect_to_network(req.ssid, req.password or "")
  35. if not result["success"]:
  36. raise HTTPException(status_code=400, detail=result["message"])
  37. return result
  38. @router.post("/save")
  39. async def wifi_save(req: WiFiConnectRequest):
  40. """Save a WiFi network without connecting."""
  41. if not req.ssid:
  42. raise HTTPException(status_code=400, detail="SSID is required")
  43. result = manager.save_network(req.ssid, req.password or "")
  44. if not result["success"]:
  45. raise HTTPException(status_code=400, detail=result["message"])
  46. return result
  47. @router.post("/forget")
  48. async def wifi_forget(req: WiFiForgetRequest):
  49. """Forget a saved WiFi network."""
  50. if not req.ssid:
  51. raise HTTPException(status_code=400, detail="SSID is required")
  52. result = manager.forget_network(req.ssid)
  53. if not result["success"]:
  54. raise HTTPException(status_code=400, detail=result["message"])
  55. return result
  56. @router.get("/saved")
  57. async def wifi_saved():
  58. """Get list of saved WiFi connections."""
  59. return manager.get_saved_connections()
  60. @router.get("/hotspot/password")
  61. async def get_hotspot_password():
  62. """Get the current hotspot password."""
  63. return manager.get_hotspot_password()
  64. @router.post("/hotspot/password")
  65. async def set_hotspot_password(req: HotspotPasswordRequest):
  66. """Set or remove the hotspot password."""
  67. result = manager.set_hotspot_password(req.password or "")
  68. if not result["success"]:
  69. raise HTTPException(status_code=400, detail=result["message"])
  70. return result
  71. # --- Captive portal detection endpoints ---
  72. # These handle the well-known URLs that phones/tablets probe to detect captive portals.
  73. # In hotspot mode, DNS redirects all domains to the Pi, so these probes arrive here.
  74. # We serve a minimal HTML page that redirects to the WiFi setup page.
  75. CAPTIVE_PORTAL_HTML = """<!DOCTYPE html>
  76. <html>
  77. <head>
  78. <meta charset="utf-8">
  79. <meta name="viewport" content="width=device-width, initial-scale=1">
  80. <title>Dune Weaver</title>
  81. <style>
  82. * { margin: 0; padding: 0; box-sizing: border-box; }
  83. body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  84. display: flex; justify-content: center; align-items: center;
  85. min-height: 100vh; background: #f8fafc; color: #0f172a; padding: 1rem; }
  86. .card { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 12px;
  87. padding: 2rem; max-width: 400px; width: 100%; text-align: center;
  88. box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
  89. h1 { font-size: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }
  90. p { color: #64748b; margin-bottom: 1.5rem; font-size: 0.9rem; }
  91. .buttons { display: flex; flex-direction: column; gap: 0.75rem; }
  92. a, button { display: block; padding: 0.75rem 2rem; border-radius: 8px;
  93. text-decoration: none; font-weight: 500; text-align: center;
  94. width: 100%; font-size: 1rem; cursor: pointer; border: none; }
  95. .btn-primary { background: #2563eb; color: white; }
  96. .btn-primary:hover { background: #1d4ed8; }
  97. .btn-secondary { background: #f1f5f9; color: #0f172a; border: 1px solid #e2e8f0; }
  98. .btn-secondary:hover { background: #e2e8f0; }
  99. .url-hint { display: none; margin-top: 1rem; padding: 0.75rem; background: #f1f5f9;
  100. border-radius: 8px; font-size: 0.85rem; color: #475569; }
  101. .url-hint code { background: #e2e8f0; padding: 0.15rem 0.4rem; border-radius: 4px;
  102. font-size: 0.9rem; user-select: all; }
  103. </style>
  104. </head>
  105. <body>
  106. <div class="card">
  107. <h1>Welcome to Dune Weaver</h1>
  108. <p>What would you like to do?</p>
  109. <div class="buttons">
  110. <a href="/wifi-setup" class="btn-primary">Connect to WiFi</a>
  111. <button class="btn-secondary" onclick="openInBrowser()">Control Table</button>
  112. </div>
  113. <div class="url-hint" id="url-hint">
  114. Open your browser and go to:<br>
  115. <code>http://10.42.0.1</code>
  116. </div>
  117. </div>
  118. <script>
  119. function openInBrowser() {
  120. // Try to open in real browser (outside captive portal webview)
  121. var url = 'http://10.42.0.1';
  122. var w = window.open(url, '_blank');
  123. if (!w || w.closed) {
  124. // window.open blocked — show URL for user to open manually
  125. document.getElementById('url-hint').style.display = 'block';
  126. }
  127. }
  128. </script>
  129. </body>
  130. </html>"""
  131. @captive_portal_router.get("/hotspot-detect.html", response_class=HTMLResponse)
  132. @captive_portal_router.get("/generate_204", response_class=HTMLResponse)
  133. @captive_portal_router.get("/connecttest.txt", response_class=HTMLResponse)
  134. @captive_portal_router.get("/ncsi.txt", response_class=HTMLResponse)
  135. @captive_portal_router.get("/redirect", response_class=HTMLResponse)
  136. @captive_portal_router.get("/canonical.html", response_class=HTMLResponse)
  137. async def captive_portal_detect():
  138. """Handle captive portal detection probes.
  139. Phones and tablets check these well-known URLs after connecting to WiFi.
  140. In hotspot mode, DNS resolves all domains to the Pi, so these probes
  141. arrive at our server. Returning anything other than the expected response
  142. triggers the OS to show a captive portal browser.
  143. """
  144. return HTMLResponse(content=CAPTIVE_PORTAL_HTML, status_code=200)