#!/usr/bin/env python3 """ Minimal HTTP ↔ CoAP proxy using aiohttp + a handcrafted UDP CoAP client. Expose REST-ish endpoints that translate browser-friendly HTTP requests to CoAP messages for the ESP32 server. Install dependencies: python3 -m pip install aiohttp Run (defaults shown): HTTP_PORT=8080 COAP_HOST=192.168.4.1 COAP_PORT=5683 python3 tools/http_coap_proxy.py Available endpoints: - GET /api/coap -> query params: host, port, method, path - POST /api/coap -> JSON body: {method, path, payload?, host?, port?} - GET /api/discover -> queries /.well-known/core - GET /healthz -> simple liveness probe """ import asyncio import json import os import random import signal import socket import struct import time from typing import Dict, List, Optional, Tuple from aiohttp import web HTTP_HOST = os.environ.get("HTTP_HOST", "0.0.0.0") HTTP_PORT = int(os.environ.get("HTTP_PORT", "8080")) DEFAULT_COAP_HOST = os.environ.get("COAP_HOST", "10.41.35.27") DEFAULT_COAP_PORT = int(os.environ.get("COAP_PORT", "5683")) REQUEST_TIMEOUT = float(os.environ.get("PROXY_TIMEOUT", "9.0")) COAP_VERSION = 1 METHOD_CODES: Dict[str, int] = { "GET": 0x01, "POST": 0x02, "PUT": 0x03, "DELETE": 0x04, "FETCH": 0x05, "PATCH": 0x06, "IPATCH": 0x07, } OPTION_NAMES: Dict[int, str] = { 3: "Uri-Host", 6: "Observe", 7: "Uri-Port", 8: "Location-Path", 11: "Uri-Path", 12: "Content-Format", 14: "Max-Age", 15: "Uri-Query", 17: "Accept", 20: "Location-Query", } def _resolve_target(request: web.Request) -> Dict[str, str]: host = (request.query.get("host") or request.match_info.get("host") or DEFAULT_COAP_HOST).strip() port_raw = request.query.get("port") or request.match_info.get("port") or str(DEFAULT_COAP_PORT) if request.can_read_body and request.content_type == "application/json": try: body = request["json_body"] except KeyError: body = {} if isinstance(body, dict): host = body.get("host", host).strip() port_raw = str(body.get("port", port_raw)) if not host: raise web.HTTPBadRequest(text="Missing CoAP host") try: port = int(port_raw) except ValueError: raise web.HTTPBadRequest(text="Invalid CoAP port") if port <= 0 or port > 65535: raise web.HTTPBadRequest(text="Invalid CoAP port range") return {"host": host, "port": port} def _normalize_path(path: str) -> str: if not path: return "/" return path if path.startswith("/") else "/" + path async def _coap_request(method: str, host: str, port: int, path: str, payload: Optional[str]) -> Dict[str, str]: loop = asyncio.get_running_loop() return await loop.run_in_executor( None, lambda: _coap_request_sync(method, host, port, path, payload), ) def _encode_extended(value: int) -> Tuple[int, bytes]: if value < 13: return value, b"" if value < 269: return 13, bytes([value - 13]) if value < 65805: return 14, struct.pack("!H", value - 269) raise ValueError("Option delta/length too large") def _decode_extended(nibble: int, data: bytes, index: int) -> Tuple[int, int]: if nibble < 13: return nibble, index if nibble == 13: return 13 + data[index], index + 1 if nibble == 14: return 269 + struct.unpack("!H", data[index:index + 2])[0], index + 2 raise ValueError("Unsupported extended field (value 15)") def _build_options(path: str, payload: Optional[str], method_code: int) -> bytes: options: List[Tuple[int, bytes]] = [] clean_path, _, query = path.partition('?') segments = [seg for seg in clean_path.split('/') if seg] for segment in segments: options.append((11, segment.encode("utf-8"))) if query: for item in query.split('&'): if item: options.append((15, item.encode("utf-8"))) if payload and method_code in (0x02, 0x03, 0x05, 0x06, 0x07): options.append((12, b"\x00")) # Content-Format: text/plain options.sort(key=lambda item: item[0]) encoded = bytearray() last_number = 0 for number, value in options: delta = number - last_number last_number = number delta_encoded, delta_bytes = _encode_extended(delta) length_encoded, length_bytes = _encode_extended(len(value)) encoded.append((delta_encoded << 4) | length_encoded) encoded.extend(delta_bytes) encoded.extend(length_bytes) encoded.extend(value) return bytes(encoded) def _parse_options_and_payload(data: bytes, start: int) -> Tuple[List[Dict[str, str]], str]: options: List[Dict[str, str]] = [] current_opt = 0 i = start length = len(data) while i < length: if data[i] == 0xFF: payload_bytes = data[i + 1 :] return options, payload_bytes.decode("utf-8", errors="replace") byte = data[i] i += 1 delta_raw = (byte >> 4) & 0x0F length_raw = byte & 0x0F delta, i = _decode_extended(delta_raw, data, i) opt_length, i = _decode_extended(length_raw, data, i) current_opt += delta value = data[i : i + opt_length] i += opt_length entry: Dict[str, str] = {"number": current_opt} name = OPTION_NAMES.get(current_opt) if name: entry["name"] = name try: entry["value"] = value.decode("utf-8") except UnicodeDecodeError: entry["value_hex"] = value.hex() options.append(entry) return options, "" def _format_coap_code(code: int) -> str: cls = (code >> 5) & 0x07 detail = code & 0x1F return f"{cls}.{detail:02d}" def _coap_request_sync(method: str, host: str, port: int, path: str, payload: Optional[str]) -> Dict[str, str]: method_upper = method.upper() method_code = METHOD_CODES.get(method_upper) if method_code is None: raise web.HTTPBadRequest(text=f"Unsupported CoAP method {method}") message_id = random.randint(0, 0xFFFF) token = os.urandom(4) options = _build_options(path, payload, method_code) payload_bytes = payload.encode("utf-8") if payload and method_code != 0x01 else b"" token_length = len(token) if token_length > 8: raise web.HTTPBadRequest(text="Token length must be <= 8 bytes") header = bytearray(4) header[0] = (COAP_VERSION << 6) | (0 << 4) | token_length # Confirmable header[1] = method_code header[2:4] = struct.pack("!H", message_id) packet = bytes(header) + token + options if payload_bytes: packet += b"\xFF" + payload_bytes uri = f"coap://{host}:{port}{_normalize_path(path)}" deadline = time.monotonic() + REQUEST_TIMEOUT with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.settimeout(REQUEST_TIMEOUT) sock.sendto(packet, (host, port)) print(f"[proxy] -> {method_upper} {uri} token={token.hex()} mid={message_id}", flush=True) while True: remaining = deadline - time.monotonic() if remaining <= 0: raise web.HTTPGatewayTimeout(text="Timed out waiting for CoAP response") sock.settimeout(remaining) try: data, remote = sock.recvfrom(2048) except socket.timeout as exc: raise web.HTTPGatewayTimeout(text="Timed out waiting for CoAP response") from exc if len(data) < 4: print("[proxy] !! received too-short datagram", flush=True) continue ver = (data[0] >> 6) & 0x03 msg_type = (data[0] >> 4) & 0x03 token_len = data[0] & 0x0F resp_code = data[1] resp_mid = struct.unpack("!H", data[2:4])[0] resp_token = data[4:4 + token_len] debug_info = ( f"[proxy] ?? from {remote} raw_len={len(data)} ver={ver} type={msg_type} " f"token_len={token_len} code_byte={resp_code} mid={resp_mid} token={resp_token.hex()}" ) if ver != COAP_VERSION: print(debug_info + " (ignored: wrong version)", flush=True) continue if resp_mid != message_id: print(debug_info + f" (ignored: mid mismatch != {message_id})", flush=True) continue if resp_token != token[:token_len]: print(debug_info + f" (ignored: token mismatch expected {token.hex()[:token_len*2]})", flush=True) continue payload_index = 4 + token_len # Some lightweight CoAP stacks (eg. Arduino coap-simple) return Decimal codes (e.g. 205) # instead of properly encoded 5-bit class/detail. Fix that up here. if resp_code >= 200: resp_code = ((resp_code // 100) << 5) | (resp_code % 100) options_list, payload_text = _parse_options_and_payload(data, payload_index) code_string = _format_coap_code(resp_code) print(f"[proxy] <- {code_string} type={msg_type} token={resp_token.hex()} mid={resp_mid}", flush=True) # Acknowledge separate CON responses to stop retransmissions if msg_type == 0: # CON ack = bytearray(4) ack[0] = (COAP_VERSION << 6) | (2 << 4) # ACK ack[1] = 0 ack[2:4] = struct.pack("!H", resp_mid) sock.sendto(bytes(ack), remote) return { "coap_code": code_string, "payload": payload_text, "options": options_list, } async def prepare_json_body(request: web.Request): if request.can_read_body and request.content_type == "application/json": try: request["json_body"] = await request.json() except json.JSONDecodeError: raise web.HTTPBadRequest(text="Invalid JSON body") async def handle_get_coap(request: web.Request) -> web.Response: await prepare_json_body(request) target = _resolve_target(request) method = request.query.get("method", "GET").upper() path = request.query.get("path", "/") result = await _coap_request(method, target["host"], target["port"], path, payload=None) return web.json_response(result) async def handle_post_coap(request: web.Request) -> web.Response: await prepare_json_body(request) target = _resolve_target(request) body = request.get("json_body", {}) if not isinstance(body, dict): raise web.HTTPBadRequest(text="Body must be JSON object") method = str(body.get("method", request.query.get("method", "PUT"))).upper() path = str(body.get("path", request.query.get("path", "/"))) payload = body.get("payload", "") if payload is None: payload = "" if not isinstance(payload, str): payload = str(payload) result = await _coap_request(method, target["host"], target["port"], path, payload) return web.json_response(result) async def handle_discover(request: web.Request) -> web.Response: await prepare_json_body(request) target = _resolve_target(request) result = await _coap_request("GET", target["host"], target["port"], "/.well-known/core", payload=None) return web.json_response(result) async def handle_health(_: web.Request) -> web.Response: return web.json_response({"ok": True}) @web.middleware async def cors_middleware(request: web.Request, handler): if request.method == "OPTIONS": resp = web.Response() else: try: resp = await handler(request) except web.HTTPException as exc: resp = exc resp.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*") resp.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS" resp.headers["Access-Control-Allow-Headers"] = "Content-Type" resp.headers["Access-Control-Allow-Credentials"] = "false" return resp def create_app() -> web.Application: app = web.Application(middlewares=[cors_middleware]) app.router.add_get("/api/coap", handle_get_coap) app.router.add_post("/api/coap", handle_post_coap) app.router.add_options("/api/coap", lambda _: web.Response()) app.router.add_get("/api/discover", handle_discover) app.router.add_options("/api/discover", lambda _: web.Response()) app.router.add_get("/healthz", handle_health) return app def main(): app = create_app() print(f"HTTP↔CoAP proxy listening on http://{HTTP_HOST}:{HTTP_PORT}") print(f"Default CoAP target: coap://{DEFAULT_COAP_HOST}:{DEFAULT_COAP_PORT}") web.run_app(app, host=HTTP_HOST, port=HTTP_PORT, print=None) if __name__ == "__main__": main()