# EMEET PIXY control protocol What `scripts/pixy` sends the camera, and why it sends it that way. Written down because the interesting parts are the ones a reader would otherwise assume were arbitrary: the duplicated length byte, the second report after every mode change, the value `0x03`, and the fact that half the camera's own features are deliberately unused. ## Provenance Two sources, and it is worth keeping them apart. **Inherited.** The starting point was the survey at and the projects it links. That is where the vendor HID interface, the report id, and the general shape of a report came from. None of it is official documentation. **Verified here.** Every constant in `scripts/pixy` was then checked against the device itself — an EMEET PIXY, USB `328f:00c0`, on Linux 6.x with `uvcvideo`. The findings below marked *(observed)* are things this camera actually did, repeatedly, on this firmware. They are not from a datasheet, and a firmware update could invalidate any of them. There is no vendor specification. Treat the whole document as a field report. ## Two control surfaces The camera answers on two unrelated interfaces, and the helper uses each for only what it is good at. | | Interface | Used for | |---|---|---| | UVC | V4L2 on `/dev/videoN` | pan, tilt, zoom, image controls, capture | | Vendor HID | `/dev/hidrawN` | mode, audio DSP, gestures, flip/rotate, focus metering, the camera's own preset slots, the idle shutter timeout | (Three, counting the microphone — but it needs no protocol work at all. See [The microphone is not a protocol problem](#the-microphone-is-not-a-protocol-problem).) The split is not cosmetic. UVC pan/tilt/zoom are **absolute and readable**, so the panel can show a real position instead of dead-reckoning from the moves it happens to have made. The control mode has no UVC equivalent at all, so it has to go over HID. ### Why HID PTZ is not used The camera does expose PTZ over HID (groups `0x03` and `0x63`). The helper never touches them. *(observed)* HID PTZ commands move the motors while leaving the UVC position readback unchanged. After one HID move, `V4L2_CID_PAN_ABSOLUTE` reports a position the lens is no longer at, and there is no way to resynchronize short of a UVC absolute write. Mixing the two paths permanently desynchronizes the readout. A panel that cannot trust its own position display is worse than one with a slightly smaller feature set, so the HID PTZ path is left alone. This is a statement about HID *motion commands* specifically. The camera's own preset slots are HID and are used — see [The camera's own preset slots](#the-cameras-own-preset-slots) — because their stored coordinates turn out to be in the UVC space, so they can be recalled over UVC without any HID motion command being sent. ## Finding the right nodes Both surfaces need discovery, and both have a trap. **HID.** The camera claims several `hidraw` nodes and only one answers vendor reports. Matching on the USB ids alone picks whichever node udev enumerated first, which is a coin flip across reboots. The helper instead reads each candidate's report descriptor and prefers the node whose descriptor contains ``` 05 83 09 83 # usage page 0x83, usage 0x83 ``` *(observed)* Exactly one node carries that pair, and it is the one that answers. If no node advertises it — a plausible firmware difference — the helper falls back to the USB id match rather than reporting no camera. **Video.** The PIXY exposes two `/dev/videoN` nodes; the second is a metadata node that accepts no controls. `VIDIOC_QUERYCAP` distinguishes them, and the `device_caps` field is the one to read, not `capabilities`: the global field advertises capture for the whole device, so a metadata node looks like a capture node if you check the wrong one. Both lookups can be overridden with `PIXY_HIDRAW` and `PIXY_VIDEO`. If an override names a path that does not exist, the helper reports no camera rather than falling through to autodetection — driving a *different* device than the one the operator named is the worse failure. ## UVC controls Three controls, driven through raw `ioctl`s rather than by shelling out to `v4l2-ctl`, so the plugin needs nothing outside the Python standard library. | Control | CID | Unit | Range on this camera | |---|---|---|---| | `V4L2_CID_PAN_ABSOLUTE` | `0x009A0908` | arc-seconds | ±540000 (±150°) | | `V4L2_CID_TILT_ABSOLUTE` | `0x009A0909` | arc-seconds | ±324000 (±90°) | | `V4L2_CID_ZOOM_ABSOLUTE` | `0x009A090D` | driver units | 100..150 | Pan and tilt are arc-seconds, so a degree is 3600 units and the step is exactly one degree. Zoom is unitless in the driver; 100..150 corresponds to 1.00×..1.50×, which is how the official app displays it and what the number means. The ranges above are what this camera reported, but the helper never hardcodes them for writes — it re-reads `VIDIOC_QUERYCTRL` and clamps against whatever the driver says. The duplicated bounds in `Model.js` exist only so the sliders have sane extents before the first reply arrives. **Snapping matters.** Values are rounded onto a step boundary before writing. An unsnapped write is silently rounded by the driver, so the readback disagrees with what the slider was set to and the handle visibly jumps on the next refresh. **Settling matters too.** A full sweep takes time. Reading back immediately after a write returns the old position, so anything that writes then reads waits `PTZ_SETTLE` (0.35 s) in between. ## Capture, for the terminal preview `scripts/pixy preview` captures frames itself rather than shelling out to anything, which puts two more V4L2 details on the record. **YUYV, not MJPEG.** The camera offers both. YUYV was chosen for one reason: it interleaves luma with chroma, so byte `2*x` of a row is pixel `x`'s brightness and a brightness-only render needs no decoding at all. MJPEG would mean hand-rolling a baseline JPEG decoder inside a file that is not allowed to import anything outside the standard library. The panel's own preview is QtMultimedia and has no such constraint; this path exists for a terminal. **Streaming, not read().** *(observed)* This camera does not advertise `V4L2_CAP_READWRITE`, so a plain `read()` on the node returns `EINVAL` no matter what. Frames only come out of the mmap streaming path, which is six ioctls in a fixed order: ``` S_FMT → REQBUFS → QUERYBUF → mmap → QBUF → STREAMON → DQBUF ⇄ QBUF ``` `S_FMT` is a request, not a command — the driver rewrites width, height, and `bytes_per_line` to what it accepted, so they are read back rather than trusted. A size silently adjusted from the requested one misaligns every row of output, and a `bytes_per_line` wider than `width * 2` shears the image progressively down the frame rather than failing. Teardown matters as much as setup: a stream left on holds the camera against every other app on the system, and mapped buffers outlive the fd if they are not explicitly unmapped. `Capture` is a context manager for that reason and undoes every acquisition step in reverse, including on a partially-failed `__enter__`. ### The `v4l2_buffer` offset trap *(observed, painfully)* `struct v4l2_buffer` contains a `timeval` and two unions, and its field offsets are **not** derivable by reading the struct definition and counting. The offsets in `scripts/pixy` came from compiling `offsetof` against this kernel's headers: | Field | Offset | |---|---| | `index` | 0 | | `type` | 4 | | `bytesused` | 8 | | `memory` | 60 | | `m.offset` | 64 | | `length` | 72 | | *sizeof* | 88 | The obvious hand derivation over-pads `v4l2_timecode` and lands `memory` 16 bytes late. That does not fail at the mistake — the wrong offset reads a neighbouring field, `mmap` maps garbage, and the visible symptom is `ENOTTY` several ioctls later, which reads like a wrong ioctl number rather than a wrong struct. There is one free check, and it is worth knowing: the size is encoded in the ioctl number itself. `VIDIOC_QBUF` is `0xc058560f`, and `0x58` is 88 — so `BUFFER_SIZE` disagreeing with `(VIDIOC_QBUF >> 16) & 0x3fff` is a bug that can be caught without hardware. `tests/test_pixy.py` asserts exactly that. ### Stream exclusivity, and why the control plane is unaffected *(observed)* Exactly one process at a time may hold the capture stream. `open()` still succeeds for everyone — that is the confusing part — and the refusal arrives later, at `S_FMT` or `REQBUFS`, as `EBUSY` (errno 16). What was verified, in both directions: | Situation | Result | |---|---| | Another app streaming, we try to capture | `EBUSY` at `S_FMT` | | We are streaming, another app tries | `EBUSY` at `S_FMT`/`REQBUFS` | | Another app streaming, we set pan/tilt/zoom | works | | Another app streaming, we set privacy/tracking | works, and `read_mode` is *more* informative | | Nothing streaming, we set pan/tilt/zoom/image | works | | Nothing streaming, we set privacy | works | | Nothing streaming, we set standard/tracking | **silently discarded** | Rows three and four are the important ones. `VIDIOC_S_CTRL` on an open fd and HID writes to `/dev/hidrawN` are both entirely independent of who holds the stream, so every control this widget exposes keeps working during someone else's video call. Only the preview competes. That asymmetry is why the preview yields and why it can be switched off without costing any functionality. The last row is the one exception to "the control plane always works", and it runs the opposite way to everything else here: not blocked by *another* app streaming, but by *nobody* streaming. It has its own section below. A blocked competitor is also *detectable*, which is what makes yielding possible at all rather than merely polite: a process refused with `EBUSY` still has the node open, so it appears under `/proc/*/fd`. The panel can therefore tell that it is itself the obstacle and release. `stream_holders()` returns `(other_pids, our_pids)` for that reason — folding the two together makes an open panel report "in use by quickshell", and discarding our own side means the panel never learns it is in the way. The residual gap is single-shot clients. `ffmpeg -f v4l2` opens the device, fails, and exits within microseconds; no poll interval and no `inotify` handler can react inside that window. (`inotify` does deliver `IN_OPEN` on `/dev/videoN` in ~9 ms, but it carries no PID, so it cannot distinguish another app's open from the helper's own `state` read — reacting to it would drop the preview on every refresh.) Apps that hold the device open while negotiating, which is what browsers and meeting clients do, are handled. For the rest there is the `preview` setting. ## The microphone is not a protocol problem Worth stating explicitly, because it is the one camera feature that needed no reverse engineering: the PIXY's microphone is a standard USB audio class device. It appears as an ALSA card and PipeWire publishes it as `Audio/Source`, so mute and volume are ordinary PipeWire property writes. `scripts/pixy` does not touch it and has no reason to. There is one trap, and it is in *identification* rather than in control. *(observed)* The camera publishes **two** PipeWire nodes carrying the nickname `EMEET PIXY`: | `node.name` | `media.class` | `audio` | |---|---|---| | `alsa_input.usb-EMEET_EMEET_PIXY_-02.mono-fallback` | `Audio/Source` | present | | `v4l2_input.pci-…-usb-0_2_1.0` | `Video/Source` | null | Note the second name. It begins `v4l2_input`, so any filter looking for "input" — or "source", or "capture" — in the node name matches the camera as well as the microphone. Identity strings cannot separate them either, since the nickname is identical and both descriptions start with "EMEET PIXY". The discriminator is `PwNode.audio`: non-null on the microphone, null on the camera. It is a constant property rather than one that fills in after binding, and it reads correctly well before `PwObjectTracker` has bound anything — verified at 100 ms from shell startup. `Model.js`'s `isPixyMic()` checks it first for that reason. An earlier version matched on the name pattern alone and returned whichever node PipeWire enumerated first. That happens to be the microphone on the development machine, which is exactly what makes the bug worth recording: it worked, and it would have kept working until enumeration order changed, at which point the mute button would have driven a camera node and silently done nothing. `tests/qml/tst_model_mic.qml` pins both orderings. The other reason not to use `Pipewire.defaultAudioSource`: a webcam is rarely the system default. Taking the default would put a control in a panel titled PIXY that adjusts some other microphone entirely. ## Vendor HID reports Every report is exactly 32 bytes, zero-padded, and starts with report id `0x09`. ``` byte 0 1 2 3 4 5 6 7 8... 09 GG AA BB 00 LL 00 LL ^ ^ ^ ^ ^ | | | | └── payload length, again | | | └──────────── payload length | └────┴────────────────────── command selector └─────────────────────────────────command group ``` *(observed)* The length appears **twice**, at offsets 5 and 7. Sending it once and leaving the other zero gets the report ignored. There is presumably a structural reason — an outer envelope wrapping an inner one, most likely — but from the outside it is simply a rule: state the length in both places. `build_report()` is the only function in the helper that produces bytes for the camera, and it is deliberately strict — wrong report id, over 32 bytes, or a byte outside 0..255 all raise rather than reaching the hardware. Validating in one place means no call site can get it wrong. ### Queries are write-then-read A vendor query writes a report and reads the reply on the same file descriptor. Four details are load-bearing: 1. **Replies echo bytes 1–3 of the request.** *(observed)* Every group in use answers with the group byte in byte 1 and the two command bytes in bytes 2–3, verbatim. That is what makes a reply matchable at all — the camera also emits unsolicited status reports, and taking the first thing that arrives decodes another group's payload as the answer. 2. **Matching on the group alone is not enough.** *(observed, and it shipped as a bug)* A group carries several commands. Group `0x05` holds both `00 03` (set audio) and `00 04` (read audio), and a set is acknowledged with `0x20` where the value belongs. A read issued right after a set therefore accepted the set's ack as its own answer, decoded `0x20` as a mode, and reported a write that had landed as failed. The helper matches the command bytes too. 3. **The group byte is masked with `0x1f`.** *(observed)* Replies come back with high bits set in byte 1 — `0x01` returns as `0x41` or `0x61` — so comparing it raw never matches. The command bytes are *not* masked; they come back exactly as sent. 4. **Reads are retried, and the fd is drained first.** *(observed)* An unread reply is drained by the *next* query, so one missed answer corrupts the following one too. Four attempts, draining before each. ### The first write on a fresh descriptor is lost *(observed)* This is the reason a query is retried at all, and it is more specific than "the camera answers slowly". Timed with instrumentation: | | Latency | |---|---| | Reply to a write on an already-used fd | ~30 ms | | Reply to the first write after `open()` | never — the write is silently dropped | So every query on a freshly opened descriptor spent a full `QUERY_TIMEOUT` (0.6 s) waiting for an answer to a report the camera never saw, then succeeded immediately on attempt two. Ten feature reads cost **8.49 s**. Sharing one descriptor across them costs **0.56 s** — `HidSession` exists for exactly this, and `pixy vendor` opens the node once for all ten. Commands that issue a single query gain nothing from it and do not use it. ### The first config write after idle is dropped too *(observed)* Separately from the above, and not fixed by sharing a descriptor: the first *set* report after a period of inactivity does not take effect. The identical command sent immediately afterwards succeeds. Verified across audio, gesture, and focus metering — cold attempts report `attempts: 2`, warm ones `attempts: 1`. `set_and_confirm()` is the single place this is handled: every vendor setter writes, waits `SET_SETTLE`, reads back, and retries once if the readback disagrees. A setter whose write landed exits on the first pass, so the retry costs nothing in the normal case — and because confirmation comes from a readback rather than from the write returning, `confirmed: false` is a real, reportable outcome rather than a hidden failure. Writes of multiple reports also pause 25 ms between them: *(observed)* the camera acknowledges a config report before it is ready for the commit that follows, and back-to-back writes get dropped. ### Control mode — group `0x01` | Mode | Value | |---|---| | Standard | `0x00` | | Tracking | `0x01` | | Privacy | `0x02` | Setting a mode is two reports: the write, then the query that follows it. ``` 09 01 01 00 00 01 00 01 VV # set mode to VV 09 01 01 01 # query mode — also commits the write ``` *(observed)* The query is not just a readback. Sending the set report alone does not reliably take effect; the query that follows is what commits it. So the helper always sends both, and reads the reply to find out what the camera actually did. Reading the mode is the query report alone. The mode is byte 8 of the reply. ### The `0x03` problem *(observed)* An **idle** camera — nothing holding the video stream — answers `0x03` to the mode query, for both Standard and Tracking. It does not distinguish them. Once any app opens the stream, it answers `0x00` or `0x01` correctly. Privacy is the exception: it answers `0x02` whether or not anything is streaming. This shapes three behaviors: - `read_mode()` returns `(None, 0x03)` — unknown, with the raw byte kept. It does not resolve the ambiguity to a guess, because guessing "Standard" would have the panel claim tracking is off while the camera is visibly following someone around the room. - The panel says so out loud rather than showing a confidently wrong toggle, and dims the chips — see the write refusal below, which is the same firmware condition and the reason the chips are disabled rather than merely unlit. - **The privacy toggle still works on an idle camera**, because `0x02` is unambiguous. An earlier version discarded the raw byte whenever the mode came back unknown, which threw away a perfectly good `0x02` and made the toggle turn privacy *on* twice in a row. The `streaming` flag exists only to explain why a mode is unknown; it never gates the decode. When the mode genuinely cannot be determined, the toggle errs toward turning privacy **on**. That is the safe direction for someone reaching for the button. *(observed)* One side effect worth knowing: **the panel's own preview counts as a stream**, so opening the panel resolves the ambiguity. Closed, `state` reports `modeRaw: 3` and `mode: null`; open with the preview running it reports `modeRaw: 0` and `mode: "standard"`. This is why the mode chips can show a filled dot at all in normal use — and it is the one thing setting `preview: false` costs. ### An idle camera also refuses the write *(observed)* The ambiguity above is only half of it: an idle camera does not merely fail to *report* Standard vs Tracking, it refuses to *change* it. The HID write is accepted — `write_reports` succeeds, the helper returns `ok: true` — and the firmware discards it. Tested in both directions, since one direction alone could have been a readback artifact rather than a lost write: | Start | While idle, write | Then open a stream and read | Verdict | |---|---|---|---| | Standard | Tracking | `standard` | write lost | | Tracking | Standard | `tracking` | write lost | The same write during a stream lands on the first attempt (3/3 trials, ~1.7 s after stream start). Privacy is exempt in **both** directions: `0x02` writes fine on an idle camera, and so does leaving privacy. This is the reason `Panel.qml`'s `setMode` guard skips privacy and skips any write made *from* privacy — gating those would let the lens be closed on an idle camera and then not reopened. So the panel dims both chips and disables the group while the camera is idle, with a note saying why. An earlier version left them live and merely showed neither as active: honest about the readback, but it read as a bug, and pressing a chip then did nothing with no error to explain it. That was reported as "neither Standard nor Tracking is highlighted", which is how this behavior was found. *(observed)* Unrelated flake noticed while testing this, not currently handled: a direct `pixy mode privacy` on an idle camera lands about 2 times in 3 — the readback comes back `0x03` and `confirmed: false`. The panel's privacy path was reliable in the same conditions, so this is recorded rather than worked around. ### Leaving privacy *(observed)* A direct privacy → tracking transition is ignored by the firmware. The camera stays in privacy while the panel believes it switched. Switching to Tracking from Privacy therefore writes Standard first, then Tracking. And leaving privacy via the toggle goes to **Standard**, never Tracking — closing the lens and reopening it should not quietly enable a camera behavior nobody asked for. *(observed)* That intent only holds while something is streaming. On an idle camera the lens does reopen, but the Standard part of the write is discarded like any other idle Standard/Tracking write: a camera that was in Tracking before privacy is in Tracking again afterward, confirmed by starting a stream and reading it back. Not worked around — the alternative is refusing to reopen the lens, and a lens that stays shut is a worse outcome than a tracking mode that survived. ## Other vendor feature groups Everything below speaks the same 32-byte report. Each feature has a set command and a read command in the same group, and the helper never trusts a write: it reads back through `set_and_confirm()`. *(observed)* Group `0x04`'s read replies put the *value* at byte 9, not at the end of the report. Reports are zero-padded to 32 bytes, so decoding from the last byte always yields `0` — which looks exactly like "the feature is off" and is a live bug in at least one other Linux tool for this camera. `read_gesture` and `read_feature` read byte 9, and there are tests pinning that. ### Audio DSP mode — group `0x05` | Mode | Value | What it is | |---|---|---| | Noise cancelling | `0x01` | the default; suppresses background noise | | Live | `0x02` | wide-band, for music | | Original | `0x03` | no processing | ``` 09 05 00 03 00 01 00 01 VV # set 09 05 00 04 # read — mode is byte 8 ``` This is DSP inside the camera, not a PipeWire filter: it survives a reboot and applies to every host the camera is plugged into. ### Gesture control — group `0x04`, command `02 00` ``` 09 04 02 00 00 02 00 02 02 EE # set: EE = 01 on, 00 off 09 04 02 01 00 01 00 01 02 # read — value at byte 9 ``` ### Image orientation — group `0x04`, command `00 08` Three independent toggles, addressed by a feature id in byte 8: | Feature | Id | |---|---| | Horizontal flip (mirror) | `0x01` | | Vertical flip | `0x02` | | Auto-rotate (portrait) | `0x04` | ``` 09 04 00 08 00 02 00 02 FF EE # set feature FF to EE 09 04 00 07 00 01 00 01 FF # read feature FF — value at byte 9 ``` These are applied by the camera to the outgoing stream, so they affect every app at once — unlike a mirror applied in a meeting client's own preview. ### Focus metering — group `0x04`, commands `00 01` / `00 03` | Mode | Value | |---|---| | Center | `0x00` | | Face | `0x01` | | Area | `0x02` | ``` 09 04 00 01 00 05 00 05 MM XX YY 7f 7f # stage 09 04 00 03 00 05 00 05 MM XX YY 7f 7f # commit 09 04 00 02 # read — mode byte 8, x byte 9, y byte 10 ``` *(observed)* Two writes are required. Command `00 01` stages and `00 03` commits; the camera does not act on `00 01` alone. EMEET Studio sends both with an identical payload, and so does the helper. `XX`/`YY` are the target point for `area`, 0..0x7F from the left and top. The trailing `7f 7f` bytes were constant in every capture and are sent verbatim. *(observed)* The x/y bytes **persist after leaving `area` mode** — the camera keeps the last point picked, and sending zeros with a non-area mode does not clear them. There is no known reset. The helper reports them alongside the mode rather than as state of their own, because they only mean anything in `area`. ### Idle shutter timeout — group `0x02` ``` 09 02 01 00 00 04 00 04 SS SS SS SS # set: seconds, 32-bit little-endian 09 02 01 01 # read — seconds at bytes 8..11 ``` Zero disables it. This runs **in the camera**: after N seconds with no stream the lens closes itself, and it stays configured across a reboot or a move to a different host. Nothing on this machine needs to be running for it to work, which is why it is worth exposing at all. ## The camera's own preset slots The camera has three PTZ preset slots of its own — the ones EMEET Studio uses. Three things had to be established before they could be used, and the answers do not point the same way. **Save works, and stores UVC degrees.** *(observed)* ``` 09 03 01 15 00 02 00 02 SS EE # SS = slot 1-3, EE = 01 save, 00 erase 09 03 01 16 00 01 00 01 SS # read — saved flag byte 9, then > 16) & 0x3fff` catches it without hardware, and the tests assert it for all three. Setting a format is deliberately **not** implemented. A format is not a camera setting — it belongs to whoever holds the stream, so writing one here would apply to this process's own capture and vanish when it exits. The useful thing to ship is the list: it tells you what to ask your meeting app for. ## Snapshots `pixy snapshot` writes a full-resolution still, and the whole trick is that it needs no image library. *(observed)* An MJPEG frame off this camera is a complete JPEG file — SOI marker and all — so a 4K still is the mmap'd buffer written to disk verbatim. No encoder, no decoder, nothing outside the standard library. The frame is checked for the `ff d8` SOI before it is written, because a `.jpg` holding something else is worse than no file at all. Warmup frames are discarded first: the frames immediately after `STREAMON` are dark or half-exposed, and unlike a preview a snapshot has no later frame to redeem it. This takes the stream, so it fails with `EBUSY` during a call. That is reported as data rather than worked around — there is no way to grab a frame from a stream someone else holds. ## Permissions UVC controls work as a normal user; `/dev/hidrawN` does not. Without a rule, the mode readout is unavailable while pan/tilt/zoom keep working — which is exactly what the panel reports rather than failing wholesale. ``` # /etc/udev/rules.d/70-emeet-pixy.rules KERNEL=="hidraw*", ATTRS{idVendor}=="328f", ATTRS{idProduct}=="00c0", MODE="0660", TAG+="uaccess" ``` `EACCES` on the HID node is the most likely first-run failure, so the helper's error text names the udev rule directly instead of saying "permission denied". ## If the firmware changes The parts most likely to break, roughly in order: the `0x03` ambiguity and the idle camera refusing mode writes (both read like bugs and may get fixed — and if they are, `Model.modeWritable` starts dimming chips that would now work, so it is the first thing to revisit), the inert preset-load command `0x18` (if a firmware update makes it work, the synthesized UVC load is still the better path, because it keeps the position readback honest), the dropped first write after idle, the privacy → tracking transition being ignored, and the doubled length byte. The command groups, the mode values, and the report id are the parts that feel structural. The capture path is the least likely to move, because none of it is EMEET's — the struct layouts belong to the kernel's UAPI and the ioctl numbers are stable by contract. The one device-specific assumption there is that a YUYV mode exists at all; `_set_format` checks the pixel format it got back and fails loudly rather than rendering whatever the driver substituted. `scripts/pixy info` dumps the node paths, device identity, and the ranges the driver reports, which is the right first command when something stops working. `tests/test_pixy.py` encodes every claim above that can be checked without hardware, so a change in behavior shows up as a named failing test rather than a mysteriously inert camera.