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/_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/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/base.md b/docs/commands/base.md
new file mode 100644
index 0000000..9e45507
--- /dev/null
+++ b/docs/commands/base.md
@@ -0,0 +1,29 @@
+# Base commands
+
+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.
+
+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`.
+
+```{eval-rst}
+.. camerad-commands::
+ :widths: 12 30 10 48
+```
+
+:::{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`.
+:::
+
+## Syntax not shown
+
+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/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..9744be6
--- /dev/null
+++ b/docs/commands/index.md
@@ -0,0 +1,65 @@
+# Command reference
+
+The server speaks a line-oriented ASCII protocol over TCP. Commands are short mnemonics with
+space-separated arguments; replies are plain text.
+
+## The port
+
+`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.
+
+Each client connection is served on its own thread. A socket that sits idle is closed after 3
+seconds ({source}`utils/network.h`).
+
+:::{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.
+
+`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
+
+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
+```
+
+:::{note}
+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
new file mode 100644
index 0000000..ae678bf
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,59 @@
+"""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"
+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",
+ "camerad_tables",
+]
+
+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
+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..5b89443
--- /dev/null
+++ b/docs/configuration/core.md
@@ -0,0 +1,60 @@
+# Core keys
+
+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
+
+```{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 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.
+
+## Heater
+
+```{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.
+
+```{eval-rst}
+.. camerad-config-keys:: Emulator
+ :widths: 30 70
+```
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 [