# -*- 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 d’usage : -------------------------- 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) -------------------------------------------------------------------

Serveur Micropython 100% autonome


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