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 [