Metadata-Version: 2.4
Name: macuvc
Version: 0.1.0
Summary: Sudo-free pyuvc stand-in for macOS: AVFoundation frames + libusb UVC controls
Author: Vimal Mollyn
License-Expression: MIT
Project-URL: Repository, https://github.com/VimalMollyn/uvc-avf
Project-URL: Homepage, https://github.com/VimalMollyn/uvc-avf
Keywords: uvc,pyuvc,libuvc,avfoundation,libusb,macos,camera
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Multimedia :: Video :: Capture
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy
Dynamic: license-file

# uvc_avf

> **PyPI:** `pip install macuvc` (this distribution) or `pip install uvc-avf`, the same code under its repository name. The import name is `uvc_avf` either way.

A sudo-free stand-in for [pyuvc](https://github.com/pupil-labs/pyuvc) (`import uvc`) on macOS.

- **Frames** come from AVFoundation, through a small Swift helper process that writes each frame's
  luma plane into a shared-memory ring.
- **UVC controls** (exposure, gain, auto-exposure mode, extension units, ...) are class requests sent
  with libusb on the camera's default control pipe, without claiming an interface.

Neither part needs root. The module mirrors the pyuvc calls a typical capture loop makes, so pyuvc
code can switch backends with one import:

```python
import os
if os.geteuid() == 0:
    import uvc                  # pyuvc: needs sudo on macOS
else:
    import uvc_avf as uvc       # this package: no sudo
```

Scope: verified with one camera (Arducam OV9281 USB Camera behind a Sonix bridge, `0c45:6366`) on
one Mac, see [Measured results](#measured-results). Frames are luma (grayscale) only.

## Why pyuvc needs sudo on macOS

libuvc (which pyuvc wraps) streams by claiming the camera's VideoStreaming interface through libusb.
macOS's own UVC driver holds that interface. libusb can only take it by "capturing" the device
through a re-enumeration, and IOKit allows that only for root or for a process that has Apple's VM
device-access entitlement. No user-space workaround exists for streaming: without root, the frames
have to come from macOS, which means AVFoundation.

Controls are a different matter. They are class requests on the default control pipe (endpoint 0),
and macOS lets a normal user send those through libusb without claiming any interface, even while
AVFoundation streams the same camera. uvc_avf combines the two paths.

## What works without root

Verified 2026-09-26/27 with `tools/nosudo_probe.py`, run as a normal user, once with the camera
idle and again while an AVFoundation session streamed it (setup under
[Measured results](#measured-results)):

| Operation (libusb, no interface claimed) | Result |
|---|---|
| `libusb_open` | succeeds |
| `libusb_kernel_driver_active` on interfaces 0 and 1 | `True` for both |
| `libusb_claim_interface(0)` | fails with `LIBUSB_ERROR_ACCESS` |
| Standard descriptor reads (e.g. the product string) | work |
| UVC `GET_LEN`, `GET_INFO`, `GET_CUR`, `GET_MIN`, `GET_MAX`, `SET_CUR` on the camera terminal, processing unit and extension units | work, idle and while streaming |
| Exposure write (`SET_CUR`) with read-back | works, idle and while streaming |
| Vendor extension-unit register read (Sonix ASIC id) | works, idle and while streaming |

While macOS streamed, the streaming interface sat on alternate setting 6, and the committed mode
(`VS_COMMIT_CONTROL` `GET_CUR`) was MJPEG 800x600 @ 120 fps with 3072 bytes per transfer.

## Design

```
camera --USB--> macOS UVC driver --> AVFoundation --> uvc_avf_helper (Swift, child process)
  ^                                                     | copies the Y plane into a ring slot
  |                                                     | prints "frame SLOT SEQ" on stdout
  |                                                     v
  |                                         ring file, mmap'ed by both processes
  |                                                     |
  |                                                     v
  +-- UVC requests on endpoint 0 <-- uvc_avf (Python): Capture.controls, Capture.get_frame()
      (libusb, no interface claimed)
```

### Streaming path

1. `Capture(uid)` opens the device with libusb, parses the UVC descriptors (modes, unit ids,
   control bitmaps) and builds `cap.controls`. It does not stream.
2. The first `get_frame()` builds the helper if needed (`swiftc -O`, next to the module, rebuilt
   when `uvc_avf_helper.swift` is newer than the binary), creates a temporary directory
   (`uvc_avf_*` under `$TMPDIR`) holding the ring file `frames`, and starts the helper. It waits up
   to 15 s (`Capture.READY_TIMEOUT`) for the helper's `ready` line, then maps the file read-only.
3. The helper picks the AVFoundation device (see [Device matching](#device-matching)) and the
   AVFoundation format with the exact size and a frame-rate range whose maximum is within 1 fps of
   the request, preferring `420v` formats (the MJPEG modes). It requests `420f` output, falling
   back to `420v`. It takes `lockForConfiguration()`, sets the format and both frame durations to
   the range's exact `minFrameDuration`, calls `startRunning()`, unlocks, and checks that the
   session kept the format.
4. For each frame the capture callback copies the luma plane row by row into the next ring slot,
   tightly packed (no row padding). For `420f`/`420v` that is plane 0; for `2vuy`/`yuvs` it takes
   every other byte.
5. Each `get_frame()` reads the pending `frame` lines, keeps at most `cap.max_frame_lag` of them
   (default 4; older ones are counted in `frames_skipped`), and copies the oldest remaining slot.
6. `stop_stream()` / `close()` send `quit` on the helper's stdin. The helper also exits on stdin EOF,
   so it does not outlive the Python process.

### Helper protocol

```
uvc_avf_helper --shm PATH --width W --height H --fps F [--slots N]
               [--unique-id ID] [--vidpid VVVVPPPP] [--device NAME] [--test-pattern]
```

`Capture` passes `--slots 8`, the computed AVFoundation uniqueID, the vendor and product id as
8 hex digits, and the USB product string.

| stdout line | Meaning |
|---|---|
| `ready {json}` | Streaming started. Keys: `width`, `height`, `fps`, `fourcc` (the AVFoundation format's subtype, e.g. `420v`), `slots`, `slot_size`, `header_size`, `slot_header`, `file_size`, `device`, `unique_id`, `matched_by` (`unique id`, `vid/pid`, `name`, or `test`), `pid`. |
| `frame SLOT SEQ` | Frame number `SEQ` (1, 2, ...) is complete in slot `SLOT` = `SEQ mod slots`. |
| `warn ...` | Non-fatal problem; the module logs it as a warning. |
| `info ...` | Informational, e.g. `info quit`. |
| `error ...` | Fatal. The helper exits right after, and the module raises the text as `StreamError`. |

stdin: a `quit` line or EOF ends the helper.

Exit codes: 0 after `quit`/EOF; 1 ring-file error; 3 camera permission denied; 4 no matching
AVFoundation device; 5 no matching format, or the session replaced it; 6 cannot open the device,
add the input or output, or lock the configuration; 7 session runtime error or session stopped;
8 camera disconnected; 64 bad arguments (message on stderr).

`--test-pattern` skips AVFoundation completely, so it needs no camera and no permission. It
publishes a scrolling gradient at the requested rate, alternating bright and dark frames about
100 grey levels apart (odd `SEQ` bright), with fourcc `TEST`.

### Ring layout

A plain file mapped by both processes. Values are little-endian.

File header (4096 bytes):

| Offset | Type | Field |
|---|---|---|
| 0 | u32 | magic `0x55564146` (`UVAF`) |
| 4 | u32 | layout version, 1 |
| 8 | u32 | width |
| 12 | u32 | height |
| 16 | u32 | number of slots |
| 20 | u32 | slot size in bytes |
| 24 | u32 | header size, 4096 |
| 28 | u32 | slot header size, 64 |
| 64 | u64 | `SEQ` of the latest published frame (= frames published) |
| 72 | u64 | frames dropped by macOS (`captureOutput(_:didDrop:from:)` calls) |

Slot `k` starts at `4096 + k * slot_size`, where `slot_size` is `64 + width * height` rounded up to
a multiple of 4096 (77824 bytes at 320x240, 483328 bytes at 800x600).

| Offset in slot | Type | Field |
|---|---|---|
| 0 | u64 | `seq`: the frame's `SEQ`; 0 while the helper is writing the slot |
| 8 | f64 | `pts`: the sample buffer's presentation time, seconds |
| 16 | f64 | `t_callback`: time in the helper's capture callback, taken right after the copy |
| 24 | u32 | width |
| 28 | u32 | height |
| 32 | u32 | pixel format of the delivered buffer (e.g. `420f`; `TEST` in test-pattern mode) |
| 64 | u8[width * height] | luma plane, rows tightly packed |

Times are `mach_absolute_time` seconds, the same clock as Python's `time.monotonic()` on macOS
(`time.get_clock_info("monotonic").implementation` reports `mach_absolute_time()`; checked on
Python 3.9.6 and 3.11.12).

Write order in the helper (a seqlock): set `seq` to 0, memory barrier, copy the luma plane, write
the other slot fields, memory barrier, set `seq` to `SEQ`, update the file header, print
`frame SLOT SEQ`. The Python reader checks that `seq` equals `SEQ` before and after its copy. If
either check fails, the slot was being overwritten: the frame is discarded and counted in
`frames_torn`. The lag cap (4) is below the slot count (8), which keeps the reader away from slots
the helper is about to reuse.

### Controls

- Requests: `bmRequestType` 0xA1 for `GET_*` and 0x21 for `SET_CUR`, `wValue` = selector << 8,
  `wIndex` = unit id << 8 | VideoControl interface number, 1000 ms timeout, on the libusb handle
  with no interface claimed. A lock serializes them.
- `cap.controls` is pyuvc's `standard_ctrl_units` table (`uvc_avf.standard_ctrl_units`), filtered by
  the `bmControls` bitmaps of the camera terminal and the processing unit in the descriptor. Controls
  the device does not advertise are left out.
- Each `Control` reads `GET_INFO` when it is created (`info_bit_mask`), plus `GET_MIN`, `GET_MAX`,
  `GET_RES` and `GET_DEF` for whichever of `min_val`, `max_val`, `step` and `def_val` the table
  leaves as `None`. `refresh()` re-reads `GET_INFO` and fills in any limit that is still `None`.
- `.value` reads `GET_CUR` and writes `SET_CUR`. Controls that share one request buffer (Pan/Tilt,
  White Balance blue/red) read `GET_CUR` first and replace only their own field. Controls with a
  dict `d_type` also accept the names, e.g. `ctrl.value = "manual mode"`.
- 4-byte controls, plus Brightness, Hue, Roll, Pan and Tilt, are decoded as signed; everything else
  is unsigned.
- A failed request is logged as a warning (`Could not get Value of ...` / `Could not set Value of
  ...`), as pyuvc does. The getter then returns `None`. Nothing is raised.

### Device matching

The helper receives three keys and uses the first that matches an AVFoundation device:

1. The AVFoundation `uniqueID`, computed from libusb data as `"0x"` + hex(locationID) + vendor id
   (4 hex digits) + product id (4 hex digits). locationID is `bus << 24`, with the USB port path in
   the following nibbles, one nibble per port, starting at bit 20. Example: bus 1, ports 1,1,
   `0c45:6366` gives locationID `0x01110000` and uniqueID `0x11100000c456366`.
2. A uniqueID ending in the vendor and product id (`0c456366`).
3. A device name containing the USB product string.

The `ready` line and the INFO log (`streaming 800x600 @ 120.0 (420v) from ..., matched by unique id`)
say which key matched.

## Requirements and install

- macOS. Tested on 26.4.1 (MacBook Air, Apple Silicon).
- Xcode command line tools for `swiftc` (`xcode-select --install`). Tested with Swift 6.3.1.
- Homebrew libusb: `brew install libusb`. Tested with 1.0.29. The module loads
  `/opt/homebrew/lib/libusb-1.0.dylib`, then `/usr/local/lib/libusb-1.0.dylib`, then
  `libusb-1.0.dylib` from the default library search path.
- Python 3.9 or newer, with numpy. The measurements used Python 3.11.
- Camera permission for the app that launches Python (see [Gotchas](#gotchas)).

```sh
pip install git+https://github.com/VimalMollyn/uvc-avf
# or, to work on it:
git clone https://github.com/VimalMollyn/uvc-avf
cd uvc-avf
pip install -e .
```

The helper is compiled on the first `get_frame()` into the installed package directory, next to
`uvc_avf_helper.swift`, so that directory must be writable. This is the case in a virtualenv or an
editable checkout. On the packaging machine `swiftc -O` took about 2.5 s. To build it ahead of time
(this does not touch the camera):

```sh
python -c "import uvc_avf; print(uvc_avf._helper_exe())"
```

## Usage

```python
import uvc_avf as uvc

dev = next(d for d in uvc.device_list() if "Arducam" in d["name"])
cap = uvc.Capture(dev["uid"])
cap.frame_size = (800, 600)
cap.frame_rate = 120
ctrls = {c.display_name: c for c in cap.controls}
ctrls["Auto Exposure Mode"].value = 1          # manual; needed before writing the exposure
ctrls["Absolute Exposure Time"].value = 50     # units of 0.0001 s
try:
    first = cap.get_frame(timeout=10.0)        # the first call also starts the stream
    for _ in range(600):
        frame = cap.get_frame(timeout=1.0)     # TimeoutError, or uvc.StreamError if the helper died
        img = frame.gray                       # (600, 800) uint8
finally:
    cap.close()
```

### pyuvc to uvc_avf

| pyuvc | uvc_avf | Notes |
|---|---|---|
| `uvc.device_list()` | same | List of dicts with `name`, `manufacturer`, `serialNumber`, `idProduct`, `idVendor`, `device_address`, `bus_number`, `uid` (`"bus:address"`). Every USB device with a UVC VideoControl interface. |
| `uvc.Device_List()` | same | `update()` re-reads the bus; `cleanup()` does nothing. |
| `uvc.Capture(uid)` | same | Opens the device with libusb (no claim), reads descriptors, builds controls. Extra keyword `test_pattern=False`. Does not stream yet. |
| `cap.available_modes`, `cap.all_modes` | same | `CameraMode(width, height, fps, format_native, format_name, supported)` from the UVC descriptors. `format_native` is the UVC frame index; `format_name` is `"MJPEG"`, `"YUYV"`, ...; `fps` is rounded to an integer. |
| `cap.frame_sizes`, `cap.frame_rates` | same | Sizes, largest first; rates available at the current size. |
| `cap.frame_mode` | same | Setting it while streaming stops the stream; the next `get_frame()` restarts it. |
| `cap.frame_size = (w, h)` | same | Keeps the current rate if the size offers it. Unknown size: `CaptureError`. |
| `cap.frame_rate = fps` | same | A mode within 1 fps of the request, MJPEG preferred; otherwise the highest rate at that size, with a warning. |
| `cap.bandwidth_factor` | accepted, unused | macOS decides the USB bandwidth. Default 2.0. |
| `cap.controls` | same | See [Controls](#controls). |
| `cap.add_vendor_control(meta)` | same | Returns the new `Control` and appends it to `cap.controls`. |
| `cap.get_frame(timeout)` | same | `timeout` 0 (the default) or negative waits forever. Raises builtin `TimeoutError`, or `StreamError` when the helper dies. |
| `cap.get_frame_robust()` | same | Four attempts with a 1 s timeout, then `StreamError("could not get a frame")`. |
| `cap.stop_stream()` | same | Stops the helper; the next `get_frame()` starts a new one. |
| `cap.close()` | same | Stops the helper and closes the libusb handle. Also runs at interpreter exit and from `__del__`. |
| `frame.gray` | same | H x W `uint8`, a copy owned by the frame. |
| `frame.bgr`, `frame.img` | gray replicated | H x W x 3; no color. |
| `frame.yuv_buffer` | luma bytes only | Not a full YUV buffer. |
| `frame.jpeg_buffer` | raises `AttributeError` | macOS decodes the MJPEG stream. |
| `frame.timestamp` | capture time | The buffer's presentation time, on the `time.monotonic()` clock. |
| `frame.index` | helper sequence number | See [Differences](#differences-from-pyuvc). |
| `frame.width`, `frame.height`, `frame.data_fully_received` | same | `data_fully_received` is always `True`. |
| `uvc.get_time_monotonic()` | same | `time.monotonic()`. |
| `uvc.is_accessible(uid)` | always `True` | |
| `uvc.InitError`, `OpenError`, `StreamError`, `DeviceNotFoundError`, `InsufficientBandwidthError` | same names | All derive from `uvc_avf.CaptureError`. `InsufficientBandwidthError` (a `StreamError`) is never raised. |

uvc_avf additions: `cap.stats` (`{"frames", "dropped_by_macos", "skipped", "torn"}`),
`cap.max_frame_lag`, `cap.frames_skipped`, `cap.frames_torn`, `cap.avf_unique_id`, `cap.name`,
`frame.t_callback`, `frame.t_received`, `Capture.REOPEN_GAP` (0.5 s), `Capture.READY_TIMEOUT` (15 s).

### Differences from pyuvc

- **Delivery order.** Frames come oldest first, with the lag capped at `cap.max_frame_lag` frames
  (default 4). libuvc always hands out the newest frame. `cap.max_frame_lag = 1` returns the most
  recently announced frame; values below 1 are not supported.
- **Child process.** The stream lives in the helper process and starts on the first `get_frame()`.
  That call first waits for the helper's `ready` line (up to 15 s); its `timeout` applies after that.
- **Restart guard.** The module waits so that at least 0.5 s pass between stopping a stream and
  starting the next one (`Capture.REOPEN_GAP`), because of a quirk of the tested bridge.
- **`frame.index`** is the helper's sequence number: frames AVFoundation delivered to the helper,
  counting from 1 for each stream. Consecutive frames returned by `get_frame()` differ by 1 unless
  the reader skipped frames (`max_frame_lag`) or discarded a torn one. Frames lost before
  AVFoundation delivered them, including those macOS dropped (`stats["dropped_by_macos"]`), leave no
  gap. To detect those, compare `frame.timestamp` differences with the frame period.
- **Signed values.** 4-byte controls are signed 32-bit, like pyuvc (see [Controls](#controls)).
- **No MJPEG buffers**, and luma only.
- **No bandwidth control.** `bandwidth_factor` is ignored.

### Vendor (extension-unit) controls

`add_vendor_control` takes a pyuvc-style meta dict with the keys `unit_id`, `display_name`, `unit`,
`control_id`, `offset`, `data_len`, `bit_mask`, `d_type`, `doc`, `buffer_len`, `min_val`,
`max_val`, `step`, `def_val`. `unit_id`, `display_name`, `control_id` and `data_len` are required.
The defaults are `unit="extension_unit"`, `offset=0`, `bit_mask=0`, `buffer_len=data_len`,
`doc=""` and `d_type=int`. The limits default to `None` and are then queried from the device.
Other keys raise `TypeError`.

```python
meta = dict(unit_id=3, control_id=1, display_name="Vendor register", unit="extension_unit",
            offset=0, data_len=4, buffer_len=4, bit_mask=1 << 0, d_type=int, doc="",
            min_val=-(1 << 31), max_val=(1 << 31) - 1, step=1, def_val=0)
ctrl = cap.add_vendor_control(meta)
ctrl.value = request            # SET_CUR, 4 bytes little-endian; pass a signed 32-bit int
reply = ctrl.value & 0xFFFFFFFF # GET_CUR, decoded as signed 32-bit
```

Example: on the tested camera, the Sonix bridge exposes ASIC register access as extension unit 3,
selector 1, 4 bytes. Writing the bytes `[addr_lo, addr_hi, 0x00, 0xFF]` (packed into a signed
32-bit int) requests a read. Byte 2 of the following `GET_CUR` holds the register value. The ASIC id
register `0x101F` reads `0x83`. Extension units are vendor-specific: do not send this to another
bridge.

## Self-test

```sh
python -m uvc_avf --help
python -m uvc_avf --test-pattern        # synthetic frames; no camera permission needed
python -m uvc_avf --reopen              # real camera; run from Terminal.app
python -m uvc_avf --name "USB Camera" --size 640x480 --fps 100 --seconds 8
```

Apart from `--help`, every variant needs a UVC camera on the bus whose name contains `--name`
(default `Arducam`). Even `--test-pattern` opens that camera through libusb for its descriptors, modes and controls; only the
frames are synthetic. `--size` must be one of the camera's modes.

What it prints:

- `devices: [...]`, `opened <uvc_avf.Capture ...> AVFoundation id 0x...`, `controls: [...]`,
  `modes: [...]`, `frame_mode: ...`.
- `first frame after X s: index 1, WxH, mean M, ipc latency L ms`. X starts at the first
  `get_frame()` call, so it includes starting the helper (and compiling it the first time).
- `N frames in S s -> F fps; non-consecutive: K; mean |pair diff| D; ipc latency p50 P ms max Q ms;
  stats {...}`. F comes from frame timestamps. K counts `index` gaps. D is the mean brightness
  difference between the two frames of each pair (about 100 with `--test-pattern`, whose frames
  alternate bright and dark). ipc latency is `t_received - t_callback`.
- Real camera only: `exposure E -> set E+1 -> read back E+1 (min ... max ...)`. The exposure and
  the AE mode are restored afterwards.
- Real camera only: a vendor-control check that runs only if the `sonix_i2c` module of the project
  this package came from is importable. It is not part of this package; otherwise the line is
  `sonix_i2c not importable: vendor-control check skipped`.
- With `--reopen`: `closed; reopening`, a second run of up to 3 s, `closed`.

The helper can also be run alone, with no camera, USB access or permission:

```sh
(sleep 2 | ./uvc_avf/uvc_avf_helper --shm "$TMPDIR/uvcavf_smoke" --width 320 --height 240 --fps 60 --test-pattern) | head -3
rm -f "$TMPDIR/uvcavf_smoke"
```

```
ready {"width":320,"height":240,"fps":60.0,"fourcc":"TEST","slots":8,"slot_size":77824,"header_size":4096,"slot_header":64,"file_size":626688,"device":"test-pattern","unique_id":"","matched_by":"test","pid":79624}
frame 1 1
frame 2 2
```

## Probes

Both probes must run without sudo, from Terminal.app or another app with camera permission.

### `tools/avf_probe.swift`: can AVFoundation stream this camera, and how fast?

```sh
swift tools/avf_probe.swift [name-substring] [width] [height] [fps] [seconds]
# defaults: Arducam 800 600 120 6; width 0 keeps the session's default mode
```

`nosudo_probe.py` compiles it to `tools/avf_probe` on first use. The output shows:

- `camera permission for this process: notDetermined|restricted|denied|authorized`, and
  `requestAccess -> true|false` if it asked.
- `devices: [...]` and `using NAME (UNIQUEID)`. Compare UNIQUEID with `Capture.avf_unique_id`.
- `format FOURCC WxH fps [...]` for each AVFoundation format. On the tested camera the MJPEG modes
  appear as `420v` and the YUY2 modes as `yuvs`.
- `data output pixel formats: [...]`. On the tested camera: `2vuy`, `yuvs`, `420v`, `420f`, `BGRA`.
- `requested ... at VALUE/TIMESCALE`: the exact advertised frame duration, e.g. `1000000/120000480`.
- `startRunning returned after X s, running=true; active format ...`. The line
  `!! the session replaced the requested format` means the format did not survive `startRunning`.
- `frames N dropped D in S s -> F fps; interval p50 A ms max B ms`, then either
  `first frame: WxH FOURCC planes=P meanY M, T s after start` or `no frames in S s`. With permission
  denied, AVFoundation still runs the session and this line reads `no frames`.

This probe requests `420v` (video range), unlike the helper, so its `meanY` is not comparable with
`frame.gray.mean()`.

### `tools/nosudo_probe.py`: do controls work without root, idle and while macOS streams?

```sh
python3 tools/nosudo_probe.py [--vidpid VVVV:PPPP] [avfoundation-name-substring]
UVC_AVF_VIDPID=VVVV:PPPP python3 tools/nosudo_probe.py "Camera Name"
```

The defaults are `0c45:6366` and `Arducam`; `--vidpid` wins over `UVC_AVF_VIDPID`. The probe needs
only the standard library and libusb.

Phase 1, camera idle:

- `camera VVVV:PPPP at bus B addr A, running as uid U (not root, good)`.
- `units: ...` and `streaming formats (from the descriptor): ...`.
- `libusb_open OK`. The probe sets `LIBUSB_DEBUG=2`. A libusb `USBDeviceOpen` warning printed above
  this line means macOS holds the device object itself; control requests still work.
- `interface N: kernel/system driver active = True` for the VideoControl and VideoStreaming
  interfaces.
- `== control requests, camera idle ==`, one `OK value` or `FAIL LIBUSB_ERROR_...` line per
  request: product string, extension-unit `GET_LEN`/`GET_INFO`, Sonix ASIC id (expect `0x83`), AE
  mode, AE priority, exposure `GET_CUR`/`GET_MIN`/`GET_MAX`, exposure `SET_CUR` with read-back. The
  exposure and the AE mode are restored. The three extension-unit requests are Sonix-specific and
  run only for vendor id `0c45`; other cameras print `skipped`.
- `VS interface N alt setting: 0 (idle)` and the probe/commit `GET_CUR` blocks.
- `claim interface 0 without detaching: LIBUSB_ERROR_ACCESS`, which is expected without root.

Phase 2, macOS streaming: `avf_probe` streams 800x600 @ 120 for 8 s. After 3.5 s the probe repeats
the control requests (`while macOS streams`) and reads the streaming state. The tested camera
reported `alt setting: 6 (isoch pipe open, streaming)` and a commit block of MJPEG 800x600 at
120 fps with 3072 B per transfer. If that attempt delivers no frames, the probe tries the session
default mode and then 640x480 @ 100, each in a fresh process 3 s apart. `!! this process has no
camera permission` means TCC denied the launching app.

The run ends with a `SUMMARY` block and either `verdict: sudo-free path WORKS: ...` or
`verdict: not proven yet (see the FAIL / !! lines above)`.

## Gotchas

- **Camera permission belongs to the launching app.** macOS attributes camera access (TCC) to the
  app that launched the process, not to Python or the helper. An app without
  `NSCameraUsageDescription` in its Info.plist is denied silently. One example is the Claude Code
  desktop app. AVFoundation raises no error in that case: the session runs and delivers zero frames.
  Run from Terminal.app, which asks once. The helper checks `AVCaptureDevice.authorizationStatus`
  before starting and exits with `error camera permission denied for this process: ...`, which the
  module raises as `StreamError`. On the first run the helper waits for the answer to the prompt. If
  the answer takes longer than 15 s, `get_frame()` raises `StreamError("camera helper did not start
  streaming within 15 s")`; call it again after answering.
- **Hold the configuration lock across `startRunning()`.** The session applies its preset at
  `startRunning()` and replaces the device's `activeFormat`. Holding `lockForConfiguration()` across
  `startRunning()` keeps the requested format; ffmpeg does the same. Re-applying the format after
  the start is a quick stop/start, which left the tested Sonix bridge delivering 0 frames.
- **Full range, not video range.** The helper requests `420f` (luma 0..255, what pyuvc's JPEG
  decode gives) and falls back to `420v`. Video range squeezes the picture into 16..235, about 14%
  less contrast.
- **Exact frame durations.** Set the frame duration to the exact advertised `CMTime`, e.g.
  `1000000/120000480`, not `1/120`, or `setActiveVideoMinFrameDuration` throws.
- **Quick restarts.** Keep at least 0.5 s between stopping and starting a stream; the module
  enforces this. Changing `frame_mode`, `frame_size` or `frame_rate` while streaming is a stop plus
  a restart on the next `get_frame()`.
- **First frame delay.** The first frame arrived 0.56-0.85 s after start, and the very first
  stream also compiles the helper. Give the first `get_frame()` a generous timeout (the self-test
  uses 10 s).
- **One `Capture` per camera.** Each `Capture` starts its own helper process and AVFoundation
  session. Two `Capture`s on one camera were not tested. Close one before opening another; close
  followed by reopen was tested and works.
- **Exposure writes need manual mode.** Set `Auto Exposure Mode` to 1 (manual) before writing
  `Absolute Exposure Time` (units of 0.0001 s).
- **Writable package directory.** The helper binary is written next to the module on first use.

## Measured results

Measured 2026-09-26/27 on this setup:

| Item | Value |
|---|---|
| Computer | MacBook Air (Apple Silicon), macOS 26.4.1 |
| Toolchain | Swift 6.3.1, Homebrew libusb 1.0.29, Python 3.11 |
| Camera | Arducam OV9281 USB Camera, Sonix bridge `0x0c45:0x6366` |
| Connection | USB 2.0 through an Apple USB-C multiport adapter |

| Measurement | Result |
|---|---|
| Frame rate | 800x600 at 118-122 fps, over runs of 5-8 s |
| Frames dropped by macOS | 0 |
| Frames skipped by the reader | 0 |
| Torn frames | 0 |
| First frame | 0.56-0.85 s after start |
| Helper callback to Python (`t_received - t_callback`) | p50 about 0.2 ms, max below 1 ms |
| Close and reopen | works |
| Controls while streaming | exposure write with read-back and vendor extension-unit reads work |
| Streaming interface while macOS streams | alternate setting 6; committed MJPEG 800x600 @ 120 fps, 3072 B per transfer |

## Limitations

- macOS only. Verified with one camera, one bridge, one Mac and one macOS version (above). Other
  cameras and bridges are untested, and the restart guard and lock-across-start behaviour were
  established on the Sonix bridge.
- Measured end to end only in MJPEG 800x600 @ 120. The luma extraction for `2vuy`/`yuvs` buffers
  exists but was not measured.
- Luma only. `bgr`/`img` replicate the gray plane and any color is discarded; the tested sensor is
  monochrome.
- A mode must exist in both the UVC descriptors and AVFoundation's format list (same size, maximum
  rate within 1 fps). Otherwise the helper fails with a list of what AVFoundation offers.
- No bandwidth control. `InsufficientBandwidthError` is never raised.
- `is_accessible()` always returns `True`. Access problems surface as `OpenError` or `StreamError`.
- The uniqueID formula uses one nibble per port, so port numbers above 15 cannot be represented.
  The vendor/product and name fallbacks then apply. With two identical cameras, the vendor/product
  fallback takes the first AVFoundation device with that id, which may be the wrong one.
- Only the pyuvc surface listed above is provided.

## License

MIT, see [LICENSE](LICENSE). Copyright 2026 Vimal Mollyn.
