Skip to main content

Documentation Index

Fetch the complete documentation index at: https://crsdk.app/llms.txt

Use this file to discover all available pages before exploring further.

Poll GET /api/cameras to detect USB hot-plug events, auto-connect new cameras, and reconnect if one drops. The Alpha Camera REST server enumerates cameras every time you call /api/cameras, so regular polling is the idiomatic way to track physical state.

When to use

  • Your app should handle USB cable unplug/replug gracefully
  • You want to auto-connect the first camera that appears
  • You’re running 24/7 and need resilience against transient disconnects
  • You want to track multiple cameras and react when one drops

When NOT to use

  • One-shot scripts — just call list() once, grab the first camera, proceed
  • Apps where the user explicitly picks the camera from a list — polling only helps if state is changing

TypeScript

The real Next.js example collapses polling + SSE + reconnect into one small lifecycle class:
  • example_app/src/lib/camera-manager.ts

Complete recipe

import { CameraManager } from "./camera-manager";

const manager = new CameraManager({
  baseUrl: "http://localhost:8080",
  pollInterval: 5000,
  autoConnect: true,
  connectionMode: "remote-transfer",
  autoReconnect: true,
  maxReconnectAttempts: 5,
});

manager.on("camera-found", ({ camera }) => {
  console.log("found", camera.model, camera.id);
});

manager.on("camera-ready", ({ cameraId, camera }) => {
  console.log("ready", camera.model, cameraId);
});

manager.on("camera-disconnected", ({ cameraId, error }) => {
  console.log("disconnected", cameraId, error);
});

manager.on("connection-failed", ({ cameraId, error, attempt }) => {
  console.error("connect failed", cameraId, attempt, error);
});

await manager.start();

Why this is the right TypeScript shape

  • polling alone is too slow for good connect/disconnect UX
  • SSE alone is not enough to recover newly reappeared cameras
  • reconnect timing belongs in one place, not spread across React components
The real manager does all of the following:
  • polls GET /api/cameras
  • keeps diffed camera state
  • opens a global SSE stream for connect/disconnect edges
  • opens per-camera event streams on demand
  • waits for SSE-connected confirmation after connect()
  • retries reconnect with capped backoff
If you are building a React app, use this lifecycle layer under your components instead of duplicating polling logic in the page.

Python

Complete recipe

# discovery.py — async polling with diff events.

import asyncio
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional


@dataclass
class CameraInfo:
    id: str
    model: str
    connectionType: str
    connected: bool


OnAppear = Callable[[CameraInfo], Awaitable[None]]
OnDisappear = Callable[[str], Awaitable[None]]
OnError = Callable[[BaseException], Awaitable[None]]


async def watch_cameras(
    client,  # AsyncAlphaSDKClient
    *,
    interval_s: float = 3.0,
    on_appear: Optional[OnAppear] = None,
    on_disappear: Optional[OnDisappear] = None,
    on_error: Optional[OnError] = None,
    cancel: Optional[asyncio.Event] = None,
) -> None:
    """
    Poll client.cameras.list() every interval_s seconds.
    Fires on_appear/on_disappear callbacks when cameras change.
    """
    previous: dict[str, CameraInfo] = {}

    while not (cancel and cancel.is_set()):
        try:
            listing = await client.cameras.list()
            current = {
                cam.id: CameraInfo(
                    id=cam.id,
                    model=cam.model,
                    connectionType=getattr(cam, "connectionType", "USB"),
                    connected=cam.connected,
                )
                for cam in (listing.cameras or [])
                if cam.id
            }

            # Appearances
            for cam_id, cam in current.items():
                if cam_id not in previous and on_appear:
                    await on_appear(cam)

            # Disappearances
            for cam_id in previous.keys():
                if cam_id not in current and on_disappear:
                    await on_disappear(cam_id)

            previous = current

        except BaseException as err:
            if on_error:
                await on_error(err)

        # Interruptible sleep
        try:
            await asyncio.wait_for(
                (cancel.wait() if cancel else asyncio.Future()),
                timeout=interval_s,
            )
            return  # cancel set during sleep
        except asyncio.TimeoutError:
            continue

Usage — auto-connect first camera

