When I started building Pwnagotchi Companion, I knew I needed two-way communication: the iPhone had to receive real-time data from the device, and it also needed to send commands back. This post covers the full-stack architecture — from Python WebSocket server to iOS Core Bluetooth implementation — and the lessons learned along the way.

The Challenge: Two Separate Channels

Pwnagotchi runs on a Raspberry Pi with no built-in remote interface. The existing ecosystem relied on SSH or a web UI that required both devices to be on the same WiFi network. That wasn't good enough for a mobile companion.

I needed to solve two problems simultaneously:

  • Live data stream: Handshake counts, GPS location, signal strength — pushed to the iPhone in real-time
  • Command & control: Send "reboot", "set power -1", "advertise" — via Bluetooth when out of WiFi range

In other words: WebSocket for internet, Bluetooth for local, and both must work seamlessly together.

Python WebSocket Server on the Pi

The Pwnagotchi software is Python-based but doesn't ship with any remote API. I wrote a plugin that runs alongside the main process and exposes a WebSocket server on port 8080.

import asyncio
import websockets
import json
from pwnagotchi.ui import display

connected_clients = set()

async def handler(websocket, path):
    global connected_clients
    connected_clients.add(websocket)
    try:
        async for message in websocket:
            data = json.loads(message)
            handle_client_command(data)
    except:
        pass
    finally:
        connected_clients.discard(websocket)

async def broadcast_stats():
    while True:
        stats = {
            'handshakes': display.get('handshakes'),
            'status': display.get('status'),
            'uptime': get_uptime()
        }
        for ws in connected_clients:
            try:
                await ws.send(json.dumps(stats))
            except:
                pass
        await asyncio.sleep(1)

async def main():
    server = await websockets.serve(handler, "0.0.0.0", 8080)
    await asyncio.gather(server.wait_closed(), broadcast_stats())

That broadcast_stats() loop pushes data once per second to all connected clients. Simple, effective, and runs on the Pi's limited hardware without breaking a sweat.

iOS Core Bluetooth Implementation

Bluetooth was harder. Core Bluetooth uses a delegate pattern that feels archaic compared to SwiftUI's declarative style. You don't just "connect" — you discover services, discover characteristics, subscribe to notifications, and handle all the connection state transitions yourself.

Here's my simplified BluetoothManager:

import CoreBluetooth

@MainActor
class BluetoothManager: NSObject, ObservableObject {
    @Published var isConnected = false
    @Published var devices: [CBPeripheral] = []

    private var central: CBCentralManager!
    private var targetPeripheral: CBPeripheral?

    private let SERVICE_UUID = CBUUID(string: "4fafc201-1fb5-459e-8fcc-c5c9c331914b")
    private let CHARACTERISTIC_UUID = CBUUID(string: "beb5483e-36e1-4688-b7f5-ea07361b26a8")
    private let COMMAND_CHAR_UUID = CBUUID(string: "5e7e12a1-3c75-4f20-9271-f2e2d3f576ba")

    override init() {
        super.init()
        central = CBCentralManager(delegate: self, queue: nil)
    }

    func sendCommand(_ command: String) {
        guard let peripheral = targetPeripheral,
              let characteristic = commandCharacteristic else { return }

        let data = Data(command.utf8)
        peripheral.writeValue(data, for: characteristic, type: .withResponse)
    }
}

extension BluetoothManager: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn {
            central.scanForPeripherals(withServices: [SERVICE_UUID], options: nil)
        }
    }

    func centralManager(_ central: CBCentralManager,
                        didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any],
                        rssi RSSI: NSNumber) {
        devices.append(peripheral)
    }

    func centralManager(_ central: CBCentralManager,
                        didConnect peripheral: CBPeripheral) {
        isConnected = true
        peripheral.discoverServices([SERVICE_UUID])
    }
}

The biggest headache was handling connection state. iOS bluetooth APIs are event-driven; you get callbacks when peripherals appear, connect, disconnect, or fail. Keeping a @Published var isConnected in sync across all those pathways took way more boilerplate than I expected.

Protocol Design: JSON Over Everything

I standardized on JSON for both channels. The Pi is already running json, and Swift's Codable makes parsing trivial. Every message gets a type field so the receiver knows how to handle it.

Example from Pi → iPhone:

{
  "type": "stats_update",
  "timestamp": 1704067200,
  "data": {
    "handshakes": 1247,
    "status": "MANUAL",
    "uptime": 86400,
    "bps": 1500000
  }
}

From iPhone → Pi (Bluetooth):

{
  "type": "command",
  "id": "uuid-here",
  "command": "reboot",
  "args": {}
}

Having a rigid schema meant I could evolve the protocol without breaking old clients. Add a new field? The old code ignores it. Remove something? Deprecate first, remove later.

Network State & Switching Logic

Here's where it got interesting: the iPhone could be on WiFi, Cellular, or offline. The Pwnagotchi could be connected to WiFi or purely standalone. Bluetooth only works within ~10 meters.

I built a ConnectionManager that tracks:

  • Current network reachability (NWPathMonitor)
  • WebSocket connection state
  • Bluetooth connection state
  • Whether the Pwnagotchi's hostname resolves

The UI shows different connection indicators based on what's actually working. User sees: Connected via WebSocket, Connected via Bluetooth, or ⚠️ No connection.

enum ConnectionState {
    case webSocket(connected: Bool)
    case bluetooth(connected: Bool)
    case neither
}

var connectionState: ConnectionState {
    if webSocketConnected { return .webSocket(connected: true) }
    if bluetoothConnected { return .bluetooth(connected: true) }
    return .neither
}

Lessons Learned

1. Bluetooth APIs are event-driven and unforgiving. You don't poll for state changes — you get callbacks. If you miss one, you're out of sync. I spent a full weekend debugging a "ghost connection" because I didn't handle the didDisconnectPeripheral callback in all code paths.

2. WebSocket reconnect logic needs backoff. My first implementation retried every second, which hammered the Pi when WiFi dropped. Exponential backoff with jitter saved the day.

3. Test on real hardware, not simulators. iOS Bluetooth simulators don't exist. Core Bluetooth doesn't work in Xcode simulators. I had to test on a physical iPhone and actual Pwnagotchis (which I had, but many developers don't).

4. Keep messages tiny. The Pi Zero 2 W has limited RAM and CPU. Sending full JSON dumps every second was wasteful. I started sending only delta updates — just the fields that changed since last tick.

The Result

The dual-channel approach worked. Users can walk away from WiFi with their phone and still send basic commands via Bluetooth. When they return to the same network, WebSocket automatically reconnects and syncs the latest stats.

Was it over-engineered? Maybe. But for a paid app that people depend on, reliability matters. That extra layer of Bluetooth support meant the difference between "cool app" and "actually useful tool."

INTERESTED IN THIS STACK?
I build iOS apps that talk to hardware. Let's collaborate on your next project.
← Back to Blog Next: Voice Assistant →