From 2186fb68ece9e46ced9a3152dcbb68187e3af16b Mon Sep 17 00:00:00 2001
From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:25:24 -0700
Subject: [PATCH 1/3] Add Sphinx documentation with pull request preview
deployments
---
.github/workflows/docs.yml | 100 +++++++++++++++++++++
.gitignore | 1 +
README.md | 7 ++
_config.yml | 2 -
docs/architecture/index.md | 69 ++++++++++++++
docs/commands/async-messages.md | 24 +++++
docs/commands/base.md | 40 +++++++++
docs/commands/controller.md | 38 ++++++++
docs/commands/index.md | 74 +++++++++++++++
docs/conf.py | 56 ++++++++++++
docs/configuration/core.md | 80 +++++++++++++++++
docs/configuration/exposure-time.md | 26 ++++++
docs/configuration/frame-outputs.md | 61 +++++++++++++
docs/configuration/index.md | 58 ++++++++++++
docs/development/index.md | 72 +++++++++++++++
docs/emulator/index.md | 44 +++++++++
docs/fits/index.md | 49 ++++++++++
docs/getting-started/index.md | 96 ++++++++++++++++++++
docs/index.md | 85 ++++++++++++++++++
docs/instruments/cryoscope.md | 19 ++++
docs/instruments/hispec-tracking-camera.md | 62 +++++++++++++
docs/instruments/index.md | 43 +++++++++
docs/python/index.md | 92 +++++++++++++++++++
docs/requirements.txt | 5 ++
24 files changed, 1201 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/docs.yml
delete mode 100644 _config.yml
create mode 100644 docs/architecture/index.md
create mode 100644 docs/commands/async-messages.md
create mode 100644 docs/commands/base.md
create mode 100644 docs/commands/controller.md
create mode 100644 docs/commands/index.md
create mode 100644 docs/conf.py
create mode 100644 docs/configuration/core.md
create mode 100644 docs/configuration/exposure-time.md
create mode 100644 docs/configuration/frame-outputs.md
create mode 100644 docs/configuration/index.md
create mode 100644 docs/development/index.md
create mode 100644 docs/emulator/index.md
create mode 100644 docs/fits/index.md
create mode 100644 docs/getting-started/index.md
create mode 100644 docs/index.md
create mode 100644 docs/instruments/cryoscope.md
create mode 100644 docs/instruments/hispec-tracking-camera.md
create mode 100644 docs/instruments/index.md
create mode 100644 docs/python/index.md
create mode 100644 docs/requirements.txt
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..f989f51
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,100 @@
+name: Documentation
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ types: [opened, synchronize, reopened, closed]
+ workflow_dispatch:
+
+# Serialize per ref so a rapid second push cannot race the first one's gh-pages commit
+concurrency:
+ group: docs-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ build:
+ if: github.event.action != 'closed'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install dependencies
+ run: |
+ bash .github/workflows/install-deps.sh
+ sudo apt-get install -y python3-dev
+
+ - name: Build and install zmqpp from source
+ run: |
+ git clone --depth 1 https://github.com/zeromq/zmqpp.git /tmp/zmqpp
+ cmake -S /tmp/zmqpp -B /tmp/zmqpp/build
+ cmake --build /tmp/zmqpp/build -j"$(nproc)"
+ sudo cmake --install /tmp/zmqpp/build
+ sudo ldconfig
+
+ # autodoc imports the compiled module, so the Python API page cannot drift from the bindings
+ - name: Install camera_interface
+ run: |
+ pip install . \
+ --config-settings=cmake.define.INSTRUMENT=hispec_tracking_camera
+
+ - name: Install documentation toolchain
+ run: pip install -r docs/requirements.txt
+
+ - name: Build documentation
+ run: |
+ python -c "import camera_interface; print(camera_interface.instrument_name())"
+ sphinx-build -W -b html docs docs/_build/html
+ touch docs/_build/html/.nojekyll
+
+ - name: Upload rendered site
+ uses: actions/upload-artifact@v4
+ with:
+ name: documentation
+ path: docs/_build/html
+ retention-days: 14
+
+ - name: Publish to the site root
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: docs/_build/html
+ # Preserve previews/, which lives on the same branch
+ keep_files: true
+
+ # Fork pull requests get a read-only token and cannot deploy; they use the artifact instead
+ - name: Publish pull request preview
+ if: >
+ github.event_name == 'pull_request' &&
+ github.event.pull_request.head.repo.full_name == github.repository
+ uses: rossjrw/pr-preview-action@v1
+ with:
+ source-dir: docs/_build/html
+ umbrella-dir: previews
+ action: deploy
+
+ remove-preview:
+ if: github.event.action == 'closed'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: rossjrw/pr-preview-action@v1
+ with:
+ umbrella-dir: previews
+ action: remove
diff --git a/.gitignore b/.gitignore
index 246dfab..af29076 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ cmake-build*
/lib/
/bin/
__pycache__/
+/docs/_build/
diff --git a/README.md b/README.md
index 8c6d9d7..2501c5c 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,13 @@
Camera Detector Controller Interface Software
+## Documentation
+
+Full documentation is at
+ , covering the architecture, the
+command and configuration references, the instruments, the emulator and the Python bindings. This
+README stays focused on building and running; the site is the reference.
+
## Reporting Issues
If you encounter any problems or have questions about this project, please open an issue on the [GitHub Issues page](https://github.com/CaltechOpticalObservatories/camera-interface/issues). Your feedback helps us improve the project!
diff --git a/_config.yml b/_config.yml
deleted file mode 100644
index 4f9c932..0000000
--- a/_config.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-theme: jekyll-theme-minimal
-show_downloads: true
diff --git a/docs/architecture/index.md b/docs/architecture/index.md
new file mode 100644
index 0000000..885fc4e
--- /dev/null
+++ b/docs/architecture/index.md
@@ -0,0 +1,69 @@
+# Architecture
+
+:::{note}
+Expands in M3 with the thread model, the frame path and the instrument plugin contract in full. The
+layering below is accurate now.
+:::
+
+The C++ reference here is narrative and links to source rather than reproducing signatures.
+
+## Layers
+
+```
+ client (socket, socksend, or Python module)
+ |
+ Camera::Server ........|...... command dispatch, ports, logging
+ |
+ Camera::Interface .....|...... the camera, as commands
+ | |
+ | +------ Camera::ExposureMode ..... how one exposure runs
+ |
+ Camera::Controller ....|...... the wire to the hardware
+ |
+ Archon (TCP) or ARC (PCIe)
+```
+
+`Camera::Server` ({source}`camerad/camera_server.h`)
+: Owns the ports and the command dispatch loop. Parses a command line, calls the matching
+ `Interface` method, and formats the reply. Knows nothing about detectors.
+
+`Camera::Interface` ({source}`camerad/camera_interface.h`)
+: The abstract camera. Declares one pure virtual per command: `expose`, `exptime`, `bias`, `bin`,
+ `load_firmware`, `power` and the rest. `ArchonInterface` and `AstroCamInterface` implement it for
+ the two controller families, and an instrument module subclasses one of those.
+
+`Camera::Controller`
+: The transport to the hardware, separate from `Interface` so that the command semantics and the
+ wire protocol can vary independently.
+
+`Camera::ExposureMode` ({source}`camerad/exposure_modes.h`)
+: One way of running an exposure, as a producer/consumer pair: an acquisition thread pulls frames
+ from the controller, a processing thread turns them into images. A single `expose` command means
+ different things for a slow CCD readout and a continuously reading H2RG, and this is where that
+ difference lives. `Interface::select_expose_mode()` picks one.
+
+## Instrument modules
+
+An instrument is a separate git repository, checked out as a submodule under
+`camerad/Instruments/` and selected with `-DINSTRUMENT=`. It contributes four things:
+
+1. A `.cmake` fragment setting `INSTRUMENT_SOURCES`, which is the whole of the build
+ integration.
+2. An `Interface` subclass, deriving from `ArchonInterface` or `AstroCamInterface`, overriding what
+ the detector needs and adding instrument commands via `instrument_cmd()` and
+ `is_instrument_command()`.
+3. Its own `ExposureMode` implementations, if the stock ones do not fit.
+4. Optionally a FITS header dictionary and shipped `.cfg` and `.acf` files.
+
+An interface factory function ties it together, so the core builds against the base class and never
+names a concrete instrument.
+
+See [instruments](../instruments/index.md) for the four that exist, and
+{source}`camerad/Instruments/hispec_tracking_camera` for the most complete example.
+
+## Frame path
+
+Acquisition is deliberately decoupled from output. The exposure mode's processing thread publishes a
+completed frame to every configured [frame output](../configuration/frame-outputs.md); each output
+owns its own queue and thread. Nothing an output does can block acquisition, which is why the FITS
+writer drops frames instead of applying backpressure.
diff --git a/docs/commands/async-messages.md b/docs/commands/async-messages.md
new file mode 100644
index 0000000..ec0da06
--- /dev/null
+++ b/docs/commands/async-messages.md
@@ -0,0 +1,24 @@
+# Asynchronous messages
+
+Messages the server multicasts to `ASYNCGROUP` on `ASYNCPORT`, unprompted. Each is prefixed with a
+tag naming its type, so a listener can filter without parsing the payload.
+
+:::{note}
+The tag list below comes from the superseded 2022 ICD and is being re-verified against the source in
+M2. Treat it as indicative until then.
+:::
+
+| Tag | Meaning |
+|---|---|
+| `ERROR:message` | An error occurred |
+| `NOTICE:message` | Informational notice |
+| `EXPOSURE:n` | Exposure progress, Archon only |
+| `EXPOSURE_d:n` | Exposure progress for device `d`, ARC only |
+| `LINECOUNT:n` | Lines read out so far, Archon only |
+| `PIXELCOUNT:n` | Pixels read out so far, ARC only |
+| `FILE: COMPLETE` | A FITS file finished writing |
+| `DATACUBE:n COMPLETE` or `ERROR` | A data cube finished |
+
+A listener wanting to wake on every frame should attach to the
+[shared-memory output](../configuration/frame-outputs.md) instead, which posts a semaphore per
+frame. The asynchronous port is for status, and is not a delivery guarantee.
diff --git a/docs/commands/base.md b/docs/commands/base.md
new file mode 100644
index 0000000..b33ae12
--- /dev/null
+++ b/docs/commands/base.md
@@ -0,0 +1,40 @@
+# Base commands
+
+Commands the server accepts regardless of which instrument it was built for. Availability still
+depends on the controller: some are Archon-only, some ARC-only.
+
+:::{note}
+This page currently lists the command set grouped by purpose. M2 of the documentation work replaces
+it with a table generated from `CAMERAD_SYNTAX` in {source}`common/camerad_commands.h`, carrying the
+full argument syntax and a description per command.
+:::
+
+Any command accepts `?` as its argument to return its own syntax.
+
+## Connection and firmware
+
+`open`, `close`, `isopen`, `load`, `loadtiming`, `readacf`, `power`, `interface`, `config`
+
+## Exposure
+
+`expose`, `abort`, `stop`, `pause`, `resume`, `exptime`, `modexptime`, `exposuremode`,
+`preexposures`, `shutter`, `readout`, `useframes`
+
+## Geometry and readout
+
+`geometry`, `imsize`, `buffer`, `bin`, `boi`, `bias`, `frametransfer`, `mode`
+
+## Output and FITS
+
+`imdir`, `autodir`, `basename`, `imnum`, `fitsname`, `fitsnaming`, `datacube`, `mex`, `mexamps`,
+`key`, `writekeys`
+
+## Controller access
+
+`native`, `raw`, `heater`, `sensor`
+
+## Diagnostics
+
+`echo`, `test`, `longerror`, `exit`
+
+Instrument modules add their own commands on top of these; see [instruments](../instruments/index.md).
diff --git a/docs/commands/controller.md b/docs/commands/controller.md
new file mode 100644
index 0000000..7a51c96
--- /dev/null
+++ b/docs/commands/controller.md
@@ -0,0 +1,38 @@
+# Controller commands
+
+Commands passed through to the detector controller largely untouched. The server does not interpret
+them, so this is the escape hatch for anything the higher-level commands do not cover.
+
+:::{note}
+Fills out in M3 alongside the architecture chapter.
+:::
+
+## Archon
+
+`native` sends an Archon command directly and returns its reply. The commands most worth knowing:
+
+`FRAME`
+: Frame buffer status: which buffer is complete, frame numbers, timestamps, sizes.
+
+`STATUS`
+: Backplane status, including module temperatures, voltages and currents.
+
+`SYSTEM`
+: Module complement: what is in each slot, with type, revision and version.
+
+`raw` reaches the Archon configuration memory directly, to read the loaded configuration or set keys
+in it.
+
+## ARC (AstroCam)
+
+ARC controllers take three-letter DSP commands. The ones the server exposes:
+
+| Command | Meaning |
+|---|---|
+| `PON` | Power on |
+| `POF` | Power off |
+| `RDM` | Read memory |
+| `WRM` | Write memory |
+| `SBN` | Set bias number |
+| `SMX` | Set multiplexer |
+| `TDL` | Test data link |
diff --git a/docs/commands/index.md b/docs/commands/index.md
new file mode 100644
index 0000000..0a2bd3f
--- /dev/null
+++ b/docs/commands/index.md
@@ -0,0 +1,74 @@
+# Command reference
+
+The server speaks a line-oriented ASCII protocol over TCP. Commands are short mnemonics with
+space-separated arguments; replies are plain text.
+
+## Ports
+
+Three ports are configured in the `.cfg` file, and they behave differently.
+
+Blocking port (`BLKPORT`)
+: The connection stays open for as long as the client holds it, so it works directly with `telnet`
+ as an ad hoc command line. One command at a time: a command sent before the previous one has
+ replied is ignored. The reply on the same connection is what signals completion. Use this when the
+ order of execution matters.
+
+Non-blocking port (`NBPORT`)
+: Accepts one command, then closes the connection. Each connection is handled on its own thread, so
+ commands can run concurrently. Their relative order is not guaranteed, which is the trade for the
+ concurrency.
+
+Asynchronous message port (`ASYNCPORT`)
+: Connectionless UDP, multicast to `ASYNCGROUP`. Listen-only. Carries status the server emits on its
+ own schedule, such as exposure progress, along with replies to non-blocking commands and messages
+ too long for a command reply. Each message is prefixed with a tag naming its type.
+
+Connections to the non-blocking port that sit idle are closed after 3 seconds
+({source}`utils/network.h`), so a client that opens a connection and never sends anything cannot
+accumulate threads.
+
+The server serializes access to hardware that cannot tolerate concurrent use, whichever port the
+commands arrive on.
+
+## Replies
+
+A reply is the command's return value, if any, followed by `DONE` or `ERROR`:
+
+```
+exptime 1.5
+1.500 DONE
+
+expose
+DONE
+
+bogus
+ERROR
+```
+
+`ERROR` means the server rejected or failed the command. With `LONGERROR=true` the reply carries a
+human-readable reason as well.
+
+Two commands break the pattern: a command invoked with `?` returns its syntax with no `DONE`
+suffix, and commands that reply with JSON return the JSON document alone.
+
+:::{warning}
+`DONE` reports on the command, not on the frame outputs. `expose` returns `DONE` once the exposure
+and readout complete, whether or not the FITS writer kept up. See
+[frame outputs](../configuration/frame-outputs.md).
+:::
+
+## Command tables
+
+```{toctree}
+:maxdepth: 1
+
+base
+controller
+async-messages
+```
+
+:::{note}
+The base command table is generated from `CAMERAD_SYNTAX` in
+{source}`common/camerad_commands.h` and cross-checked against the descriptions kept alongside the
+docs, so a command added to the server without a description here fails the documentation build.
+:::
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..cfecdfd
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,56 @@
+"""Sphinx configuration for the camera-interface documentation."""
+
+import importlib.util
+import tomllib
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+project = "camera-interface"
+author = "Caltech Optical Observatories"
+copyright = "Caltech Optical Observatories"
+
+with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject:
+ release = tomllib.load(pyproject)["project"]["version"]
+version = release
+
+extensions = [
+ "myst_parser",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.extlinks",
+ "sphinx_copybutton",
+ "sphinx_design",
+]
+
+exclude_patterns = ["_build"]
+
+# Link to a source file with {source}`camerad/camera_interface.h` instead of pasting signatures,
+# since the C++ reference is narrative rather than generated
+extlinks = {
+ "source": (
+ "https://github.com/CaltechOpticalObservatories/camera-interface/blob/main/%s",
+ "%s",
+ ),
+}
+
+myst_enable_extensions = ["colon_fence", "deflist", "substitution"]
+myst_heading_anchors = 3
+
+# The Python module is a compiled extension, so autodoc needs a real build to import. CI always has
+# one, and a genuine import failure there must break the build; a local docs build without one still
+# succeeds and shows a note in place of the API reference.
+if importlib.util.find_spec("camera_interface"):
+ tags.add("has_python_module") # noqa: F821 (Sphinx injects `tags` into this namespace)
+else:
+ suppress_warnings = ["autodoc"]
+
+autodoc_member_order = "bysource"
+autodoc_default_options = {"members": True}
+
+html_theme = "furo"
+html_title = f"camera-interface {release}"
+html_theme_options = {
+ "source_repository": "https://github.com/CaltechOpticalObservatories/camera-interface/",
+ "source_branch": "main",
+ "source_directory": "docs/",
+}
diff --git a/docs/configuration/core.md b/docs/configuration/core.md
new file mode 100644
index 0000000..0a229a4
--- /dev/null
+++ b/docs/configuration/core.md
@@ -0,0 +1,80 @@
+# Core keys
+
+Keys read by the server itself, independent of instrument or frame output.
+
+:::{note}
+This table is written by hand today. M2 of the documentation work replaces it with a generated table
+cross-checked against the keys the source actually honours.
+:::
+
+## Controller connection
+
+| Key | Meaning |
+|---|---|
+| `ARCHON_IP` | IP address of the Archon controller. Point at the emulator to run without hardware. |
+| `ARCHON_PORT` | Port of the Archon controller |
+| `DEFAULT_FIRMWARE` | Firmware loaded by `load` with no argument. Indexed array for multi-controller ARC systems. |
+| `INSTRUMENT` | Instrument name, checked against the instrument the binary was built for |
+
+## Archon parameters
+
+The server drives an Archon by writing named parameters defined in the ACF. These keys tell it which
+names to use, so the same binary works with differently authored timing scripts.
+
+| Key | Meaning |
+|---|---|
+| `EXPOSE_PARAM` | Parameter written to trigger an exposure |
+| `START_PARAM` | Parameter written to start the timing script |
+| `ABORT_PARAM` | Parameter written to abort an exposure |
+| `EXPTIME_MSEC_PARAM` | Parameter holding the milliseconds part of the exposure time. Required. |
+| `EXPTIME_SEC_PARAM` | Parameter holding the whole-seconds part. Optional, but see [exposure time](exposure-time.md) for the limit without it. |
+
+## Ports
+
+| Key | Meaning |
+|---|---|
+| `BLKPORT` | Blocking command port. One command at a time, connection stays open. |
+| `NBPORT` | Non-blocking command port. One command per connection, then closed. |
+| `ASYNCPORT` | UDP port for asynchronous status messages |
+| `ASYNCGROUP` | Multicast group the asynchronous messages are sent to |
+
+See [the command protocol](../commands/index.md) for how the ports differ in behaviour.
+
+## Files and logging
+
+| Key | Meaning |
+|---|---|
+| `IMDIR` | Base directory for image files |
+| `AUTODIR` | `yes` to write into a `YYYYMMDD` subdirectory of `IMDIR` |
+| `BASENAME` | Base filename for images |
+| `DIRMODE` | Permissions for directories the server creates |
+| `LOGPATH` | Directory for the daily log file |
+| `LOG_STDERR` | Also write the log to stderr, overriding what `--foreground` implies |
+| `TM_ZONE_LOG` | `UTC` or `local`, for log entry timestamps only |
+| `TM_ZONE` | `UTC` or `local`, for everything else including FITS times and `AUTODIR` |
+| `TZ_ENV` | POSIX `TZ` string used when a zone is set to `local` |
+
+:::{tip}
+`TM_ZONE=local` is useful in the lab, where a UTC date rollover in the middle of a working day splits
+one session across two `AUTODIR` directories. The time zone is recorded in the FITS header either way.
+:::
+
+## Behaviour
+
+| Key | Meaning |
+|---|---|
+| `DAEMON` | `yes` or `no`. The `--foreground` command line option overrides it. |
+| `LONGERROR` | `true` to return long error messages on the command port |
+| `LONGEXPOSURE` | Unit for bare `exptime` arguments. See [exposure time](exposure-time.md). |
+| `READOUT_TIME` | Expected readout time in msec, used to time out a readout that never completes |
+| `HEATER_TARGET_MIN` | Lower bound for heater targets, overriding the backplane default |
+| `HEATER_TARGET_MAX` | Upper bound for heater targets, overriding the backplane default |
+
+## Emulator
+
+Read by `camerad-emulator`, not by the server, but conventionally kept in the same file.
+
+| Key | Meaning |
+|---|---|
+| `EMULATOR_PORT` | Port the emulator listens on. `ARCHON_PORT` points here to run without hardware. |
+| `EMULATOR_SYSTEM` | Path to the `.system` file describing the emulated module complement |
diff --git a/docs/configuration/exposure-time.md b/docs/configuration/exposure-time.md
new file mode 100644
index 0000000..a3036fc
--- /dev/null
+++ b/docs/configuration/exposure-time.md
@@ -0,0 +1,26 @@
+# Exposure time
+
+```
+exptime [ [ s | ms ] ]
+```
+
+Sets the exposure time, or reports it when given no argument.
+
+A bare argument is interpreted in whatever unit `LONGEXPOSURE` selects, and the reported value uses
+that same unit. An explicit `s` or `ms` suffix overrides it for that one command, so `exptime 500 ms`
+means the same thing whichever way the instrument is configured.
+
+| Key | Default | Meaning |
+|---|---|---|
+| `LONGEXPOSURE` | `true` | `true`: bare `exptime` arguments are seconds. `false`: milliseconds. |
+| `EXPTIME_MSEC_PARAM` | none | Archon parameter holding the milliseconds part. Required. |
+| `EXPTIME_SEC_PARAM` | none | Archon parameter holding the whole-seconds part. Optional. |
+
+Internally the exposure time is always held in seconds, which is also the unit of the `EXPTIME` FITS
+keyword, so the configured unit never changes what ends up archived.
+
+:::{warning}
+Archon parameters are 20 bits. Without `EXPTIME_SEC_PARAM` the whole exposure time has to fit in the
+milliseconds parameter, capping it at 2^20 msec, about 1048 seconds. Beyond that `exptime` returns an
+error rather than silently leaving the controller and the FITS header disagreeing.
+:::
diff --git a/docs/configuration/frame-outputs.md b/docs/configuration/frame-outputs.md
new file mode 100644
index 0000000..bfcaeca
--- /dev/null
+++ b/docs/configuration/frame-outputs.md
@@ -0,0 +1,61 @@
+# Frame output keys
+
+Every instrument publishes each acquired frame to zero or more outputs, built at startup from these
+keys. The two outputs are independent: either, both or neither can be enabled.
+
+## FITS
+
+Writes one FITS file per frame. A queue and a dedicated writer thread keep the readout thread from
+ever blocking on disk.
+
+| Key | Default | Meaning |
+|---|---|---|
+| `FITS_ENABLED` | `no` | Enable the FITS writer |
+| `FITS_OUTPUT_DIR` | `/tmp/images` | Base directory. Must already exist. |
+| `FITS_AUTODIR` | `no` | Write into a `YYYYMMDD` subdirectory of `FITS_OUTPUT_DIR` |
+| `FITS_BASENAME` | `tracking` | Base filename |
+| `FITS_QUEUE_SIZE` | `32` | Frames buffered for the writer thread. The oldest is dropped when full. |
+| `FITS_DRAIN_TIMEOUT_MS` | `5000` | How long to keep draining the queue at shutdown before giving up |
+
+:::{warning}
+Dropping frames is the designed behaviour, not a failure mode: disk is slower than acquisition can
+be, and stalling acquisition to wait for a write would be worse. If you need every frame, size
+`FITS_QUEUE_SIZE` for the burst and watch the dropped count in `output_status()`.
+:::
+
+## Shared memory
+
+Publishes each frame as an [ImageStreamIO](https://github.com/milk-org/ImageStreamIO) stream, which
+AO frameworks such as [cacao](https://github.com/cacao-org/cacao) read directly. Needs a build with
+`-DENABLE_SHM_OUTPUT=ON`.
+
+| Key | Default | Meaning |
+|---|---|---|
+| `SHM_ENABLED` | `no` | Enable the shared-memory writer |
+| `SHM_SEGMENT_NAME` | `camera` | ImageStreamIO stream name |
+| `SHM_RING_BUFFER_SIZE` | `4` | Depth of the internal history ring buffer (`CBsize`). Separate from the live frame a real-time reader sees. |
+| `SHM_DIR` | unset | Directory ImageStreamIO writes into. Unset falls back to `MILK_SHM_DIR`, then `/milk/shm`. If set, it must exist and be writable. |
+
+Frame geometry is deliberately not a key. An ImageStreamIO stream's geometry is fixed for its
+lifetime, so the writer recreates the stream whenever the geometry changes from what is allocated.
+
+Unlike the FITS writer, this output posts a semaphore per frame, so it is the right attachment point
+for anything that has to wake up on every frame.
+
+### Readers
+
+`camerad-shm-reader` prints geometry, keywords and pixel statistics once, for diagnostics:
+
+```bash
+camerad-shm-reader [shm-dir]
+```
+
+`python/examples/shm_read_frames.py` is a streaming consumer that blocks on the semaphore and flags
+frames it missed:
+
+```bash
+python python/examples/shm_read_frames.py --segment hispec_tracking_camera --count 10
+```
+
+It needs numpy and `ImageStreamIOWrap`, the Python wrapper from the ImageStreamIO source tree built
+with `-DPYTHON_WRAPPER=ON`. The wrapper is not on PyPI.
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
new file mode 100644
index 0000000..626dd22
--- /dev/null
+++ b/docs/configuration/index.md
@@ -0,0 +1,58 @@
+# Configuration reference
+
+`camerad` reads a single plain-text configuration file, named with `--config`. By convention it ends
+in `.cfg`, but nothing enforces that.
+
+## File format
+
+One key per line, with an optional trailing comment:
+
+```
+KEY=VALUE # optional comment
+```
+
+Keys that take several values are written as an indexed array, repeating the key:
+
+```
+DEFAULT_FIRMWARE=(0 /home/dsp/E2V4240/tim.lod)
+DEFAULT_FIRMWARE=(1 /home/dsp/E2V4240/tim.lod)
+```
+
+Anything after `#` is ignored.
+
+When the server runs as a daemon, the file is re-read on `SIGHUP`. Not every key takes effect on
+reload: some are read once at startup, and some are only defaults that a command can override at
+runtime. The key tables note which is which.
+
+## Key tables
+
+```{toctree}
+:maxdepth: 1
+
+core
+frame-outputs
+exposure-time
+```
+
+:::{note}
+The key tables are generated from the source at documentation build time and cross-checked against
+the keys `camerad` actually honours, so a key added to the code without a description here fails the
+build. See [development](../development/index.md).
+:::
+
+## Build options
+
+These are CMake options, fixed when the software is compiled, not `.cfg` keys.
+
+| Option | Default | Meaning |
+|---|---|---|
+| `CONTROLLER` | none, required | `archon` or `astrocam`. CMake errors out if unset. |
+| `INSTRUMENT` | none | Instrument module to build, from `camerad/Instruments/` |
+| `INTERFACE_TYPE` | `Archon` | Selects which emulator is built, independently of `CONTROLLER`. `AstroCam` builds none, since none exists for ARC. |
+| `ENABLE_SHM_OUTPUT` | `OFF` | Build the ImageStreamIO shared-memory output. Also needs `-DImageStreamIO_DIR=/lib/cmake`. |
+| `BUILD_PYTHON_MODULE` | `OFF` | Build the `camera_interface` Python module. Needs pybind11 at compile time only. |
+| `CMAKE_INSTALL_PREFIX` | `/usr/local` | Where `make install` puts binaries and the module |
+
+`ENABLE_SHM_OUTPUT` and `SHM_ENABLED` are separate gates: a `.cfg` that sets `SHM_ENABLED=yes` on a
+build compiled without `ENABLE_SHM_OUTPUT` logs a warning and carries on without shared memory,
+rather than failing to start.
diff --git a/docs/development/index.md b/docs/development/index.md
new file mode 100644
index 0000000..5a49f73
--- /dev/null
+++ b/docs/development/index.md
@@ -0,0 +1,72 @@
+# Development
+
+## Building the documentation
+
+```bash
+pip install -r docs/requirements.txt
+sphinx-build -W -b html docs docs/_build/html
+```
+
+`-W` turns warnings into errors, which is what CI uses, so a broken cross-reference fails the build
+rather than shipping a dead link.
+
+The Python API page needs the compiled `camera_interface` module to be importable. Without it the
+build still succeeds and the page shows a note in place of the reference, so a docs-only change does
+not require a full C++ build locally.
+
+## How the docs stay current
+
+Reference tables that restate something the source already knows are generated at documentation
+build time and cross-checked against the source, so the build fails when they diverge:
+
+| Table | Source of truth |
+|---|---|
+| Base commands | `CAMERAD_SYNTAX` in {source}`common/camerad_commands.h` |
+| Configuration keys | The key comparisons in `camerad`, `common`, `utils` and `emulator` |
+| ATC FITS keywords | The `HeaderDictEntry` table in {source}`camerad/Instruments/hispec_tracking_camera/fits_header_dictionary.cpp` |
+
+Descriptions are written by hand in `docs/data/`, keyed by command or key name. Adding a command or
+a config key to the source without a description there fails the docs build; so does describing one
+that no longer exists.
+
+:::{note}
+The generators arrive in M2. Until then the affected tables are hand-written and marked as such.
+:::
+
+## Documentation layout
+
+```
+docs/
+ conf.py Sphinx configuration
+ requirements.txt pinned docs toolchain
+ data/ hand-written descriptions the generators consume
+ _ext/ generator extensions
+ / one directory per chapter
+```
+
+Pages are Markdown via MyST. Use `{source}`` `path` `` to link to a file in the repository rather
+than pasting signatures into the prose.
+
+## Publishing
+
+`.github/workflows/docs.yml` builds on every pull request and every push to `main`.
+
+- A pull request publishes to `previews/pr-/` on the `gh-pages` branch, and a bot comments the
+ link. The preview is removed when the PR closes.
+- A merge to `main` publishes to the site root.
+- Every build uploads the rendered HTML as a workflow artifact, which is the fallback for pull
+ requests from forks, since those get a read-only token and cannot deploy.
+
+## Testing camerad itself
+
+```bash
+make run_unit_tests && ./bin/run_unit_tests
+```
+
+The end-to-end tests run against the [emulator](../emulator/index.md) in CI.
+
+## Instrument submodules
+
+Instrument modules are pinned submodules. Updating one is a commit to `camera-interface` that moves
+the pin, which `.github/workflows/update-submodules.yml` automates. Documentation for an instrument
+lives here, in this repository, while each instrument repository keeps its own README.
diff --git a/docs/emulator/index.md b/docs/emulator/index.md
new file mode 100644
index 0000000..f44abb7
--- /dev/null
+++ b/docs/emulator/index.md
@@ -0,0 +1,44 @@
+# Emulator
+
+`camerad-emulator` stands in for an Archon controller so the server, an instrument module and a
+client can all be exercised with no hardware. CI uses it for the end-to-end tests.
+
+:::{note}
+Expands in M3 with the emulated command list and the limits of the emulation.
+:::
+
+There is no ARC emulator. `-DINTERFACE_TYPE=AstroCam` therefore builds none, and the default
+`-DINTERFACE_TYPE=Archon` builds this one regardless of which `CONTROLLER` was selected.
+
+## Running
+
+```bash
+camerad-emulator -i
+```
+
+It reads `EMULATOR_PORT` and `EMULATOR_SYSTEM` from the same `.cfg` the server uses, so the only
+thing that makes a config point at the emulator rather than hardware is `ARCHON_IP` and
+`ARCHON_PORT`. `-i generic` suits the shipped test configs.
+
+`EMULATOR_SYSTEM` names a `.system` file describing the module complement to report, which is what
+lets the emulator answer `SYSTEM` convincingly for a given instrument.
+
+## What it models
+
+The emulator answers the Archon command set the server uses: configuration load, parameter writes,
+frame status, and pixel delivery on the timing the exposure implies. Frames carry synthetic pixel
+data, so it validates plumbing, geometry, timing and headers, not image quality.
+
+Sources are in {source}`emulator`, with the per-detector frame sources alongside
+(`generic.h`, `nirc2.h`).
+
+## In CI
+
+Two workflow jobs run against it on every push and pull request:
+
+- A frame outputs test that takes an exposure with both FITS and shared memory enabled, then
+ validates the written header and reads the shared-memory segment back.
+- A Python module test that drives the same exposure through the `camera_interface` bindings.
+
+Both are in `.github/workflows/emulator-integration.yml` and are the closest thing to a
+regression suite for the acquisition path.
diff --git a/docs/fits/index.md b/docs/fits/index.md
new file mode 100644
index 0000000..2b8effd
--- /dev/null
+++ b/docs/fits/index.md
@@ -0,0 +1,49 @@
+# FITS output
+
+:::{note}
+Expands in M2, when the ATC keyword table becomes generated output. Naming, cube layout and the
+system keyword tables are written then.
+:::
+
+`camerad` writes FITS through an asynchronous writer: the readout thread hands a completed frame to
+a queue, and a dedicated thread writes it. Configuration is under
+[frame output keys](../configuration/frame-outputs.md).
+
+## Filenames
+
+`fitsnaming` selects between two schemes:
+
+`time`
+: The filename carries a timestamp, so names never collide and sort chronologically.
+
+`number`
+: The filename carries an incrementing image number, reported and set with `imnum`.
+
+`autodir` adds a `YYYYMMDD` subdirectory under the image directory. Which midnight that rolls over
+on follows `TM_ZONE`.
+
+## Cubes and extensions
+
+`datacube` writes successive frames as planes of one cube rather than separate files. For detectors
+read through several amplifiers, `mexamps` writes each amplifier as its own extension, and `mex`
+controls multi-extension output generally.
+
+## Keywords
+
+Three sources of keywords end up in a header:
+
+1. **System keywords**, written by the server: geometry, timing, exposure and controller state.
+2. **Instrument keywords**, from the instrument module's header dictionary. The ATC dictionary is in
+ {source}`camerad/Instruments/hispec_tracking_camera/fits_header_dictionary.cpp`.
+3. **User keywords**, added at runtime with `key`. `writekeys` controls whether they are written
+ before or after the exposure, which matters for anything whose value changes during it.
+
+## Checking a header
+
+`python/tests/fits_header_check.py` asserts that every keyword the instrument's dictionary promises
+is present and carries the expected value, so a keyword that silently stops being populated fails
+rather than going unnoticed. It needs no FITS library, and both emulator CI jobs run it.
+
+```bash
+python3 python/tests/fits_header_check.py --exptime
+```
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
new file mode 100644
index 0000000..1a7559a
--- /dev/null
+++ b/docs/getting-started/index.md
@@ -0,0 +1,96 @@
+# Getting started
+
+This walks from a clean checkout to a FITS file on disk, with no detector controller attached: the
+Archon emulator stands in for the hardware.
+
+## Dependencies
+
+`camerad` needs a C++20 compiler, CMake 3.12 or newer, and:
+
+- cfitsio and CCfits, for FITS output
+- OpenCV, Boost (thread and chrono), nlohmann-json
+- ZeroMQ and [zmqpp](https://github.com/zeromq/zmqpp), which is not usually packaged and is built from source
+- gtest, to run the unit tests
+- [ImageStreamIO](https://github.com/milk-org/ImageStreamIO), only for the shared-memory output
+
+On Debian or Ubuntu the packaged ones are what CI installs:
+
+```bash
+sudo apt-get install -y build-essential cmake ninja-build \
+ libccfits-dev libcfitsio-dev libcurl4-openssl-dev libgtest-dev \
+ nlohmann-json3-dev libzmq3-dev libopencv-dev \
+ libboost-thread-dev libboost-chrono-dev
+```
+
+## Build
+
+`-DCONTROLLER=` is required; CMake stops with an error without it. `-DINSTRUMENT=` is optional and
+selects an instrument module from `camerad/Instruments/`.
+
+```bash
+git clone --recurse-submodules \
+ https://github.com/CaltechOpticalObservatories/camera-interface.git
+cd camera-interface/build
+cmake -DCONTROLLER=archon -DINSTRUMENT=hispec_tracking_camera ..
+make
+```
+
+Binaries land in `bin/` in the source tree. `make install` copies them under
+`CMAKE_INSTALL_PREFIX` instead, which is the better choice for anything deployed.
+
+The build options are covered in full under [configuration](../configuration/index.md); the ones that
+change what gets built are `ENABLE_SHM_OUTPUT`, `BUILD_PYTHON_MODULE` and `INTERFACE_TYPE`.
+
+:::{note}
+Instrument modules are git submodules. A clone without `--recurse-submodules` leaves
+`camerad/Instruments/` empty and `-DINSTRUMENT=` will fail. Fix it with
+`git submodule update --init --recursive`.
+:::
+
+## Run the emulator
+
+The emulator reads `EMULATOR_PORT` and `EMULATOR_SYSTEM` from the same `.cfg` the server uses, so
+pointing `ARCHON_IP` and `ARCHON_PORT` at it is all that separates a test rig from real hardware. The
+shipped `config/demo/demo.cfg` already does this.
+
+```bash
+bin/camerad-emulator config/demo/demo.cfg -i generic
+```
+
+## Run the server
+
+```bash
+bin/camerad --foreground --config config/demo/demo.cfg
+```
+
+`--config` is required. Without `--foreground` the server daemonizes. Logging always goes to a daily
+file under `LOGPATH`; `--foreground` additionally writes it to stderr.
+
+## Take an exposure
+
+`camerad-socksend` sends one command and prints the reply. Point it at the blocking port from the
+`.cfg` (`BLKPORT`).
+
+```bash
+send() { bin/camerad-socksend -p 3031 -t 60 "$1"; }
+
+send "open" # connect to the controller
+send "load" # load firmware named by DEFAULT_FIRMWARE
+send "power on"
+send "exptime 1.5" # seconds, because this config leaves LONGEXPOSURE at its default
+send "expose 1"
+```
+
+Each returns `DONE` or `ERROR`. The FITS file appears under `IMDIR`, named from `BASENAME`.
+
+:::{warning}
+`DONE` means the server accepted and completed the command, not that every frame output succeeded.
+The FITS writer drops frames by design when the queue backs up, so `DONE` from `expose` is not a
+promise that a file was written. [Frame outputs](../fits/index.md) explains how to check.
+:::
+
+## Next
+
+- Drive the camera from Python instead of a socket: [Python bindings](../python/index.md)
+- Understand what the emulator does and does not model: [Emulator](../emulator/index.md)
+- Configure a real instrument: [Instruments](../instruments/index.md)
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..7f47814
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,85 @@
+# camera-interface
+
+`camerad` is a detector controller server. It owns the connection to a detector controller, exposes
+every detector function as a line-oriented ASCII command over TCP, and writes acquired frames to FITS
+files and to shared memory.
+
+It supports two controller families, selected at build time with `-DCONTROLLER=`:
+
+`archon`
+: STA/Archon controllers, reached over TCP. This is the path most actively developed, and the only
+ one with an emulator.
+
+`astrocam`
+: Astronomical Research Cameras ("Leach") controllers, reached over a PCIe driver. Requires ARC API
+ 3.6 and the Arc66PCIe driver.
+
+A detector-specific **instrument** module is layered on top of the controller, also selected at build
+time, with `-DINSTRUMENT=`. The instrument supplies the exposure modes, extra commands and FITS
+keywords that a particular camera needs.
+
+Clients can drive the server three ways: the text protocol over a socket, the `camerad-socksend`
+command line tool, or the `camera_interface` Python module, which skips the server process entirely
+and owns the camera in-process.
+
+## Start here
+
+::::{grid} 1 1 2 2
+:gutter: 2
+
+:::{grid-item-card} {octicon}`rocket` Getting started
+:link: getting-started/index
+:link-type: doc
+
+Build the software, run the server, and take a first exposure against the emulator.
+:::
+
+:::{grid-item-card} {octicon}`terminal` Command reference
+:link: commands/index
+:link-type: doc
+
+Every command the server accepts, the ports it listens on, and what it returns.
+:::
+
+:::{grid-item-card} {octicon}`gear` Configuration reference
+:link: configuration/index
+:link-type: doc
+
+Every key the `.cfg` file honours, including frame outputs and exposure time.
+:::
+
+:::{grid-item-card} {octicon}`stack` Architecture
+:link: architecture/index
+:link-type: doc
+
+How the server, interface, controller and exposure modes fit together.
+:::
+
+::::
+
+```{toctree}
+:hidden:
+:caption: Using camerad
+
+getting-started/index
+configuration/index
+commands/index
+fits/index
+```
+
+```{toctree}
+:hidden:
+:caption: Reference
+
+architecture/index
+instruments/index
+emulator/index
+python/index
+```
+
+```{toctree}
+:hidden:
+:caption: Contributing
+
+development/index
+```
diff --git a/docs/instruments/cryoscope.md b/docs/instruments/cryoscope.md
new file mode 100644
index 0000000..2fe9de1
--- /dev/null
+++ b/docs/instruments/cryoscope.md
@@ -0,0 +1,19 @@
+# CryoScope
+
+An H2RG on an Archon controller, reading in RXR mode.
+
+Repository: [cryoscope-instrument](https://github.com/CaltechOpticalObservatories/cryoscope-instrument),
+checked out at `camerad/Instruments/cryoscope`.
+
+:::{note}
+Expands in M4. The module defines a `CryoScope` interface deriving from `ArchonInterface`, with its
+own exposure modes, and registers no additional instrument commands beyond the base set.
+:::
+
+## Build
+
+```bash
+cd build
+cmake -DCONTROLLER=archon -DINSTRUMENT=cryoscope ..
+make
+```
diff --git a/docs/instruments/hispec-tracking-camera.md b/docs/instruments/hispec-tracking-camera.md
new file mode 100644
index 0000000..28b7af5
--- /dev/null
+++ b/docs/instruments/hispec-tracking-camera.md
@@ -0,0 +1,62 @@
+# HISPEC tracking camera
+
+The HISPEC acquisition and tracking camera (ATC): an H2RG on an Archon controller. This is the most
+complete instrument module and the one the emulator integration tests exercise.
+
+Repository: [hispec-tracking-camera-instrument](https://github.com/CaltechOpticalObservatories/hispec-tracking-camera-instrument),
+checked out at `camerad/Instruments/hispec_tracking_camera`.
+
+:::{note}
+Expands in M4 with the readout and operational modes, the ROI and guiding geometry rules, and the
+full keyword table. What is here is verified against the current submodule.
+:::
+
+## Build
+
+```bash
+cd build
+cmake -DCONTROLLER=archon -DINSTRUMENT=hispec_tracking_camera ..
+make
+```
+
+Shipped configuration is in the submodule's `config/`: `hispecatc.cfg` and `hispecatc.acf`.
+
+## Instrument commands
+
+These are reached through the normal command interface, and from Python via `instrument_cmd()`.
+
+| Command | Purpose |
+|---|---|
+| `h2rg_init` | Initialize the H2RG |
+| `mode` | Select the readout mode |
+| `exposure` | Select the exposure mode |
+| `autofetch_mode` | Control autofetch, where the controller pushes frames continuously |
+| `freerun` | Continuous acquisition |
+| `window_mode` | Windowed readout |
+| `roi` | Set the region of interest |
+| `take_stats` | Report pixel statistics |
+| `debug` | Development diagnostics |
+
+`instrument_commands()` enumerates them at runtime, which is the authoritative list for a given
+build.
+
+## Readout modes
+
+`mode` selects among the H2RG readout schemes, each backed by an ACF timing mode:
+
+| Mode | ACF timing mode |
+|---|---|
+| `utr_rr` | `mode_UTR_RR`, up-the-ramp, reset-read |
+| `utr_gr` | `mode_UTR_GR`, up-the-ramp, guided read |
+| `rx` | `mode_RX`, reset-execute |
+| `rxr` | `mode_RXR`, reset-execute-read |
+
+## FITS keywords
+
+The module carries its own keyword dictionary
+({source}`camerad/Instruments/hispec_tracking_camera/fits_header_dictionary.cpp`) mapping each
+internal property to a keyword, comment, type and default. It covers two cameras, ATC and SPEC, with
+separate defaults.
+
+The generated keyword table lands here in M2. `python/tests/fits_header_check.py` validates a
+written file against this dictionary.
diff --git a/docs/instruments/index.md b/docs/instruments/index.md
new file mode 100644
index 0000000..ab426ab
--- /dev/null
+++ b/docs/instruments/index.md
@@ -0,0 +1,43 @@
+# Instruments
+
+An instrument module adapts the core to one detector: its exposure modes, its extra commands, its
+FITS keywords. One is chosen at build time with `-DINSTRUMENT=`, and a given binary serves exactly
+one instrument.
+
+Each lives in its own repository, pulled in as a submodule under `camerad/Instruments/`. The
+submodule is pinned, so a given `camera-interface` commit builds one specific instrument revision.
+
+## Status
+
+| Instrument | Controller | Detector | State |
+|---|---|---|---|
+| [hispec_tracking_camera](hispec-tracking-camera.md) | Archon | H2RG | In active use. The reference implementation. |
+| [cryoscope](cryoscope.md) | Archon | H2RG | Implemented, RXR mode |
+| `hispec` | Archon | | Repository exists, no sources yet |
+| `deimos` | | | Repository exists, no sources yet |
+
+:::{note}
+`hispec` and `deimos` are currently README-only submodules. They are listed so the set is not
+misleading about what exists; there is nothing to document until they carry sources.
+:::
+
+```{toctree}
+:hidden:
+
+hispec-tracking-camera
+cryoscope
+```
+
+## Building one
+
+```bash
+git submodule update --init camerad/Instruments/
+cd build
+cmake -DCONTROLLER=archon -DINSTRUMENT= ..
+make
+```
+
+The instrument name is also checked against the `INSTRUMENT` key in the `.cfg`, so a config cannot
+be pointed at a binary built for a different camera.
+
+For how a module plugs into the core, see [architecture](../architecture/index.md).
diff --git a/docs/python/index.md b/docs/python/index.md
new file mode 100644
index 0000000..7fcd7a0
--- /dev/null
+++ b/docs/python/index.md
@@ -0,0 +1,92 @@
+# Python bindings
+
+`camera_interface` lets a Python process own a camera directly: no `camerad` process, no socket, no
+text protocol. It performs the same startup the server does, then exposes the command set as
+methods.
+
+```python
+import camera_interface
+
+camera = camera_interface.Camera("hispecatc.cfg")
+camera.open()
+camera.load()
+camera.power("on")
+camera.exptime("1.5")
+camera.expose("1")
+```
+
+## Installing
+
+`pip install` compiles `camerad` and the module and puts both in the environment, so `import
+camera_interface` needs no `PYTHONPATH` and `camerad` is on `PATH`:
+
+```bash
+pip install ./camera-interface \
+ --config-settings=cmake.define.INSTRUMENT=hispec_tracking_camera
+```
+
+Any CMake option can be passed the same way. `CONTROLLER` defaults to `archon` and
+`BUILD_PYTHON_MODULE` is forced on. pybind11 comes from the build requirements into an isolated
+build environment, and is never installed into the target environment.
+
+A compiler and the full C++ dependency set must be present wherever `pip install` runs.
+
+:::{important}
+The controller and instrument are fixed when the wheel is built, and the module is always named
+`camera_interface`, so one environment holds one instrument. Install into a separate environment per
+instrument, and have the caller assert which one it got:
+
+```python
+assert camera_interface.instrument_name() == "hispec_tracking_camera"
+```
+:::
+
+Alternatively, `cmake -DBUILD_PYTHON_MODULE=ON` builds it in the source tree, where importing it
+means putting the build's `lib` directory on `PYTHONPATH`.
+
+## Behaviour worth knowing
+
+Commands release the GIL while they run, so a blocking `expose()` leaves the rest of the process
+responsive. For a daemon, that is the difference between one exposure and its whole RPC loop
+stalling.
+
+A failed command raises `RuntimeError`.
+
+Every command `camerad` accepts is reachable. Base commands are bound as methods; instrument
+commands go through `instrument_cmd()`, enumerable with `instrument_commands()`; controller
+commands such as `mode`, `raw`, `readacf`, `loadtiming`, `heater` and `sensor` go through
+`controller_cmd()`. Only `exit` is missing, since the process belongs to the caller.
+
+Logging follows `LOG_STDERR` from the `.cfg`, overridable per session with `log_to_stderr=`. The C++
+log always goes to its daily file under `LOGPATH`.
+
+:::{warning}
+`output_status()` is a snapshot, never a barrier. The FITS writer queues and drops frames by design,
+and nothing in this API lets a caller stall acquisition by waiting on an output. Anything that must
+be woken per frame should attach to the
+[shared-memory segment](../configuration/frame-outputs.md), which posts semaphores.
+:::
+
+## API
+
+```{eval-rst}
+.. only:: has_python_module
+
+ .. automodule:: camera_interface
+ :members:
+ :undoc-members:
+ :member-order: bysource
+
+.. only:: not has_python_module
+
+ .. note::
+
+ The API reference is generated from the compiled module, which was not importable when these
+ docs were built. Build with ``-DBUILD_PYTHON_MODULE=ON`` or ``pip install .`` and rebuild the
+ docs to see it. The published documentation always includes it.
+```
+
+## Examples
+
+`python/examples/shm_read_frames.py` is a streaming shared-memory consumer; see
+[frame outputs](../configuration/frame-outputs.md).
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..a9787d3
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,5 @@
+sphinx==8.1.3
+myst-parser==4.0.0
+furo==2024.8.6
+sphinx-copybutton==0.5.2
+sphinx-design==0.6.1
From 21171c4bb36d96c6259fb26596f216caa5e1675e Mon Sep 17 00:00:00 2001
From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com>
Date: Wed, 23 Sep 2026 10:25:30 -0700
Subject: [PATCH 2/3] Document the gh-pages root .nojekyll requirement
---
docs/development/index.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/docs/development/index.md b/docs/development/index.md
index 5a49f73..a5d9ee0 100644
--- a/docs/development/index.md
+++ b/docs/development/index.md
@@ -57,6 +57,13 @@ than pasting signatures into the prose.
- Every build uploads the rendered HTML as a workflow artifact, which is the fallback for pull
requests from forks, since those get a read-only token and cannot deploy.
+:::{important}
+The `gh-pages` branch needs a `.nojekyll` file at its root. Without it Pages runs the output through
+Jekyll, which skips directories beginning with an underscore, and the whole site loads with no CSS
+because `_static/` returns 404. A marker inside a preview subdirectory is not enough; it has to be at
+the branch root. Recreating `gh-pages` from scratch means adding it again.
+:::
+
## Testing camerad itself
```bash
From 29dc901ea452637ddb6a5d9d70b6cf8a004008bf Mon Sep 17 00:00:00 2001
From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com>
Date: Wed, 23 Sep 2026 13:16:07 -0700
Subject: [PATCH 3/3] Generate the command, config key and FITS keyword tables
from the sources
---
docs/_ext/camerad_tables.py | 402 +++++++++++++++++++++
docs/commands/async-messages.md | 24 --
docs/commands/base.md | 51 +--
docs/commands/index.md | 43 +--
docs/conf.py | 5 +-
docs/configuration/core.md | 108 +++---
docs/configuration/index.md | 6 +-
docs/data/commands.yaml | 124 +++++++
docs/data/config_keys.yaml | 102 ++++++
docs/development/index.md | 13 +-
docs/emulator/index.md | 7 +-
docs/instruments/hispec-tracking-camera.md | 20 +-
docs/requirements.txt | 1 +
13 files changed, 745 insertions(+), 161 deletions(-)
create mode 100644 docs/_ext/camerad_tables.py
delete mode 100644 docs/commands/async-messages.md
create mode 100644 docs/data/commands.yaml
create mode 100644 docs/data/config_keys.yaml
diff --git a/docs/_ext/camerad_tables.py b/docs/_ext/camerad_tables.py
new file mode 100644
index 0000000..a2e830c
--- /dev/null
+++ b/docs/_ext/camerad_tables.py
@@ -0,0 +1,402 @@
+"""Sphinx directives that build reference tables from the camerad sources.
+
+Each table is generated from the code that defines the thing being documented, and cross-checked
+against hand-written descriptions in ``docs/data``. A command, configuration key or FITS keyword
+that gains or loses a definition without a matching description fails the build, so the reference
+cannot silently drift from the source.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from functools import cache
+from pathlib import Path
+
+import yaml
+from docutils import nodes
+from docutils.parsers.rst import Directive
+from docutils.statemachine import ViewList
+from sphinx.errors import ExtensionError
+
+DOCS_DIR = Path(__file__).resolve().parent.parent
+REPO_ROOT = DOCS_DIR.parent
+DATA_DIR = DOCS_DIR / "data"
+
+# Where a configuration key may be recognized. Instrument submodules are excluded so that
+# instrument-specific keys do not leak into the core table.
+CONFIG_SEARCH_DIRS = ("camerad", "common", "utils", "emulator")
+
+ATC_DICTIONARY = (
+ REPO_ROOT
+ / "camerad/Instruments/hispec_tracking_camera/fits_header_dictionary.cpp"
+)
+
+# Reading the server config always goes through a config object's `param` array, which is what
+# separates a real key from the Archon protocol tokens matched the same way elsewhere
+_PARAM = (
+ r'config(?:file)?\s*\.\s*param\s*'
+ r'(?:\[\s*\w+\s*\]|\.\s*at\s*\(\s*\w+\s*\))'
+)
+CONFIG_KEY_PATTERNS = (
+ re.compile(_PARAM + r'\s*==\s*"([A-Z][A-Z0-9_]*)"'),
+ re.compile(_PARAM + r'\s*\.\s*compare\s*\(\s*\d+\s*,\s*\d+\s*,\s*"([A-Z][A-Z0-9_]*)"\s*\)'),
+)
+
+# The frame output keys are parsed from an already-split key/value pair rather than the param array,
+# so this one idiom is scoped to the file that does it
+FRAME_OUTPUT_SOURCE = "utils/frame_output_factory.cpp"
+FRAME_OUTPUT_KEY_PATTERN = re.compile(r'\bkey\s*==\s*"([A-Z][A-Z0-9_]*)"')
+
+# Matched by the dispatch scan but not commands
+NON_COMMANDS = frozenset({"_EXCEPTION_", "-h", "--help", "help", "?"})
+
+
+@dataclass(frozen=True)
+class Command:
+ """One command the server dispatches, with its advertised syntax."""
+
+ name: str
+ syntax: str
+ summary: str
+ controller: str
+ note: str = ""
+
+
+@dataclass(frozen=True)
+class ConfigKey:
+ """One configuration file key the code honours."""
+
+ name: str
+ summary: str
+ group: str
+
+
+@dataclass(frozen=True)
+class FitsKeyword:
+ """One entry of an instrument's FITS header dictionary."""
+
+ keyword: str
+ property: str
+ comment: str
+ type: str
+ default_atc: str
+ default_spec: str
+ enum_values: tuple[str, ...] = ()
+
+
+def _read(relative_path: str) -> str:
+ path = REPO_ROOT / relative_path
+ if not path.is_file():
+ raise ExtensionError(
+ f"{relative_path} is missing. Instrument modules are submodules; run "
+ "`git submodule update --init --recursive` before building the documentation."
+ )
+ return path.read_text(encoding="utf-8")
+
+
+def _load_data(name: str) -> dict:
+ path = DATA_DIR / name
+ if not path.is_file():
+ raise ExtensionError(f"missing documentation data file {path}")
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+
+
+def _check_coverage(kind: str, in_source: set[str], described: set[str]) -> None:
+ undocumented = sorted(in_source - described)
+ stale = sorted(described - in_source)
+ problems = []
+ if undocumented:
+ problems.append(f"{kind} in the source with no description: {', '.join(undocumented)}")
+ if stale:
+ problems.append(f"{kind} described but no longer in the source: {', '.join(stale)}")
+ if problems:
+ raise ExtensionError(
+ "; ".join(problems) + ". Update docs/data/ to match, or remove the stale entries."
+ )
+
+
+def _command_constants(header: str) -> dict[str, str]:
+ pattern = r'const\s+std::string\s+(CAMERAD_\w+)\s*\(\s*"([^"]*)"\s*\)'
+ return dict(re.findall(pattern, header))
+
+
+def _advertised_syntax(header: str, constants: dict[str, str]) -> dict[str, str]:
+ """Map command name to the syntax string CAMERAD_SYNTAX advertises for it."""
+ block = re.search(r"CAMERAD_SYNTAX\s*=\s*\{(.*?)\n\s*\};", header, re.S)
+ if not block:
+ raise ExtensionError("could not find CAMERAD_SYNTAX in common/camerad_commands.h")
+ syntax = {}
+ entry = re.compile(r'(CAMERAD_\w+)((?:\s*\+\s*"(?:[^"\\]|\\.)*")*)')
+ for constant, suffix in entry.findall(block.group(1)):
+ parts = re.findall(r'"((?:[^"\\]|\\.)*)"', suffix)
+ name = constants[constant]
+ syntax[name] = name + "".join(parts).replace("\\|", "|")
+ return syntax
+
+
+def _dispatched_commands(server: str, constants: dict[str, str]) -> list[str]:
+ body = server[server.index("Process commands here"):]
+ found = []
+ for constant, literal in re.findall(r'cmd\s*==\s*(?:(CAMERAD_\w+)|"([^"]+)")', body):
+ name = constants[constant] if constant else literal
+ if name not in NON_COMMANDS and name not in found:
+ found.append(name)
+ return found
+
+
+@cache
+def load_commands() -> list[Command]:
+ header = _read("common/camerad_commands.h")
+ constants = _command_constants(header)
+ syntax = _advertised_syntax(header, constants)
+ dispatched = _dispatched_commands(_read("camerad/camera_server.cpp"), constants)
+
+ described = _load_data("commands.yaml")
+ _check_coverage("commands", set(dispatched), set(described))
+
+ return [
+ Command(
+ name=name,
+ syntax=syntax.get(name, name),
+ summary=described[name]["summary"],
+ controller=described[name].get("controller", "any"),
+ note=described[name].get("note", ""),
+ )
+ for name in sorted(dispatched)
+ ]
+
+
+@cache
+def load_config_keys() -> list[ConfigKey]:
+ found: set[str] = set()
+ for directory in CONFIG_SEARCH_DIRS:
+ for source in sorted((REPO_ROOT / directory).rglob("*")):
+ if source.suffix not in (".cpp", ".h") or "Instruments" in source.parts:
+ continue
+ text = source.read_text(encoding="utf-8", errors="replace")
+ for pattern in CONFIG_KEY_PATTERNS:
+ found.update(pattern.findall(text))
+ found.update(FRAME_OUTPUT_KEY_PATTERN.findall(_read(FRAME_OUTPUT_SOURCE)))
+
+ described = _load_data("config_keys.yaml")
+ _check_coverage("configuration keys", found, set(described))
+
+ return [
+ ConfigKey(name=name, summary=described[name]["summary"], group=described[name]["group"])
+ for name in sorted(found)
+ ]
+
+
+def _split_top_level(text: str) -> list[str]:
+ """Split on commas that are not nested inside braces or a string literal."""
+ parts, depth, in_string, escaped, current = [], 0, False, False, []
+ for char in text:
+ if in_string:
+ current.append(char)
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == '"':
+ in_string = False
+ continue
+ if char == '"':
+ in_string = True
+ current.append(char)
+ elif char == "{":
+ depth += 1
+ current.append(char)
+ elif char == "}":
+ depth -= 1
+ current.append(char)
+ elif char == "," and depth == 0:
+ parts.append("".join(current))
+ current = []
+ else:
+ current.append(char)
+ parts.append("".join(current))
+ return [part.strip() for part in parts]
+
+
+def _entry_bodies(block: str) -> list[str]:
+ """Yield the text inside each top-level ``{ ... }`` of an initializer list."""
+ bodies, depth, in_string, escaped, start = [], 0, False, False, 0
+ for index, char in enumerate(block):
+ if in_string:
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == '"':
+ in_string = False
+ continue
+ if char == '"':
+ in_string = True
+ elif char == "{":
+ if depth == 0:
+ start = index + 1
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ bodies.append(block[start:index])
+ return bodies
+
+
+def _unquote(field_text: str) -> str:
+ """Join adjacent C++ string literals into their value."""
+ return "".join(re.findall(r'"((?:[^"\\]|\\.)*)"', field_text)).replace('\\"', '"')
+
+
+def _balanced_body(text: str, open_index: int) -> str:
+ """Return what is between the brace at ``open_index`` and its match."""
+ depth, in_string, escaped = 0, False, False
+ for index in range(open_index, len(text)):
+ char = text[index]
+ if in_string:
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == '"':
+ in_string = False
+ continue
+ if char == '"':
+ in_string = True
+ elif char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return text[open_index + 1:index]
+ raise ExtensionError("unbalanced braces in the FITS header dictionary")
+
+
+@cache
+def load_fits_keywords() -> list[FitsKeyword]:
+ text = _read(str(ATC_DICTIONARY.relative_to(REPO_ROOT)))
+ block = _balanced_body(text, text.index("dictionary = {") + len("dictionary = "))
+
+ keywords = []
+ for body in _entry_bodies(block):
+ fields = _split_top_level(body)
+ if len(fields) != 7:
+ continue
+ keywords.append(
+ FitsKeyword(
+ property=_unquote(fields[0]),
+ keyword=_unquote(fields[1]),
+ comment=_unquote(fields[2]),
+ type=fields[3].replace("Type::", ""),
+ default_atc=_unquote(fields[4]),
+ default_spec=_unquote(fields[5]),
+ enum_values=tuple(re.findall(r'"([^"]*)"', fields[6])),
+ )
+ )
+ if not keywords:
+ raise ExtensionError(f"parsed no entries from {ATC_DICTIONARY}")
+ return keywords
+
+
+def _literal(text: str) -> str:
+ return f"``{text}``" if text else ""
+
+
+class _TableDirective(Directive):
+ """Base for directives that render a generated list-table."""
+
+ has_content = False
+
+ def _render(self, headers: list[str], rows: list[list[str]]) -> list[nodes.Node]:
+ widths = self.options.get("widths", "")
+ lines = [".. list-table::", " :header-rows: 1"]
+ if widths:
+ lines.append(f" :widths: {widths}")
+ lines.append("")
+ for row in [headers, *rows]:
+ lines.append(f" * - {row[0]}")
+ lines.extend(f" - {cell}" for cell in row[1:])
+ lines.append("")
+
+ view = ViewList(lines, source="")
+ container = nodes.Element()
+ self.state.nested_parse(view, self.content_offset, container)
+ return container.children
+
+
+class CameradCommands(_TableDirective):
+ """Render the table of commands the server dispatches."""
+
+ option_spec = {"widths": str}
+
+ def run(self) -> list[nodes.Node]:
+ rows = []
+ for command in load_commands():
+ summary = command.summary
+ if command.note:
+ summary += f" {command.note}"
+ rows.append(
+ [
+ _literal(command.name),
+ _literal(command.syntax),
+ "Archon" if command.controller == "archon" else "any",
+ summary,
+ ]
+ )
+ return self._render(["Command", "Syntax", "Controller", "Description"], rows)
+
+
+class CameradConfigKeys(_TableDirective):
+ """Render the configuration keys belonging to one group."""
+
+ required_arguments = 1
+ final_argument_whitespace = True
+ option_spec = {"widths": str}
+
+ def run(self) -> list[nodes.Node]:
+ group = self.arguments[0].strip()
+ keys = [key for key in load_config_keys() if key.group == group]
+ if not keys:
+ raise ExtensionError(f"no configuration keys in group {group!r}")
+ rows = [[_literal(key.name), key.summary] for key in keys]
+ return self._render(["Key", "Meaning"], rows)
+
+
+class CameradFitsKeywords(_TableDirective):
+ """Render an instrument's FITS header dictionary."""
+
+ option_spec = {"widths": str}
+
+ def run(self) -> list[nodes.Node]:
+ rows = []
+ for entry in load_fits_keywords():
+ default = entry.default_atc or entry.default_spec
+ comment = entry.comment
+ if entry.enum_values:
+ comment += " (" + ", ".join(f"``{v}``" for v in entry.enum_values) + ")"
+ rows.append(
+ [
+ _literal(entry.keyword),
+ _literal(entry.property),
+ entry.type,
+ _literal(default),
+ comment,
+ ]
+ )
+ return self._render(["Keyword", "Property", "Type", "Default", "Comment"], rows)
+
+
+def _validate(app) -> None:
+ """Fail the build early when the source and the descriptions disagree."""
+ load_commands()
+ load_config_keys()
+ load_fits_keywords()
+
+
+def setup(app):
+ app.add_directive("camerad-commands", CameradCommands)
+ app.add_directive("camerad-config-keys", CameradConfigKeys)
+ app.add_directive("camerad-fits-keywords", CameradFitsKeywords)
+ app.connect("builder-inited", _validate)
+ return {"parallel_read_safe": True, "parallel_write_safe": True}
diff --git a/docs/commands/async-messages.md b/docs/commands/async-messages.md
deleted file mode 100644
index ec0da06..0000000
--- a/docs/commands/async-messages.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Asynchronous messages
-
-Messages the server multicasts to `ASYNCGROUP` on `ASYNCPORT`, unprompted. Each is prefixed with a
-tag naming its type, so a listener can filter without parsing the payload.
-
-:::{note}
-The tag list below comes from the superseded 2022 ICD and is being re-verified against the source in
-M2. Treat it as indicative until then.
-:::
-
-| Tag | Meaning |
-|---|---|
-| `ERROR:message` | An error occurred |
-| `NOTICE:message` | Informational notice |
-| `EXPOSURE:n` | Exposure progress, Archon only |
-| `EXPOSURE_d:n` | Exposure progress for device `d`, ARC only |
-| `LINECOUNT:n` | Lines read out so far, Archon only |
-| `PIXELCOUNT:n` | Pixels read out so far, ARC only |
-| `FILE: COMPLETE` | A FITS file finished writing |
-| `DATACUBE:n COMPLETE` or `ERROR` | A data cube finished |
-
-A listener wanting to wake on every frame should attach to the
-[shared-memory output](../configuration/frame-outputs.md) instead, which posts a semaphore per
-frame. The asynchronous port is for status, and is not a delivery guarantee.
diff --git a/docs/commands/base.md b/docs/commands/base.md
index b33ae12..9e45507 100644
--- a/docs/commands/base.md
+++ b/docs/commands/base.md
@@ -1,40 +1,29 @@
# Base commands
-Commands the server accepts regardless of which instrument it was built for. Availability still
-depends on the controller: some are Archon-only, some ARC-only.
-
-:::{note}
-This page currently lists the command set grouped by purpose. M2 of the documentation work replaces
-it with a table generated from `CAMERAD_SYNTAX` in {source}`common/camerad_commands.h`, carrying the
-full argument syntax and a description per command.
-:::
+Every command the server dispatches. Instrument modules add their own on top of these; see
+[instruments](../instruments/index.md).
Any command accepts `?` as its argument to return its own syntax.
-## Connection and firmware
-
-`open`, `close`, `isopen`, `load`, `loadtiming`, `readacf`, `power`, `interface`, `config`
-
-## Exposure
-
-`expose`, `abort`, `stop`, `pause`, `resume`, `exptime`, `modexptime`, `exposuremode`,
-`preexposures`, `shutter`, `readout`, `useframes`
-
-## Geometry and readout
+The **Controller** column distinguishes commands the interface implements directly from those
+routed through `controller_cmd`, which only the Archon interface implements. On an ARC build the
+latter return `not_supported`.
-`geometry`, `imsize`, `buffer`, `bin`, `boi`, `bias`, `frametransfer`, `mode`
+```{eval-rst}
+.. camerad-commands::
+ :widths: 12 30 10 48
+```
-## Output and FITS
-
-`imdir`, `autodir`, `basename`, `imnum`, `fitsname`, `fitsnaming`, `datacube`, `mex`, `mexamps`,
-`key`, `writekeys`
-
-## Controller access
-
-`native`, `raw`, `heater`, `sensor`
-
-## Diagnostics
+:::{note}
+This table is generated from the dispatch chain in {source}`camerad/camera_server.cpp`, so it lists
+what the server actually answers. The `help` command is a separate hand-maintained list in
+{source}`common/camerad_commands.h` that has drifted: it advertises around two dozen commands the
+server no longer implements, and omits several it does, including `power`. Trust this table over
+`help`.
+:::
-`echo`, `test`, `longerror`, `exit`
+## Syntax not shown
-Instrument modules add their own commands on top of these; see [instruments](../instruments/index.md).
+A few commands are dispatched but absent from the syntax list, so no argument syntax is generated
+for them. They are `power`, `getp`, `setp`, `inreg`, `autofetch_mode` and `bob`. Use `?` against a
+running server for the authoritative syntax.
diff --git a/docs/commands/index.md b/docs/commands/index.md
index 0a2bd3f..9744be6 100644
--- a/docs/commands/index.md
+++ b/docs/commands/index.md
@@ -3,32 +3,24 @@
The server speaks a line-oriented ASCII protocol over TCP. Commands are short mnemonics with
space-separated arguments; replies are plain text.
-## Ports
+## The port
-Three ports are configured in the `.cfg` file, and they behave differently.
+`camerad` opens exactly one TCP port, `BLKPORT` ({source}`camerad/camerad.cpp`). The connection
+stays open for as long as the client holds it, so it works directly with `telnet` as an ad hoc
+command line, and the reply on the same connection is what signals completion.
-Blocking port (`BLKPORT`)
-: The connection stays open for as long as the client holds it, so it works directly with `telnet`
- as an ad hoc command line. One command at a time: a command sent before the previous one has
- replied is ignored. The reply on the same connection is what signals completion. Use this when the
- order of execution matters.
+Each client connection is served on its own thread. A socket that sits idle is closed after 3
+seconds ({source}`utils/network.h`).
-Non-blocking port (`NBPORT`)
-: Accepts one command, then closes the connection. Each connection is handled on its own thread, so
- commands can run concurrently. Their relative order is not guaranteed, which is the trade for the
- concurrency.
-
-Asynchronous message port (`ASYNCPORT`)
-: Connectionless UDP, multicast to `ASYNCGROUP`. Listen-only. Carries status the server emits on its
- own schedule, such as exposure progress, along with replies to non-blocking commands and messages
- too long for a command reply. Each message is prefixed with a tag naming its type.
-
-Connections to the non-blocking port that sit idle are closed after 3 seconds
-({source}`utils/network.h`), so a client that opens a connection and never sends anything cannot
-accumulate threads.
+:::{warning}
+Older configuration files and the superseded 2022 ICD describe two more ports: a non-blocking
+command port (`NBPORT`) and a UDP multicast port for asynchronous status messages (`ASYNCPORT`,
+`ASYNCGROUP`). Neither exists in camerad 2.0.
-The server serializes access to hardware that cannot tolerate concurrent use, whichever port the
-commands arrive on.
+`NBPORT` is read only by the emulator. `ASYNCPORT` and `ASYNCGROUP` are read by nothing: the UDP
+multicast class still exists in {source}`utils/network.cpp` but is never instantiated, so no
+asynchronous messages are ever sent. Setting those keys has no effect.
+:::
## Replies
@@ -64,11 +56,10 @@ and readout complete, whether or not the FITS writer kept up. See
base
controller
-async-messages
```
:::{note}
-The base command table is generated from `CAMERAD_SYNTAX` in
-{source}`common/camerad_commands.h` and cross-checked against the descriptions kept alongside the
-docs, so a command added to the server without a description here fails the documentation build.
+The base command table is generated from the server's dispatch chain and cross-checked against the
+descriptions kept alongside the docs, so a command the server gains or loses without a matching
+description fails the documentation build.
:::
diff --git a/docs/conf.py b/docs/conf.py
index cfecdfd..ae678bf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,10 +1,12 @@
"""Sphinx configuration for the camera-interface documentation."""
import importlib.util
+import sys
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(Path(__file__).resolve().parent / "_ext"))
project = "camera-interface"
author = "Caltech Optical Observatories"
@@ -20,9 +22,10 @@
"sphinx.ext.extlinks",
"sphinx_copybutton",
"sphinx_design",
+ "camerad_tables",
]
-exclude_patterns = ["_build"]
+exclude_patterns = ["_build", "data"]
# Link to a source file with {source}`camerad/camera_interface.h` instead of pasting signatures,
# since the C++ reference is narrative rather than generated
diff --git a/docs/configuration/core.md b/docs/configuration/core.md
index 0a229a4..5b89443 100644
--- a/docs/configuration/core.md
+++ b/docs/configuration/core.md
@@ -1,80 +1,60 @@
# Core keys
-Keys read by the server itself, independent of instrument or frame output.
-
-:::{note}
-This table is written by hand today. M2 of the documentation work replaces it with a generated table
-cross-checked against the keys the source actually honours.
+Keys the server itself reads, independent of the frame outputs.
+
+:::{important}
+These tables list only keys the code actually reads. Configuration files in the wild, and the
+superseded 2022 ICD, carry a number of keys that nothing reads any more: `IMDIR`, `BASENAME`,
+`AUTODIR`, `DIRMODE`, `DAEMON`, `LONGERROR`, `TM_ZONE`, `TZ_ENV`, `ASYNCPORT`, `ASYNCGROUP` and
+`START_PARAM` among them. Setting them has no effect. Image naming and location moved to the
+[frame output keys](frame-outputs.md).
:::
## Controller connection
-| Key | Meaning |
-|---|---|
-| `ARCHON_IP` | IP address of the Archon controller. Point at the emulator to run without hardware. |
-| `ARCHON_PORT` | Port of the Archon controller |
-| `DEFAULT_FIRMWARE` | Firmware loaded by `load` with no argument. Indexed array for multi-controller ARC systems. |
-| `INSTRUMENT` | Instrument name, checked against the instrument the binary was built for |
+```{eval-rst}
+.. camerad-config-keys:: Controller connection
+ :widths: 30 70
+```
## Archon parameters
-The server drives an Archon by writing named parameters defined in the ACF. These keys tell it which
-names to use, so the same binary works with differently authored timing scripts.
-
-| Key | Meaning |
-|---|---|
-| `EXPOSE_PARAM` | Parameter written to trigger an exposure |
-| `START_PARAM` | Parameter written to start the timing script |
-| `ABORT_PARAM` | Parameter written to abort an exposure |
-| `EXPTIME_MSEC_PARAM` | Parameter holding the milliseconds part of the exposure time. Required. |
-| `EXPTIME_SEC_PARAM` | Parameter holding the whole-seconds part. Optional, but see [exposure time](exposure-time.md) for the limit without it. |
-
-## Ports
-
-| Key | Meaning |
-|---|---|
-| `BLKPORT` | Blocking command port. One command at a time, connection stays open. |
-| `NBPORT` | Non-blocking command port. One command per connection, then closed. |
-| `ASYNCPORT` | UDP port for asynchronous status messages |
-| `ASYNCGROUP` | Multicast group the asynchronous messages are sent to |
-
-See [the command protocol](../commands/index.md) for how the ports differ in behaviour.
-
-## Files and logging
-
-| Key | Meaning |
-|---|---|
-| `IMDIR` | Base directory for image files |
-| `AUTODIR` | `yes` to write into a `YYYYMMDD` subdirectory of `IMDIR` |
-| `BASENAME` | Base filename for images |
-| `DIRMODE` | Permissions for directories the server creates |
-| `LOGPATH` | Directory for the daily log file |
-| `LOG_STDERR` | Also write the log to stderr, overriding what `--foreground` implies |
-| `TM_ZONE_LOG` | `UTC` or `local`, for log entry timestamps only |
-| `TM_ZONE` | `UTC` or `local`, for everything else including FITS times and `AUTODIR` |
-| `TZ_ENV` | POSIX `TZ` string used when a zone is set to `local` |
-
-:::{tip}
-`TM_ZONE=local` is useful in the lab, where a UTC date rollover in the middle of a working day splits
-one session across two `AUTODIR` directories. The time zone is recorded in the FITS header either way.
-:::
+The server drives an Archon by writing named parameters defined in the ACF. These keys say which
+names to use, so one binary works with differently authored timing scripts.
+
+```{eval-rst}
+.. camerad-config-keys:: Archon parameters
+ :widths: 30 70
+```
+
+## Server
+
+```{eval-rst}
+.. camerad-config-keys:: Server
+ :widths: 30 70
+```
+
+## Exposure
+
+```{eval-rst}
+.. camerad-config-keys:: Exposure
+ :widths: 30 70
+```
+
+See [exposure time](exposure-time.md) for what the unit affects.
-## Behaviour
+## Heater
-| Key | Meaning |
-|---|---|
-| `DAEMON` | `yes` or `no`. The `--foreground` command line option overrides it. |
-| `LONGERROR` | `true` to return long error messages on the command port |
-| `LONGEXPOSURE` | Unit for bare `exptime` arguments. See [exposure time](exposure-time.md). |
-| `READOUT_TIME` | Expected readout time in msec, used to time out a readout that never completes |
-| `HEATER_TARGET_MIN` | Lower bound for heater targets, overriding the backplane default |
-| `HEATER_TARGET_MAX` | Upper bound for heater targets, overriding the backplane default |
+```{eval-rst}
+.. camerad-config-keys:: Heater
+ :widths: 30 70
+```
## Emulator
Read by `camerad-emulator`, not by the server, but conventionally kept in the same file.
-| Key | Meaning |
-|---|---|
-| `EMULATOR_PORT` | Port the emulator listens on. `ARCHON_PORT` points here to run without hardware. |
-| `EMULATOR_SYSTEM` | Path to the `.system` file describing the emulated module complement |
+```{eval-rst}
+.. camerad-config-keys:: Emulator
+ :widths: 30 70
+```
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index 626dd22..a99afc8 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -35,9 +35,9 @@ exposure-time
```
:::{note}
-The key tables are generated from the source at documentation build time and cross-checked against
-the keys `camerad` actually honours, so a key added to the code without a description here fails the
-build. See [development](../development/index.md).
+The key tables are generated at documentation build time from the keys `camerad` actually reads, so
+a key added to or dropped from the code without a matching description fails the build. See
+[development](../development/index.md).
:::
## Build options
diff --git a/docs/data/commands.yaml b/docs/data/commands.yaml
new file mode 100644
index 0000000..256e44d
--- /dev/null
+++ b/docs/data/commands.yaml
@@ -0,0 +1,124 @@
+# Descriptions for the commands camerad dispatches.
+#
+# The command list and the argument syntax are generated from the source; this file supplies the
+# prose. A command added to or removed from the dispatch in camerad/camera_server.cpp without a
+# matching change here fails the documentation build.
+#
+# summary one line, imperative
+# controller "any", or "archon" for commands routed through controller_cmd
+# note optional caveat appended to the summary
+
+abort:
+ summary: Abort an exposure in progress
+ controller: any
+
+autodir:
+ summary: Write images into a dated subdirectory
+ controller: any
+ note: "Not implemented: the Archon interface logs `not yet implemented` and returns an error. Use `FITS_AUTODIR` instead."
+
+autofetch_mode:
+ summary: Control autofetch, where the controller pushes frames continuously rather than on request
+ controller: archon
+
+basename:
+ summary: Set or get the image base name
+ controller: any
+
+bias:
+ summary: Set or get a bias voltage
+ controller: any
+
+bin:
+ summary: Set or get the binning factor
+ controller: any
+ note: "Broken: the server routes this to the bias handler, so an argument is applied as a bias rather than a binning factor."
+
+bob:
+ summary: Reach the BOB interface
+ controller: archon
+ note: "Non-functional: no controller implements a handler for it, so it always returns an error."
+
+close:
+ summary: Disconnect from the controller
+ controller: any
+
+datacube:
+ summary: Set or report whether reads are written as one multi-extension FITS file
+ controller: any
+
+exit:
+ summary: Shut the server down
+ controller: any
+
+expose:
+ summary: Take an exposure, or a counted series of them
+ controller: any
+
+exposuremode:
+ summary: Select or report the exposure mode
+ controller: any
+
+exptime:
+ summary: Set or report the exposure time
+ controller: any
+
+getp:
+ summary: Read an Archon parameter value
+ controller: archon
+
+heater:
+ summary: Control a closed-loop heater on a Heater or HeaterX module
+ controller: archon
+
+inreg:
+ summary: Write a VCPU input register
+ controller: archon
+
+key:
+ summary: Add, list or delete a user FITS keyword written into every exposure
+ controller: any
+
+load:
+ summary: Load firmware, defaulting to DEFAULT_FIRMWARE from the config
+ controller: any
+
+loadtiming:
+ summary: Load a timing script and its parameters
+ controller: archon
+
+mode:
+ summary: Select a camera mode defined in the ACF
+ controller: archon
+
+native:
+ summary: Send a command straight to the controller and return its reply
+ controller: any
+
+open:
+ summary: Connect to the controller
+ controller: any
+
+power:
+ summary: Turn detector power on or off, or report the current state
+ controller: any
+
+raw:
+ summary: Read or write the Archon configuration memory directly
+ controller: archon
+
+readacf:
+ summary: Read an ACF file into the controller configuration
+ controller: archon
+
+sensor:
+ summary: Set or get a temperature sensor's excitation current and digital averaging
+ controller: archon
+
+setp:
+ summary: Write an Archon parameter value
+ controller: archon
+
+test:
+ summary: Run a development-level test
+ controller: any
diff --git a/docs/data/config_keys.yaml b/docs/data/config_keys.yaml
new file mode 100644
index 0000000..ece1087
--- /dev/null
+++ b/docs/data/config_keys.yaml
@@ -0,0 +1,102 @@
+# Descriptions for the configuration file keys camerad honours.
+#
+# The key list is extracted from the source; this file supplies the prose and the grouping. A key
+# the code starts or stops reading without a matching change here fails the documentation build.
+#
+# Only keys the code actually reads appear. Several keys in older configuration files and in the
+# superseded 2022 ICD are no longer read by anything and are deliberately absent.
+
+ARCHON_IP:
+ group: Controller connection
+ summary: IP address of the Archon controller. Point this at the emulator to run without hardware.
+ARCHON_PORT:
+ group: Controller connection
+ summary: Port of the Archon controller
+DEFAULT_FIRMWARE:
+ group: Controller connection
+ summary: Firmware that `load` uses when given no argument
+
+ABORT_PARAM:
+ group: Archon parameters
+ summary: Parameter written to abort an exposure
+EXPOSE_PARAM:
+ group: Archon parameters
+ summary: Parameter written to trigger an exposure
+EXPTIME_MSEC_PARAM:
+ group: Archon parameters
+ summary: Parameter holding the milliseconds part of the exposure time. Required.
+EXPTIME_SEC_PARAM:
+ group: Archon parameters
+ summary: Parameter holding the whole-seconds part of the exposure time. Optional.
+
+BLKPORT:
+ group: Server
+ summary: TCP port the server listens on. This is the only port camerad opens.
+LOGPATH:
+ group: Server
+ summary: Directory for the daily log file. Required; the server refuses to start without it.
+LOG_STDERR:
+ group: Server
+ summary: Also write the log to stderr, overriding what `--foreground` implies
+TM_ZONE_LOG:
+ group: Server
+ summary: Time zone for log entry timestamps, `UTC` or `local`
+
+LONGEXPOSURE:
+ group: Exposure
+ summary: Unit for a bare `exptime` argument. `true` for seconds, `false` for milliseconds.
+
+HEATER_TARGET_MIN:
+ group: Heater
+ summary: Lower bound for heater targets, overriding the backplane default
+HEATER_TARGET_MAX:
+ group: Heater
+ summary: Upper bound for heater targets, overriding the backplane default
+
+FITS_ENABLED:
+ group: FITS output
+ summary: Enable the FITS writer. Default `no`.
+FITS_OUTPUT_DIR:
+ group: FITS output
+ summary: Base directory for FITS files, which must already exist. Default `/tmp/images`.
+FITS_AUTODIR:
+ group: FITS output
+ summary: Write into a `YYYYMMDD` subdirectory of `FITS_OUTPUT_DIR`. Default `no`.
+FITS_BASENAME:
+ group: FITS output
+ summary: Base filename for FITS files. Default `tracking`.
+FITS_QUEUE_SIZE:
+ group: FITS output
+ summary: Frames buffered for the writer thread; the oldest is dropped when full. Default `32`.
+FITS_DRAIN_TIMEOUT_MS:
+ group: FITS output
+ summary: How long to keep draining the queue at shutdown before giving up. Default `5000`.
+
+SHM_ENABLED:
+ group: Shared memory output
+ summary: Enable the shared-memory writer. Ignored with a warning on a build without `ENABLE_SHM_OUTPUT`.
+SHM_SEGMENT_NAME:
+ group: Shared memory output
+ summary: ImageStreamIO stream name. Default `camera`.
+SHM_RING_BUFFER_SIZE:
+ group: Shared memory output
+ summary: Depth of ImageStreamIO's internal history ring buffer. Default `4`.
+SHM_DIR:
+ group: Shared memory output
+ summary: Directory ImageStreamIO writes into. Unset falls back to `MILK_SHM_DIR`, then `/milk/shm`.
+
+EMULATOR_PORT:
+ group: Emulator
+ summary: Port the emulator listens on. `ARCHON_PORT` points here to run without hardware.
+EMULATOR_SYSTEM:
+ group: Emulator
+ summary: Path to the `.system` file describing the module complement the emulator reports
+EMULATOR_DATADIR:
+ group: Emulator
+ summary: Directory of real frames for the emulator to serve. Unset means synthetic pixel data.
+NBPORT:
+ group: Emulator
+ summary: Read by the emulator only. camerad itself does not open a non-blocking port.
+INSTRUMENT:
+ group: Emulator
+ summary: Instrument name the emulator reports
diff --git a/docs/development/index.md b/docs/development/index.md
index a5d9ee0..b48c396 100644
--- a/docs/development/index.md
+++ b/docs/development/index.md
@@ -21,16 +21,21 @@ build time and cross-checked against the source, so the build fails when they di
| Table | Source of truth |
|---|---|
-| Base commands | `CAMERAD_SYNTAX` in {source}`common/camerad_commands.h` |
-| Configuration keys | The key comparisons in `camerad`, `common`, `utils` and `emulator` |
+| Base commands | The dispatch chain in {source}`camerad/camera_server.cpp`, with syntax from `CAMERAD_SYNTAX` |
+| Configuration keys | Reads through a config object's `param` array, plus the frame output parser |
| ATC FITS keywords | The `HeaderDictEntry` table in {source}`camerad/Instruments/hispec_tracking_camera/fits_header_dictionary.cpp` |
Descriptions are written by hand in `docs/data/`, keyed by command or key name. Adding a command or
a config key to the source without a description there fails the docs build; so does describing one
that no longer exists.
-:::{note}
-The generators arrive in M2. Until then the affected tables are hand-written and marked as such.
+The generators are Sphinx directives in `docs/_ext/camerad_tables.py`. Validation runs once at
+`builder-inited`, so a mismatch fails immediately rather than partway through writing pages.
+
+:::{tip}
+Commands are taken from the dispatch chain rather than from `CAMERAD_SYNTAX`, because the two have
+diverged: `CAMERAD_SYNTAX` feeds the `help` output and still advertises commands the server no
+longer implements. The dispatch is what actually answers a client.
:::
## Documentation layout
diff --git a/docs/emulator/index.md b/docs/emulator/index.md
index f44abb7..dc7ed4d 100644
--- a/docs/emulator/index.md
+++ b/docs/emulator/index.md
@@ -26,8 +26,11 @@ lets the emulator answer `SYSTEM` convincingly for a given instrument.
## What it models
The emulator answers the Archon command set the server uses: configuration load, parameter writes,
-frame status, and pixel delivery on the timing the exposure implies. Frames carry synthetic pixel
-data, so it validates plumbing, geometry, timing and headers, not image quality.
+frame status, and pixel delivery on the timing the exposure implies.
+
+Frames carry synthetic pixel data by default, so it validates plumbing, geometry, timing and headers
+rather than image quality. Point `EMULATOR_DATADIR` at a directory of real frames to have it serve
+those instead, which is what makes it useful for exercising downstream processing.
Sources are in {source}`emulator`, with the per-detector frame sources alongside
(`generic.h`, `nirc2.h`).
diff --git a/docs/instruments/hispec-tracking-camera.md b/docs/instruments/hispec-tracking-camera.md
index 28b7af5..147f861 100644
--- a/docs/instruments/hispec-tracking-camera.md
+++ b/docs/instruments/hispec-tracking-camera.md
@@ -53,10 +53,18 @@ build.
## FITS keywords
-The module carries its own keyword dictionary
-({source}`camerad/Instruments/hispec_tracking_camera/fits_header_dictionary.cpp`) mapping each
-internal property to a keyword, comment, type and default. It covers two cameras, ATC and SPEC, with
-separate defaults.
+The module carries its own keyword dictionary mapping each internal property to a keyword, comment,
+type and default. It covers two cameras, ATC and SPEC, with separate defaults; the table below shows
+the ATC default, falling back to the SPEC one where ATC has none.
-The generated keyword table lands here in M2. `python/tests/fits_header_check.py` validates a
-written file against this dictionary.
+`python/tests/fits_header_check.py` validates a written file against this dictionary.
+
+```{eval-rst}
+.. camerad-fits-keywords::
+ :widths: 12 20 10 10 48
+```
+
+:::{note}
+Generated from {source}`camerad/Instruments/hispec_tracking_camera/fits_header_dictionary.cpp`, so
+it cannot drift from the dictionary the instrument actually writes.
+:::
diff --git a/docs/requirements.txt b/docs/requirements.txt
index a9787d3..823fbf7 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -3,3 +3,4 @@ myst-parser==4.0.0
furo==2024.8.6
sphinx-copybutton==0.5.2
sphinx-design==0.6.1
+pyyaml==6.0.2