import asyncio
from alpha_sdk_client import AsyncAlphaSDKClient
from discovery import watch_cameras, CameraInfo

async def main():
    client = AsyncAlphaSDKClient(base_url="http://localhost:8080")

    async def on_appear(cam: CameraInfo):
        print(f"Camera appeared: {cam.model} ({cam.id})")
        if not cam.connected:
            await client.cameras.connect(cam.id, mode="remote")
            await asyncio.sleep(0.5)  # settle
            await client.properties.set_priority_key(cam.id, setting="pc-remote")
            print(f"Auto-connected {cam.id}")

    async def on_disappear(cam_id: str):
        print(f"Camera {cam_id} gone")

    async def on_error(err):
        print(f"Discovery retry after error: {err}")

    cancel = asyncio.Event()
    await watch_cameras(
        client,
        interval_s=3.0,
        on_appear=on_appear,
        on_disappear=on_disappear,
        on_error=on_error,
        cancel=cancel,
    )

asyncio.run(main())

Swift

Complete recipe

// DiscoveryController.swift — mirrors the real app's poll + SSE hybrid.

import Foundation
import AlphaSDK

@MainActor
final class DiscoveryController: ObservableObject {
    @Published private(set) var cameras: [CameraInfo] = []
    @Published private(set) var connectedCameraId: String?

    private let client: AlphaSDKClient
    private let baseURL: String

    private var discoveryTask: Task<Void, Never>?
    private var disconnectListenerTask: Task<Void, Never>?
    private var connecting: Set<String> = []
    private var missedConnectedTicks = 0
    private var consecutiveListErrors = 0

    // Match the real app: 5s polls, require two consecutive negative
    // ticks before declaring a previously-connected camera gone.
    private static let pollIntervalNs: UInt64 = 5_000_000_000
    private static let connectionDropThreshold = 2

    // Example app also suppresses discovery while downloads are active.
    // Keep this flag here so call sites can gate polling if they have
    // long-running transfer work.
    var shouldSkipDiscoveryTick = false

    init(client: AlphaSDKClient, baseURL: String) {
        self.client = client
        self.baseURL = baseURL
    }

    func start() {
        discoveryTask?.cancel()
        discoveryTask = Task { [weak self] in
            while !Task.isCancelled {
                guard let self else { return }
                if !shouldSkipDiscoveryTick {
                    await refreshCameras()
                }
                try? await Task.sleep(nanoseconds: Self.pollIntervalNs)
            }
        }
    }

    func stop() {
        discoveryTask?.cancel()
        disconnectListenerTask?.cancel()
        discoveryTask = nil
        disconnectListenerTask = nil
    }

    func refreshCameras() async {
        do {
            let response = try await client.cameras.list(
                requestOptions: RequestOptions(timeout: 10)
            )
            cameras = response.cameras

            let serverConnected = response.cameras.first(where: { $0.connected })

            if let cam = serverConnected {
                missedConnectedTicks = 0
                if connectedCameraId != cam.id {
                    connectedCameraId = cam.id
                    await postConnectSetup(cameraId: cam.id)
                }
            } else if let first = response.cameras.first,
                      !connecting.contains(first.id),
                      connectedCameraId == nil {
                await connect(first.id)
            }

            if let id = connectedCameraId {
                let stillListed = response.cameras.contains(where: { $0.id == id })
                if !stillListed {
                    handleDisconnect()
                    missedConnectedTicks = 0
                } else if serverConnected?.id != id {
                    missedConnectedTicks += 1
                    if missedConnectedTicks >= Self.connectionDropThreshold {
                        handleDisconnect()
                        missedConnectedTicks = 0
                    }
                }
            }
            consecutiveListErrors = 0
        } catch {
            consecutiveListErrors += 1
            let backoff = min(8, 2 * (consecutiveListErrors - 1))
            if backoff > 0 {
                try? await Task.sleep(nanoseconds: UInt64(backoff) * 1_000_000_000)
            }
        }
    }

