10.8. Two-way control with WebSockets

Server-Sent Events are push-only. When the owner taps “save a snapshot right now” or “reset the trigger counter” on the dashboard, the dashboard has to send a message to the cam. That’s WebSockets – one TCP socket, framed messages flowing both directions.

10.8.1. The /control route

microdot.websocket.with_websocket() performs the WebSocket upgrade handshake and hands the handler a WebSocket object. The handler loops forever, reading commands and sending acknowledgements:

from microdot.websocket import with_websocket
from microdot.websocket import WebSocketError
import json

@app.get('/control')
@with_websocket
async def control(request, ws):
    while True:
        try:
            msg = await ws.receive()
        except WebSocketError:
            break
        try:
            cmd = json.loads(msg)
        except ValueError:
            await ws.send({'error': 'bad json'})
            continue

        if cmd.get('cmd') == 'snapshot_now':
            if latest_jpeg:
                path = '/sdcard/snaps/manual-{}.jpg'.format(
                    int(time.time()))
                with open(path, 'wb') as f:
                    f.write(latest_jpeg)
                await ws.send({'ok': True, 'saved': path})
            else:
                await ws.send({'ok': False, 'reason': 'no frame yet'})
        elif cmd.get('cmd') == 'reset':
            state['trigger_count'] = 0
            await ws.send({'ok': True, 'counters': 'reset'})
        else:
            await ws.send({'error': 'unknown command'})

receive() returns a string for text frames and bytes for binary frames. The browser-side WebSocket.send(...) sends text by default, so JSON-encoded commands are the natural choice.

send() accepts strings, bytes, or anything JSON-serializable – a dict is sent as a JSON text frame.

WebSocketError is raised when the client disconnects (clean close, network drop, or protocol error). The handler exits the loop and returns; microdot tidies up the socket.

10.8.2. The dashboard sends commands

Two buttons go into index.html next to the slider:

<button id="snap-btn">Save snapshot</button>
<button id="reset-btn">Reset counter</button>

and app.js opens the WebSocket once and wires both buttons to send:

const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(proto + location.host + '/control');

document.getElementById('snap-btn').addEventListener('click', () =>
    ws.send(JSON.stringify({cmd: 'snapshot_now'})));
document.getElementById('reset-btn').addEventListener('click', () =>
    ws.send(JSON.stringify({cmd: 'reset'})));

ws.addEventListener('message', (e) => {
    console.log('cam:', JSON.parse(e.data));
});

The ws:// vs wss:// choice mirrors http:// vs https:// – the WebSocket inherits the same TLS handling. With HTTPS in place the dashboard automatically connects via wss://.

10.8.3. When to pick SSE vs WebSockets

Use SSE when the cam pushes and the browser only listens – notifications, telemetry, status changes. The wire is plain HTTP, the client side is one line (new EventSource), and reconnect is automatic.

Use WebSockets when the browser also needs to push – buttons, sliders that send keystroke-rate updates, anything where waiting on the next request would be too slow. The connection is bidirectional and framed, but the API is more involved on both sides.

The cam is now an interactive thing – watch, push events out, accept commands in.