Skip to content

Protocol translation hub

BlackBull speaks HTTP/1.1, HTTP/2, WebSocket, SSE, gRPC, and MQTT from a single Python process — which means translating between them needs no Envoy, no nginx, no sidecar, and no config file. A device publishes over MQTT; a browser watches over WebSocket or SSE; a REST call becomes a gRPC invocation. The glue is ordinary application code.

The complete working example is examples/translation_hub.py — run it and drive all four protocols at once:

python examples/translation_hub.py
mosquitto_pub -t 'sensors/kitchen/temperature' -m '21.5' -p 1883 -V 5
curl -N http://localhost:8000/events            # the reading, over SSE
curl http://localhost:8000/rooms/kitchen/stats  # REST -> gRPC translation

This page walks through the three translations it contains.

The fan-out primitive

Every translation direction meets in a tiny in-process hub — a set of bounded per-subscriber queues. A slow browser drops its own oldest frame rather than back-pressuring the MQTT broker, the same best-effort stance the tap layer itself takes:

class Hub:
    def subscribe(self) -> asyncio.Queue: ...
    def unsubscribe(self, q) -> None: ...
    def publish(self, item: dict) -> None:
        for q in self._queues:
            if q.full():
                q.get_nowait()          # drop that subscriber's oldest
            q.put_nowait(item)

That is the entire "message bus". No broker process, no serialization hop — the dict flows from the MQTT tap to the WS/SSE handlers as a Python object.

MQTT → WebSocket / SSE

An @mqtt.on_message tap translates each device publish into a hub item; {room} captures work like HTTP path params:

@mqtt.on_message(topic='sensors/{room}/temperature')
async def on_temperature(msg: Message, room: str):
    hub.publish({'room': room, 'temperature': float(msg.payload)})

The WebSocket handler subscribes to the hub and forwards items as text frames, while also watching receive() so a client close ends the handler. The SSE side is even shorter — an async generator wrapped in EventSourceResponse:

@app.route(path='/events')
async def sse_feed():
    async def readings():
        q = hub.subscribe()
        try:
            while True:
                yield {'event': 'reading', 'data': json.dumps(await q.get())}
        finally:
            hub.unsubscribe(q)
    return EventSourceResponse(readings())

One MQTT PUBLISH in, one WS frame and one SSE event out, per connected client — three protocols, one process.

REST → gRPC

The gRPC service registered with GrpcServiceRegistry is reachable two ways at once: natively (any gRPC client calling hub.Telemetry/RoomStats on the same port, h2c or TLS), and through a REST route that performs the translation in-process — build a GrpcContext, invoke the registered method, and map GrpcStatus onto an HTTP status:

@app.route(path='/rooms/{room}/stats')
async def rest_room_stats(room: str, scope: dict):
    handler = grpc.lookup('/hub.Telemetry/RoomStats')
    context = GrpcContext(scope)
    try:
        payload = await handler(json.dumps(room).encode(), context)
    except GrpcError as exc:
        return JSONResponse({'error': exc.details or exc.status.name},
                            status=HTTPStatus(_GRPC_TO_HTTP.get(exc.status, 500)))
    return Response(payload, content_type='application/json')

context.abort(GrpcStatus.NOT_FOUND, ...) in the service surfaces as a NOT_FOUND status trailer to gRPC clients and as a 404 JSON error to REST clients — one handler, both protocols, consistent semantics.

Why this is hard elsewhere

The conventional shape of this system is four processes and a gateway: an MQTT broker (Mosquitto), a translation worker, a gRPC service behind Envoy's gRPC-JSON transcoder, and the web server. Each hop adds a serialization boundary, a network hop, a config file, and a failure mode. Here the entire hub is one python invocation whose "config" is the example file itself — and every protocol conversation is observable with the same event API and fault-injection tooling the rest of BlackBull uses.