    func connect(_ cameraId: String) async {
        guard !connecting.contains(cameraId) else { return }
        connecting.insert(cameraId)
        defer { connecting.remove(cameraId) }

        // Real app pattern: subscribe BEFORE connect so the SDK's
        // OnConnected callback can win the race against the next 5s poll.
        let sse = SSEClient(baseURL: baseURL, cameraId: cameraId, onEvent: nil)
        let connectedTask = Task<SSEClient.ConnectedPayload?, Never> {
            await sse.waitForConnected(timeoutSeconds: 60)
        }
        try? await Task.sleep(nanoseconds: 200_000_000)

        do {
            _ = try await client.cameras.connect(
                cameraId: cameraId,
                request: Requests.ConnectionRequest(mode: .remoteTransfer, reconnecting: .off)
            )

            if let payload = await connectedTask.value {
                connectedCameraId = payload.id
                cameras = cameras.map { cam in
                    cam.id == payload.id
                        ? CameraInfo(
                            id: cam.id,
                            model: cam.model,
                            connectionType: cam.connectionType,
                            connected: true,
                            additionalProperties: cam.additionalProperties
                        )
                        : cam
                }
                await postConnectSetup(cameraId: payload.id)
            } else {
                // Fallback path if the SSE listener missed the event.
                try? await Task.sleep(nanoseconds: 1_000_000_000)
                await refreshCameras()
            }
        } catch {
            connectedTask.cancel()
        }
    }

    private func postConnectSetup(cameraId: String) async {
        startDisconnectListener(cameraId: cameraId)
        do {
            _ = try await client.properties.setPriorityKey(
                cameraId: cameraId,
                request: Requests.SetPriorityKeyRequest(setting: .pcRemote)
            )
        } catch {}
        // The real app also enables/starts live view and begins
        // property/live-view polling here.
    }

    private func startDisconnectListener(cameraId: String) {
        disconnectListenerTask?.cancel()
        disconnectListenerTask = Task { [weak self] in
            guard let self else { return }
            let sse = SSEClient(baseURL: baseURL, cameraId: cameraId, onEvent: nil)
            let fired = await sse.waitForDisconnected()
            await MainActor.run {
                guard !Task.isCancelled else { return }
                guard self.connectedCameraId == cameraId else { return }
                if fired { self.handleDisconnect() }
            }
        }
    }

    private func handleDisconnect() {
        let priorId = connectedCameraId
        connectedCameraId = nil
        disconnectListenerTask?.cancel()
        disconnectListenerTask = nil
        if let priorId {
            cameras = cameras.map { cam in
                cam.id == priorId
                    ? CameraInfo(
                        id: cam.id,
                        model: cam.model,
                        connectionType: cam.connectionType,
                        connected: false,
                        additionalProperties: cam.additionalProperties
                    )
                    : cam
            }
        }
    }
}

Usage

let client = AlphaSDKClient(baseURL: "http://localhost:8080", timeout: 60)
let controller = DiscoveryController(client: client, baseURL: "http://localhost:8080")
controller.start()

// Stop later:
controller.stop()

Combining with SSE for best UX

Polling catches hot-plug events within the poll interval (5s in the real macOS app). SSE reports disconnects instantly. Ideal pattern:
  • Polling — detects new cameras and performs eventual consistency / recovery
  • SSE connected — fast-paths connect completion so the UI does not wait for the next 5s poll
  • SSE disconnected — instant teardown when a camera drops mid-session
You don’t need both if your use case is simple. The real app uses both because 5-second polling alone feels too slow for connect/disconnect edges.

Common pitfalls

  • Polling alone is not enough for good UX. The real app combines 5s discovery polling with short-lived SSE listeners around connect and a long-lived disconnected listener.
  • Callbacks can throw. Wrap your onAppear logic in try/catch — otherwise one bad camera kills the discovery loop.
  • Don’t poll too fast. The real app uses 5s. /api/cameras can itself take several seconds because the server performs a fresh discovery scan.
  • Do not assume one negative connected:false means dropped. The real app requires two consecutive negative polls because the camera may transiently report not-connected during post-connect setup.
  • Skip discovery during transfers if your app can. The example app suppresses camera-list polling while SD downloads are active because the SDK serializes those operations per camera.

Verified against

The Swift section mirrors the connection/discovery lifecycle in example_swift_app/Sources/AlphaCameraExample/AppCoordinator.swift.