Files
2026-03-11 23:03:02 +01:00

412 lines
12 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.
# -*- coding: utf-8 -*-
# uaio_http_server.py
# GNU GENERAL PUBLIC LICENSE: (c) DD miraceti.net
import uasyncio as asyncio # @UnresolvedImport
import hashlib # @UnresolvedImport
import ubinascii # @UnresolvedImport
import os
'''
AsyncHttpServer est un serveur HTTP asynchrone 100 % MicroPython.
Autonome et sans dépendances externes, conçu pour ESP32.
Il gère le parsing HTTP, le routage GET/POST, le service de fichiers statiques en streaming et les WebSockets.
Respecte les contraintes mémoire et appuie uniquement sur uasyncio et les bibliothèques natives (hashlib, ubinascii, ujson).
'''
MIME = {
'.txt': 'text/plain',
'.html': 'text/html; charset=utf-8',
'.css': 'text/css',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.json': 'application/json',
'.js': 'application/javascript',
}
EPOCH_2000_UNIX_TIME = 946684800
def toUnixTimestamp(totalseconds):
return totalseconds + EPOCH_2000_UNIX_TIME
def to2000Timestamp(totalseconds):
return totalseconds - EPOCH_2000_UNIX_TIME
def base64_encode(data):
return ubinascii.b2a_base64(data).strip()
def base64_decode(data):
return ubinascii.a2b_base64(data)
class AsyncHttpServer:
'''
--------------------------
Exemple minimal dusage :
--------------------------
server = AsyncHttpServer()
@server.get("/hello")
async def hello(server, reader, writer):
await server.send_response(writer, b"Hello GET")
@server.post("/echo")
async def echo(server, reader, writer, body):
await server.send_response(writer, body, mime="application/json")
async def on_ws_message(server, msg, writer):
await server.ws_send_text(writer, msg)
server.ws_handler = on_ws_message
server.run()
-------------------------------------------------------------------
Client HTML + JS (pour tester WebSocket + API + fichiers statiques)
-------------------------------------------------------------------
<!DOCTYPE html>
<html>
<body>
<h1>Serveur Micropython 100% autonome</h1>
<button id="g">Test GET</button>
<button id="p">Test POST</button>
<button id="w">WS Send</button>
<pre id="log"></pre>
<script>
const out = t => document.getElementById("log").textContent += t+"\n";
// GET
document.getElementById("g").onclick = async () => {
let r = await fetch("/hello");
out(await r.text());
};
// POST
document.getElementById("p").onclick = async () => {
let r = await fetch("/echo", {
method: "POST",
body: JSON.stringify({x: 1})
});
out(await r.text());
};
// WS
let ws = new WebSocket(`ws://${location.host}/ws`);
ws.onmessage = e => out("WS → " + e.data);
document.getElementById("w").onclick = () => ws.send("ping");
</script>
</body>
</html>
'''
def __init__(self, host="0.0.0.0", port=80, www="www"):
self.host = host
self.port = port
self.www = www
self.routes_get = {}
self.routes_post = {}
self.ws_routes = {}
self.ws_clients = []
self._server = None
# -------------------------
# Routing API
# -------------------------
def get(self, path):
def decorator(func):
self.routes_get[path] = func
return func
return decorator
def post(self, path):
def decorator(func):
self.routes_post[path] = func
return func
return decorator
def websocket(self, path):
def decorator(func):
self.ws_routes[path] = func
return func
return decorator
# -------------------------
# Serveur principal
# -------------------------
async def start(self):
self._server = await asyncio.start_server(self.handle_client, self.host, self.port)
print(f"HTTP Serveur {self.host}:{self.port} vient de démarrer!...")
while True:
await asyncio.sleep(1)
# -------------------------
# Lecture HTTP minimaliste
# -------------------------
def ensure_bytes(self, body):
if body is None:
return b""
if isinstance(body, bytes):
return body
if isinstance(body, str):
return body.encode("utf-8")
# Si c'est un dict, liste, JSON…
return str(body).encode("utf-8")
def get_mime(self, path):
for ext, mt in MIME.items():
if path.lower().endswith(ext):
return mt
return 'application/octet-stream'
def file_exists(self, path):
try:
os.stat(path)
return True
except:
return False
async def read_headers(self, reader):
headers = {}
while True:
line = await reader.readline()
if not line:
break
line = line.decode().strip()
if line == "":
break # fin des headers
if ":" in line:
k, v = line.split(":", 1)
headers[k.lower()] = v.strip()
return headers
async def read_body(self, reader, headers):
length = int(headers.get("content-length", "0"))
if length > 0:
return await reader.read(length)
return b""
# -------------------------
# Réponse HTTP standard
# -------------------------
async def send_response(self, writer, body=b'', status="200 OK", content_type="text/plain"):
size = len(body)
await writer.awrite(
"HTTP/1.1 " + status + "\r\n"
"Content-Type: " + content_type + "\r\n"
"Content-Length: " + str(size) + "\r\n"
"Connection: close\r\n"
"\r\n"
)
await writer.drain()
if body:
body = self.ensure_bytes(body)
await writer.awrite(body)
await writer.drain()
# -------------------------
# Client handler dispatcher
# -------------------------
async def handle_client(self, reader, writer):
try:
first = await reader.readline()
if not first:
await writer.aclose()
return
request_line = first.decode().strip()
method, path, proto = request_line.split() # @UnusedVariable
headers = await self.read_headers(reader)
# POST ?
if method == "POST" and path in self.routes_post:
body = await self.read_body(reader, headers)
await self.routes_post[path](self, writer, headers, body)
# GET API ?
elif method == "GET" and path in self.routes_get:
await self.routes_get[path](self, writer, headers)
# websocket ?
elif headers.get('upgrade', '').lower() == 'websocket' and path in self.ws_routes:
await self.handle_websocket(reader, writer, headers, path)
else:
await self.serve_static(writer, path)
except Exception as e:
print("Server error:", e)
await self.send_response(writer, "500 Internal Server Error")
finally:
try:
await writer.aclose()
except:
pass
# ---------------------------
# Fichiers statiques resolver
# ---------------------------
async def serve_static(self, writer, path):
if path == "/":
path = "/index.html"
full = self.www + path
print("serve static =>", full)
try:
if not self.file_exists(full):
raise Exception("File Not found")
size = os.stat(full)[6]
mime = self.get_mime(full)
header = (
"HTTP/1.1 200 OK\r\n"
"Content-Type: " + mime + "\r\n"
"Content-Length: " + str(size) + "\r\n"
#"Cache-Control: no-cache, no-store, must-revalidate\r\n"
#"Pragma: no-cache\r\n"
#"Expires: 0\r\n"
"Connection: close\r\n"
"\r\n"
)
await writer.awrite(header)
await writer.drain()
with open(full, "rb") as f:
while True:
chunk = f.read(1024)
if not chunk:
break
await writer.awrite(chunk)
await writer.drain()
except Exception as e:
print("serve_static error =>", full, e)
await self.send_response(writer, "404 File Not found")
# --- compute Sec-WebSocket-Accept ---
def ws_accept_key(self, key):
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".encode()
key_bytes = key.encode()
sha1 = hashlib.sha1(key_bytes + GUID).digest()
ws_key = ubinascii.b2a_base64(sha1).strip().decode()
return ws_key
# --- send one WebSocket TEXT frame ---
async def ws_send_text(self, writer, text):
payload = text.encode()
await writer.awrite(b"\x81") # FIN + opcode TEXT
length = len(payload)
if length < 126:
await writer.awrite(bytes([length]))
else:
await writer.awrite(b"\x7E" + bytes([(length >> 8) & 0xFF, length & 0xFF]))
await writer.awrite(payload)
await writer.drain()
# --- receive a single frame ---
async def ws_recv(self, reader):
header = await reader.readexactly(2)
if not header:
return None
opcode = header[0] & 0x0F
masked = header[1] & 0x80
length = header[1] & 0x7F
if opcode == 0x8:
return None
if length == 126:
ext = await reader.readexactly(2)
length = (ext[0] << 8) | ext[1]
elif length == 127:
return None
mask = await reader.readexactly(4) if masked else None
data = await reader.readexactly(length)
if masked:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return data.decode()
# -------------------------
# Websocket
# -------------------------
async def handle_websocket(self, reader, writer, headers, path):
try:
ws_key = headers["sec-websocket-key"]
if not ws_key:
await writer.awrite("HTTP/1.1 400 Bad Request\r\n\r\n")
await writer.drain()
await writer.aclose()
return
accept = self.ws_accept_key(ws_key)
response = (
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: " + accept +"\r\n"
"\r\n"
)
await writer.awrite(response)
await writer.drain()
#print(response)
# Register this client
self.ws_clients.append(writer)
# Main recv loop
while True:
try:
msg = await self.ws_recv(reader)
if msg is None:
break
callback = self.ws_routes[path]
if callback:
# APPEL DU CALLBACK UTILISATEUR (si défini)
# ws_routes[path] doit être une coroutine ou fonction acceptant (server, msg, writer)
asyncio.create_task(callback(self, msg, writer))
else:
await self.ws_send_text(writer, msg)
except:
pass
except Exception:
pass
finally:
# retirer le client s'il est présent
try:
if writer in self.ws_clients:
self.ws_clients.remove(writer)
except:
pass
try:
await writer.aclose()
except:
pass