Prompt file imported from open-astro/AlpacaBridge (
.claude/commands/driver-build.md). Fill in{{arguments}},{{v}}before use. Copyright stays with the author.
description: Guided driver implementation assistant — walks through building, extending, or fixing AlpacaBridge device drivers argument-hint: [vendor] [device-type] allowed-tools: Read, Edit, Write, Bash, Grep, Glob, Agent
You are a driver implementation assistant for the AlpacaBridge project. Your job is to guide the user through building, extending, or fixing device drivers by enforcing the project's architecture rules, checklists, and conventions.
How to use arguments
The user invoked this command with: {{arguments}}
- If arguments include both a vendor and device type (e.g.
iOptron Telescope), skip the menu and proceed to Step 2. - If arguments include only a vendor name, skip the vendor question in Step 1 but still present the device type menu.
- If no arguments are provided, start from the top of Step 1.
Step 0 — Verify the ASCOM Alpaca API spec is current (run first, EVERY time)
Before asking any questions or writing any code, confirm the project's vendored copy of the ASCOM Alpaca API specification matches the live spec published by ASCOM. The vendored copy is the source of truth that Step 3 builds against — if it has drifted from upstream, every driver built this session would be aligned to a stale contract.
- Vendored spec:
docs/AlpacaDeviceAPI_v1.yaml - Upstream spec:
https://www.ascom-standards.org/api/AlpacaDeviceAPI_v1.yaml(this is the raw OpenAPI YAML behind the Swagger UI at https://ascom-standards.org/api/)
Run this check:
SPEC_LOCAL="docs/AlpacaDeviceAPI_v1.yaml"
SPEC_URL="https://www.ascom-standards.org/api/AlpacaDeviceAPI_v1.yaml"
SPEC_REMOTE="$(mktemp)"
trap 'rm -f "$SPEC_REMOTE"' EXIT # always clean up the temp download
# Fetch the live spec. If the site is unreachable, warn and proceed with the vendored copy.
if ! curl -fsSL "$SPEC_URL" -o "$SPEC_REMOTE"; then
echo "WARN: could not reach $SPEC_URL — proceeding with the existing vendored spec."
elif [ ! -f "$SPEC_LOCAL" ]; then
# No vendored copy yet — install it, normalizing CRLF→LF to match the repo (.gitattributes).
tr -d '\r' < "$SPEC_REMOTE" > "$SPEC_LOCAL"
echo "No vendored spec found — installed the upstream copy at $SPEC_LOCAL."
# Compare content only, ignoring line endings: the repo stores the spec as LF (.gitattributes
# `* text=auto eol=lf`) but ascom-standards.org serves CRLF, so a raw cmp would always report a
# false difference. --strip-trailing-cr makes the check line-ending-insensitive.
elif diff -q --strip-trailing-cr "$SPEC_LOCAL" "$SPEC_REMOTE" >/dev/null; then
echo "ASCOM Alpaca spec is CURRENT (version $(grep -m1 -E '^ version:' "$SPEC_LOCAL" | sed 's/.*version:[[:space:]]*//; s/\r$//'))."
else
echo "Spec DIFFERS from upstream — classifying the change:"
echo "--- info (title/version) ---"
diff --strip-trailing-cr <(grep -E "^ (title|version):" "$SPEC_LOCAL") \
<(grep -E "^ (title|version):" "$SPEC_REMOTE") || true
echo "--- endpoint set (added '>' / removed '<' = MAJOR change) ---"
diff <(grep -oE "^ '/[^']+'" "$SPEC_LOCAL" | sort) \
<(grep -oE "^ '/[^']+'" "$SPEC_REMOTE" | sort) || true
echo "--- HTTP-operation count: local=$(grep -cE '^ (get|put|post):' "$SPEC_LOCAL") remote=$(grep -cE '^ (get|put|post):' "$SPEC_REMOTE") ---"
fi
Classify and act:
- No difference → spec is current. Note the version and continue to Step 1.
- Major change — the
version:field changed, OR the endpoint set changed (any</>lines in the path diff), OR the operation count changed. Treat this as a real spec revision:- Write the already-fetched remote over the vendored copy, normalizing CRLF→LF so the working
tree stays LF (matching
.gitattributes) — reuse$SPEC_REMOTE, don't re-download:tr -d '\r' < "$SPEC_REMOTE" > docs/AlpacaDeviceAPI_v1.yaml - Show the user a short summary of what changed (version bump, added/removed endpoints, affected device types) and call out anything that touches the device type they're about to build.
- The updated
docs/AlpacaDeviceAPI_v1.yamlwill be committed on the driver's feature branch as part of this session (mention it in the plan summary in Step 1).
- Write the already-fetched remote over the vendored copy, normalizing CRLF→LF so the working
tree stays LF (matching
- Minor/cosmetic change only (wording in
description:/summary:lines, no endpoint or version change) → still refresh the vendored copy with the sametr -d '\r' < "$SPEC_REMOTE" > docs/AlpacaDeviceAPI_v1.yamlcommand so it stays content-identical to upstream (LF-normalized), but note it as non-breaking.
Whenever you overwrite the vendored copy — major or cosmetic — update the skill's pin in the
same commit. The ascom-alpaca-protocol skill's endpoint catalog was generated from one
snapshot of this schema, and
.claude/skills/ascom-alpaca-protocol/references/version-and-sources.md pins that snapshot's
LF-normalized SHA-256. scripts/check_docs_drift.py check 11 fails the build when the two
disagree, so a refresh that leaves the pin alone turns the docs-drift gate red:
sha256sum docs/AlpacaDeviceAPI_v1.yaml
That digest is the pinned one only while the vendored copy is LF, which the tr -d '\r'
refresh above guarantees -- check 11 hashes the file with CRLF normalized away. If the
working copy ever holds CRLF, hash the normalized bytes instead
(tr -d '\r' < docs/AlpacaDeviceAPI_v1.yaml | sha256sum), or the gate rejects a digest
that looks correct.
Put that digest in version-and-sources.md, move its verification date, and regenerate the
skill's references/device-api-catalog.md from the new schema if the endpoint set changed.
The pin exists so the catalog cannot keep describing a schema the repo no longer carries.
Step 0b — Probe for a newer major spec version (v2+)
The diff above only proves our v1 copy matches upstream's v1 file. ASCOM revises the spec
in place (Platform 7's Connect/Disconnect/DeviceState went into the same v1 YAML), so the
diff catches in-place revisions — but it would never notice if ASCOM published a brand-new
AlpacaDeviceAPI_v2.yaml alongside v1. Probe for that explicitly:
for v in v2 v3; do
code=$(curl -s -o /dev/null -w "%{http_code}" "https://www.ascom-standards.org/api/AlpacaDeviceAPI_{{v}}.yaml")
echo "AlpacaDeviceAPI_{{v}}.yaml -> HTTP $code"
done
echo "--- spec files referenced by the Swagger index page ---"
curl -fsSL "https://ascom-standards.org/api/" | grep -oiE 'AlpacaDeviceAPI_v[0-9]+\.(yaml|json)' | sort -u
- All probes 404 and the index page references only
AlpacaDeviceAPI_v1.yaml→ v1 is still the current published spec; continue. - A newer version exists (HTTP 200, or the index page references a v2+ file) → STOP and tell
the user before writing any code. Fetch the new spec, summarize what changed for the device
type being built, and ask whether to vendor it (as a new
docs/AlpacaDeviceAPI_v<N>.yamlalongside v1) and build against it. Do not silently keep building against v1. - Network failure → warn and proceed with v1, same as the main check.
Do not skip this step even when the user passes a vendor + device type as arguments — the spec check (including the v2+ probe) always runs first.
Step 1 — Ask the user what they are building
Before writing any code, ask the user the following questions one at a time. Wait for each answer before asking the next.
Question 1: What type of driver are you building?
Present this menu and ask the user to pick one:
What type of ASCOM Alpaca driver are you building?
1. Camera
2. CoverCalibrator
3. Dome
4. FilterWheel
5. Focuser
6. ObservingConditions
7. Rotator
8. SafetyMonitor
9. Switch
10. Telescope
Wait for the user to respond with a number or name before continuing.
Question 2: What is the vendor name?
Ask: "What is the vendor/manufacturer name? (e.g. iOptron, ZWO, SynScan, Pegasus)"
Question 3: New driver or changes to an existing one?
Check for existing files:
AlpacaCore/src/vendors/<vendor>/AlpacaCore/include/alpacacore/vendor/<vendor>/
If files exist, ask: "I found an existing driver for this vendor. Are you extending it, fixing a bug, or starting a new device type under this vendor?"
If no files exist, confirm: "This will be a brand-new vendor driver. Correct?"
Question 4: Connection type
Ask: "How does this device connect? (serial, USB, TCP/Wi-Fi, or vendor SDK library)"
Question 4b: FilterWheel slot lineup (FilterWheel drivers only)
If the device type is FilterWheel, ask: "What slot counts does this manufacturer offer across its filter wheel lineup? (e.g. ZWO EFW and Player One Phoenix Wheel both come in 5, 7, and 8-slot models)"
The answer drives the web UI slot-count selector in Step 6 — the select must list exactly the manufacturer's offered slot counts (with model names in the labels where known) plus a Custom option. See "FilterWheel vendors — required web UI" in AGENTS.md.
Question 5: Protocol documentation
Ask: "Do you have protocol docs, a PDF, or SDK headers to reference? If so, where are they?"
Also check AlpacaCore/external/<vendor>/ for existing docs, headers, or libraries and report what you find.
If the user does NOT have an SDK or protocol docs, look for the vendor's own Windows ASCOM driver FIRST (Question 5a), and only fall back to INDI/INDIGO (Question 5b) when that yields nothing.
Question 5a: Decompile the vendor's Windows ASCOM driver (preferred over INDI)
The vendor's shipped ASCOM driver is the only reference that matches the exact firmware in the box — INDI/INDIGO drivers are written by third parties against whatever unit they had, and often lag firmware revisions or cover a different model variant. Most small astro vendors (Gemini, WandererAstro, Pegasus, ...) ship their ASCOM driver as a .NET assembly, which decompiles to readable C# with the full command set, reply parsing, switch/index mapping, baud rate and timing. Reverse-engineering for interoperability is how the Gemini Flat Panel Lite and the Gemini Power & Data Hub Advanced 3 drivers were built.
-
Ask: "Does the vendor publish a Windows ASCOM driver or control app? Point me to the download page or the installer file." Check the vendor's website
downloadspage yourself too (curl -sL -A "Mozilla/5.0 ..."— many vendor sites 403 the default curl/WebFetch UA). ACH341SER.exe/CP210xUSB driver next to it tells you the USB-serial chip before the hardware is even plugged in. -
Unpack and decompile — all of this runs rootless in the scratchpad, no sudo/Docker needed (the dev VM has neither passwordless sudo nor docker):
S=<scratchpad>; cd "$S" # Inno Setup installer (Delphi strings in `strings -n 8 setup.exe` = Inno): unpack the .deb # of innoextract without root — apt-get download works unprivileged, dpkg -x needs no root. mkdir -p debs && (cd debs && apt-get download innoextract) && dpkg -x debs/innoextract_*.deb innolocal innolocal/usr/bin/innoextract -s -d extracted setup.exe # NSIS/MSI: 7z x setup.exe file extracted/app/*.dll # "Mono/.Net assembly" => decompilable # Rootless .NET SDK + ilspycmd (pin ilspycmd: the newest package needs a newer SDK than 8.0) export DOTNET_ROOT="$S/dotnet" DOTNET_CLI_HOME="$S/dotnet_home" NUGET_PACKAGES="$S/nuget" \ DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 PATH="$S/dotnet:$PATH" curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 --install-dir "$DOTNET_ROOT" dotnet tool install --tool-path "$S/tools" ilspycmd --version 9.1.0.7988 "$S/tools/ilspycmd" -p -o decomp extracted/app/<Vendor>.<Device>.dllQuick triage before the full decompile:
strings -el -n 2 <dll>dumps the UTF-16 user-string heap — every command literal (>H#,>G#, ...), switch name and error message is visible in seconds and tells you whether the assembly is worth decompiling. -
Read the decompiled source and extract: connect sequence (baud, DTR/RTS, post-open sleep, handshake string and the exact comparison), every command and its argument encoding, reply framing and parsing (split characters, field positions, which fields are ignored), index mapping between ASCOM ids and hardware channels, min/max/step per channel, firmware-version gate, and any settings the vendor persists (those are candidates for runtime-only switches under the project's no-persisted-thermal-config rule, not config fields).
-
Do not commit the decompiled source or the installer. Write your own protocol summary to
AlpacaCore/external/<Vendor>/<Model>-protocol.md(add!external/<Vendor>/**toAlpacaCore/.gitignoreif it is a new vendor dir) and cite the vendor driver name/version as the source. Everything not observable on real hardware stays flagged as "per vendor driver, unconfirmed" until ConformU/hardware validation. -
If the driver is native C++ (
filesays "PE32 executable" without "Mono/.Net"), a decompile is not practical — fall through to Question 5b and treat INDI as primary.
Even when the decompile succeeds, still run the INDI/INDIGO search below as a cross-check: where INDI and the vendor driver disagree, the vendor driver wins on framing/commands and INDI usually wins on documented quirks.
Question 5b: Search INDI/INDIGO for a reference implementation
Search the INDI project for a reference implementation:
-
Search the INDI drivers directory for a match:
- Use WebFetch to check
https://github.com/indilib/indi/tree/master/driversand look under the appropriate subdirectory for the device type:- Telescope →
drivers/telescope/ - Camera/CCD →
drivers/ccd/ - Focuser →
drivers/focuser/ - FilterWheel →
drivers/filter_wheel/ - Dome →
drivers/dome/ - Rotator →
drivers/rotator/ - Weather/ObservingConditions →
drivers/weather/ - Auxiliary (Switch, CoverCalibrator, SafetyMonitor) →
drivers/auxiliary/
- Telescope →
- Use WebFetch to check
-
Search the INDI 3rd-party repository for a match:
- Use WebFetch to check
https://github.com/indilib/indi-3rdpartyfor a matching directory.
- Use WebFetch to check
-
Use fuzzy/partial matching — vendor names in INDI often differ from commercial names. Do NOT require an exact match. Instead:
- Match on substrings: "Touptek" should match "toupbase", "indi-toupbase", or "libtoupcam".
- Match on common abbreviations or SDK names: "ZWO" matches "asi", "indi-asi"; "PlayerOne" matches "indi-playerone"; "QHY" matches "indi-qhy".
- Try the vendor name, the vendor name lowercased, common SDK library names, and partial prefixes.
- Scan ALL directory/file names in the listing and flag anything that looks like a plausible match — err on the side of showing a possible match and asking the user to confirm rather than missing it.
- Known vendor-to-INDI mappings (non-exhaustive):
- Touptek → toupbase
- ZWO → asi
- PlayerOne → playerone
- Pegasus → pegasus
- iOptron → ioptron (note: iOptron is in indi core
drivers/telescope/, not 3rd-party) - Celestron → celestron
- Meade → lx200
- SynScan / Sky-Watcher → synscan, skywatcher
- If multiple possible matches are found, list them all and ask the user which one is correct.
-
If no match is found in INDI, search the INDIGO project as a fallback:
- Use WebFetch to check
https://github.com/indigo-astronomy/indigo/tree/master/indigo_driversfor a matching driver. - Apply the same fuzzy/partial matching rules as above — match on substrings, SDK names, and common abbreviations.
- INDIGO driver directories typically follow the pattern
indigo_<device_type>_<vendor>(e.g.indigo_ccd_asi,indigo_mount_ioptron). - Device type prefixes in INDIGO:
ccd(Camera),wheel(FilterWheel),focuser,mount(Telescope),dome,rotator,aux(Switch/CoverCalibrator/SafetyMonitor),guider,gps.
- Use WebFetch to check
-
Report what you found:
- If a match is found in INDI or INDIGO, tell the user: "I found a likely reference driver at [URL]. Can you confirm this is the right one?"
- If no match is found in either project, tell the user: "No reference driver found in INDI or INDIGO for this vendor. We'll need protocol documentation or an SDK to proceed."
-
If a match is confirmed, use WebFetch to read the driver source files and extract:
- Command/response protocol details (byte formats, command strings)
- Device capability flags and supported features
- Any known quirks or workarounds noted in the code
Store these findings and use them as a protocol reference throughout the implementation.
After all questions are answered, summarize the plan (including the decompiled vendor driver and/or any INDI reference found) and confirm with the user before proceeding.
Create the feature branch
Once the user confirms the plan, create a feature branch from main before writing any code:
git checkout main && git pull && git checkout -b driver/<vendor>-<device>
Branch naming convention: driver/<vendor-lowercase>-<device-or-model-lowercase>
Examples:
driver/touptek-aaf(ToupTek Automatic Astro Focuser)driver/pegasus-ppb(Pegasus Pocket Powerbox)driver/zwo-am5(ZWO AM5 mount)driver/qhy-camera(QHY camera driver)driver/ioptron-hem27-wifi(iOptron HEM27 Wi-Fi support)
For extending an existing vendor with a new device type, use the device/model name. For bug fixes to existing drivers, use a more descriptive name like driver/ioptron-pulse-guide-fix.
Confirm the branch name with the user before creating it.
Step 2 — Architecture rules (enforce strictly)
3-layer driver pattern
Every driver must follow:
- Alpaca interface —
AlpacaCore/include/alpacacore/<device>_driver.h(base class, already exists per device type) - Vendor wrapper header —
AlpacaCore/include/alpacacore/vendor/<vendor>/<vendor>_<device>_driver.h - Vendor implementation —
AlpacaCore/src/vendors/<vendor>/<vendor>_<device>_driver.cpp
Protocol wrapper / SDK wrapper (4th file — required)
Every driver in this project has a separate wrapper that isolates the raw hardware communication from the Alpaca driver logic. This is a consistent pattern — do not skip it.
For serial/network protocol devices (mounts, focusers, etc.), create a protocol wrapper:
AlpacaCore/include/alpacacore/vendor/<vendor>/<vendor>_protocol_wrapper.hAlpacaCore/src/vendors/<vendor>/<vendor>_protocol_wrapper.cpp- Handles: serial/TCP port open/close, raw command send/receive, response parsing, auto-detection/enumeration, baud rate config
- The driver
.cppcalls the protocol wrapper — it never touches serial ports or sockets directly
For SDK-based devices (cameras, etc.), create an SDK wrapper:
AlpacaCore/include/alpacacore/vendor/<vendor>/<vendor>_sdk_wrapper.hAlpacaCore/src/vendors/<vendor>/<vendor>_sdk_wrapper.cpp- Handles: SDK global init/release (as a singleton), camera/device enumeration, SDK lifecycle
- The driver
.cppcalls the SDK wrapper — it never calls raw SDK functions directly
Existing examples:
- Protocol wrappers:
synscan_protocol_wrapper,ioptron_protocol_wrapper,celestron_protocol_wrapper,bisque_protocol_wrapper,gemini_protocol_wrapper,zwo_mount_protocol_wrapper - SDK wrappers:
zwo_sdk_wrapper,qhy_sdk_wrapper,svbony_sdk_wrapper,playerone_sdk_wrapper,touptek_sdk_wrapper
AGPL license header (required on every new file)
Every new .h, .hpp, .cpp file must include the AGPL-3.0-or-later license header. Copy it from any existing source file in the same directory — do not write it from scratch or modify it.
Boundary rules
AlpacaCorehas NO HTTP/REST/sockets/JSON transport code.AlpacaHTTPhas NO vendor SDK usage and NO duplicated device logic.- Call flow:
AlpacaHTTP -> AlpacaCore driver -> vendor implementation. - Raw vendor SDK headers must NOT be included outside wrapper implementation files.
Code style
- C++20, RAII, small focused functions.
#pragma oncein headers.- Prefer
enum class,std::chrono,std::string_view. - No
using namespace std;in headers. - Fixed-width types (
int32_t,uint16_t) for hardware registers and protocol fields. - Use
doublefor floating-point protocol values (neverlong double). - License headers must remain AGPL-3.0-or-later and unmodified.
Architecture target
- Linux arm64 only. Targets include Raspberry Pi 3B+/4/5, Rockchip-based SBCs, OrangePi, and embedded astronomy computers (e.g. iOptron iMate). Do not assume Raspberry Pi — test portability across arm64 SoCs.
- 64-bit only. No 32-bit, no amd64/x86_64.
Step 3 — Implement the driver
ASCOM Alpaca API compliance (non-negotiable)
The driver MUST implement the official ASCOM Alpaca API specification exactly — to the letter. The spec is the contract: every property, method, parameter, return type, value range, error code, and behavior must match. Following the API "to a T" is non-negotiable — this is what keeps every AlpacaBridge driver aligned with ASCOM Alpaca and passing ConformU.
Primary reference (source of truth): the vendored spec at docs/AlpacaDeviceAPI_v1.yaml, which Step 0 just verified is current. Read it directly — do not work from memory of the API.
Human-readable cross-check (optional): https://ascom-standards.org/api/#/ renders the same spec in Swagger UI if you want to browse it visually.
Before writing any code, extract the exact contract for the device type from the vendored YAML. The common endpoints (every device implements these) use /{device_type}/...; the device-specific endpoints use /<devicetype>/...:
DEV=filterwheel # lowercase device type being built
# Every endpoint this driver must implement (common + device-specific):
grep -oE "^ '/[^']+'" docs/AlpacaDeviceAPI_v1.yaml | grep -E "^ '/(\{device_type\}|$DEV)/"
For each endpoint, open the relevant section of docs/AlpacaDeviceAPI_v1.yaml and read the HTTP verb, parameters, value ranges, response schema, and the documented error behavior. Implement against exactly what the YAML says — parameter names, casing, ranges, and the NotImplemented/NotConnected/InvalidValue semantics are all part of the contract.
For device behavior the YAML does not capture (capability gates, units, completion properties, error selection, ImageBytes, discovery), use the ascom-alpaca-protocol skill in .claude/skills/ascom-alpaca-protocol/, starting with its references/alpacabridge-integration.md.
The device-type API surfaces are:
- Camera: common methods +
Camera-specific endpoints - CoverCalibrator: common +
CoverCalibrator-specific endpoints - Dome: common +
Dome-specific endpoints - FilterWheel: common +
FilterWheel-specific endpoints (names,focusoffsets,position) - Focuser: common +
Focuser-specific endpoints - ObservingConditions: common +
ObservingConditions-specific endpoints - Rotator: common +
Rotator-specific endpoints - SafetyMonitor: common +
SafetyMonitor-specific endpoints - Switch: common +
Switch-specific endpoints - Telescope: common +
Telescope-specific endpoints
Key compliance rules:
- Every property and method listed in the API for the device type must be implemented. If the hardware doesn't support a capability, the method must still exist and throw the appropriate ASCOM error (e.g.,
PropertyNotImplemented,NotConnected,InvalidValue). - Return types and value ranges must match the spec exactly. RA is in hours (0-24), Dec in degrees (-90 to +90), angles in degrees, exposure in seconds, etc.
- Error codes must use the correct ASCOM error numbers: NotImplemented
0x400, InvalidValue0x401, NotConnected0x407, InvalidWhileParked0x408, InvalidOperation0x40B, etc. (seeAlpacaCore/include/alpacacore/alpaca_errors.h). CanXxxproperties must accurately reflect hardware capabilities. IfCanPulseGuidereturns true,PulseGuidemust work. If the hardware doesn't support it,CanPulseGuidemust return false andPulseGuidemust throwMethodNotImplemented.- Interface version must match the current ASCOM spec version for the device type — AlpacaBridge advertises Platform 7 versions: Camera 4, Telescope 4, Focuser 4, Rotator 4, FilterWheel 3, Switch 3, ObservingConditions 2 (see
AGENTS.md). - Common methods (
Action,CommandBlind,CommandBool,CommandString,SupportedActions) must be implemented on every device. - DeviceState must return a well-formed property bag with device-type-appropriate operational telemetry.
When in doubt about a public behavior, the official spec decides. Existing drivers in this project show local structure, and INDI/INDIGO can inform undocumented vendor wire protocols, but neither defines ASCOM behavior.
Match existing driver structure (cross-driver consistency)
Always study the existing drivers of the same device type before writing a new one, and match their structure, naming, shared infrastructure, and test patterns so every driver of a given type is built the same way. Derive public ASCOM behavior (capabilities, error codes, units, value ranges, and state transitions) from the official spec, not from another driver. If an existing driver disagrees with the spec, follow the spec and flag the driver. New drivers must not invent a divergent shape.
Find the closest matching existing driver for the same device type:
ls AlpacaCore/src/vendors/*/
ls AlpacaCore/include/alpacacore/vendor/*/
FilterWheel consistency (important). All filter wheel drivers must share the same filter
setup and UI as the existing ones — use the ZWO EFW (zwo_filterwheel_driver) and Player
One Phoenix Wheel drivers as the canonical templates. Specifically match:
- the slot-count handling and the default/standard filter name set,
NamesandFocusOffsetssemantics (array length tied to slot count, defaults, persistence),- the web UI slot lineup built with
createFilterwheelSlotUI({...})(see Step 6 and AGENTS.md).
A new filter wheel should differ from ZWO/Player One only where the hardware genuinely differs (slot counts offered, enumeration/SDK details) — never in how filters are named, stored, or presented. The same "match the existing drivers" rule applies to every device type (cameras follow the ZWO/QHY/SVBONY shape, telescopes follow iOptron/SynScan, etc.), but filter wheels are the most common place divergence slips in.
Reconcile both sources: where an existing driver and the spec appear to disagree, the spec
(docs/AlpacaDeviceAPI_v1.yaml) wins — and that likely means the existing driver has a bug to
fix across all drivers, per the project's "fix shared patterns everywhere" rule.
Required runtime semantics
- Async
connect()/disconnect()withget_connecting(). - Synchronous
set_connected()for compatibility. - Useful
get_device_state()telemetry. - Clean thread/task shutdown in destructors.
- Convert vendor failures to
AlpacaException. - Add TODO comments where vendor protocol/SDK behavior is uncertain.
Device auto-detection
Devices fall into two categories:
-
SDK-enumerated devices (e.g. ZWO, QHY, SVBONY, PlayerOne, ToupTek) — the vendor SDK provides an enumeration API that lists connected cameras/focusers/etc. by index or ID. These are "instant on" — the user picks a
cameraIndexorcameraIdin the config and the SDK handles discovery. No auto-detection scan needed. -
Serial/network protocol devices (e.g. iOptron, SynScan, Celestron, Gemini, Bisque) — there is no SDK enumeration. The driver must implement auto-detection to find the device. Auto-detection is required for these devices.
If the device uses serial or network communication (determined from Question 4), implement auto-detection in the protocol wrapper:
USB/Serial auto-detection
- Implement an
enumerate_<vendor>_ports()function in the protocol wrapper that returns a list of candidate ports with metadata. - Scan
/dev/serial/by-id/first (stable names survive replug), then fall back to/dev/ttyUSB0through/dev/ttyUSB9. - Filter by known USB-serial chip vendor IDs where possible (Prolific
067b, FTDI0403, CP210x/Silicon Labs10c6, CH340/CH3411a86). - Probe each candidate port with the device's firmware/version/identity query command.
- Connect to the first port that responds with a valid reply.
- Handle the DTR/HUPCL quirk for CH340/CH341 adapters (clear HUPCL before closing during probe to prevent MCU reset — see Lessons section).
Network/Wi-Fi auto-detection
- Probe well-known IP addresses and ports for the vendor's Wi-Fi module (e.g. iOptron:
10.10.100.254,10.10.100.1,192.168.100.1on ports 8899 and 4030). - Try the default gateway on each local network interface.
- If no known addresses work, scan all hosts on local subnets (up to /24) with parallel non-blocking TCP connect probes.
- Verify each candidate with the device's identity/version command before accepting the connection.
Connection type config
The driver should support a connectionType config field with these options:
"auto"(default) — scan for the device automatically using the appropriate method (USB or network based on what's available)."serial"— connect to a specific serial port (user provides path)."network"— connect to a specific IP/port (user provides host and port).
The Web UI (Step 6) should include a connection type selector matching this pattern. Use the iOptron or Celestron web UI config as a reference — they have Auto-Detect/Serial/Network dropdowns with conditional fields.
Units
- Exposure: seconds. Angles: degrees. RA: hours. Dec: degrees.
- Pixel size: microns. Time: UTC with
std::chrono.
Step 4 — CMake integration
Update AlpacaCore/CMakeLists.txt:
- Add
option(ALPACACORE_ENABLE_<VENDOR> ...). - Update
ALPACACORE_ENABLE_ALL_VENDORSlogic (if it exists). - Add conditional
add_subdirectory(src/vendors/<vendor>)+target_link_libraries(alpacacore_builtins PRIVATE alpacacore_<vendor>)(never link a vendor intoalpacacore; the vendor's own CMakeLists linksalpacacorePRIVATE, #710). A vendor whose descriptor factoryregister_builtin_factories()registers (AlpacaCore/src/catalog/builtin_catalog.cpp) also needstarget_compile_definitions(alpacacore_builtins PRIVATE ALPACACORE_ENABLE_<VENDOR>)next to that link, or the registration compiles empty with no build error (see the Astroasis block inAlpacaCore/CMakeLists.txt). - Add install rules for vendor target.
Create AlpacaCore/src/vendors/<vendor>/CMakeLists.txt for the vendor target.
If the vendor has an SDK with libraries:
- Store the arm64 subset only under
AlpacaCore/external/<VENDOR>/(commonlylib/linux/armv8/,lib/linux/arm64/, orlib/armv8/— check existing vendor SDKs for the exact naming the upstream uses). - Do not commit x86_64/x64/amd64 SDK binaries — AlpacaBridge is arm64-only and they would only bloat the repo.
- Add
!external/<VENDOR>/**toAlpacaCore/.gitignore. - If using pkg-config, prefer imported targets.
SDK cleanup (MANDATORY before committing)
Vendor SDKs ship with files we don't need that bloat the repo. After the user places the SDK in AlpacaCore/external/<VENDOR>/, audit the contents and remove everything that isn't required for the Linux arm64 build.
Scan the SDK directory and remove:
- Windows files:
*.dll,*.lib,*.exp,*.pdb,*.exe, anywin/,win32/,win64/,windows/directories - macOS files:
*.dylib,*.framework, anymac/,macos/,osx/directories - Non-arm64 Linux libraries: any
x64/,x86_64/,amd64/,linux64/,x86/,i386/,armv6/,armv7/,armhf/directories (we are arm64 only) - Demo/sample apps:
demo/,sample/,example/,test/directories, pre-built demo binaries - IDE project files:
*.sln,*.vcxproj,*.xcodeproj,*.xcworkspace - Documentation we don't need:
*.pdf,*.doc,*.docx,*.chm(unless it's the protocol spec we're using — ask the user) - Redundant packaging:
*.tar.gz,*.zip,*.rpm,*.debinside the SDK - Python/Java/C# bindings: unless the driver needs them (AlpacaBridge is C++ only)
- Obsolete SDK versions: if the SDK ships multiple versions, keep only the one we're building against
Keep:
- Linux arm64/armv8 libraries (
.a,.so,.so.*) - C/C++ header files (
*.h,*.hpp) needed for compilation - Udev rules (
*.rules) - Firmware files if required by the device (e.g. QHY
.img/.HEXfiles) - License/copyright files (legal requirement)
- Protocol documentation if it's our primary reference
After cleanup, list what was removed and what was kept, and confirm with the user before committing. Check an existing lean SDK (e.g. AlpacaCore/external/SVBONY/ or AlpacaCore/external/PlayerOne/) as a reference for what a clean SDK directory looks like.
Step 5 — Shared library packaging (if vendor has .so files)
Camera vendors MUST ship .so in the .deb — non-negotiable. Non-camera vendors also ship .so if the SDK provides one.
Checklist:
- Store
.aand.sounderAlpacaCore/external/<VENDOR>/in the arm64 subdir the upstream SDK uses. - Add
!external/<VENDOR>/**toAlpacaCore/.gitignorebefore committing SDK files. - Update
debian/rules— copy.so*to$(STAGING)/usr/lib/alpacabridge/. - Update
build_and_run.sh— copy.so*to/usr/local/lib/, runldconfig. - Update
install_alpaca_service.sh— same asbuild_and_run.sh, keep in sync.
Step 6 — AlpacaHTTP integration
All 9 steps required for end-to-end functionality:
- Router registration in
AlpacaHTTP/src/http/router.cpp. - Router includes with
#ifdef ALPACACORE_ENABLE_<VENDOR>guard. - Config sanitization fields preserved.
- Web UI vendor dropdown in
AlpacaHTTP/web/app.js. - Web UI vendor-specific form fields.
- Unique vendor-prefixed
nameon EVERY field (not just filter wheels): hidden vendor sections still submit, so a genericnamelikecameraIndex/focuserIndexcollides inFormDatawith ZWO's same-named field (first in DOM wins) and the value you typed is silently dropped —0gets saved. Name non-ZWO fields likeplayerOneCameraIndex,qhyCameraIndex,touptekFocuserIndex, and read that exact name in the submit handler. Elementids stay descriptive; only thenamemust be unique. (ZWO keeps the bare names as the canonical first block.) - FilterWheel vendors: the form MUST include the standard slot UI — a slot-count
select listing the manufacturer's actual lineup (from Question 4b) plus Custom,
per-slot filter name dropdowns, and the advanced names textarea. Instantiate
createFilterwheelSlotUI({...})inapp.jswith vendor-prefixed element IDs and copy the markup pattern from an existing filterwheel vendor inindex.html.
- Unique vendor-prefixed
- Web UI index auto-numbering — for any SDK-enumeration index field (camera/focuser/
filterwheel/rotator), add an entry to the
INDEX_FIELDSarray inapp.js(fieldId,vendor,deviceType,configKey, optionalidFieldId). That wires up per-(vendor, deviceType)auto-increment and manual-edit tracking, so a second device of the same vendor/type doesn't silently reuse index 0. (Distinct from the Alpaca device number, which auto-assigns per type already.) See "Enumeration index fields" in AGENTS.md. - Frontend validation logic.
- Build-flag propagation from AlpacaCore to AlpacaHTTP.
- Routing/config tests in
AlpacaHTTP/tests/.
Step 7 — Tests (MANDATORY — do not skip)
This step is non-negotiable. Every driver MUST ship with Catch2 unit tests. Do NOT consider the driver complete, do NOT move to Step 8, and do NOT declare the implementation finished until tests are written, building, and passing. If the user asks to skip tests, push back — explain that every driver in this project has tests and skipping them has caused regressions in the past.
Test file
Create AlpacaCore/tests/test_<vendor>_<device>.cpp.
Use an existing test file as a template — pick the one closest to your device type:
ls AlpacaCore/tests/test_*_*.cpp
Reference examples by device type:
- Camera →
test_svbony_camera.cpp,test_zwo_camera.cpp,test_qhy_camera.cpp,test_playerone_camera.cpp,test_touptek_camera.cpp - Telescope →
test_synscan_telescope.cpp,test_ioptron_telescope.cpp - Focuser →
test_gemini_focuser.cpp,test_zwo_focuser.cpp - FilterWheel →
test_zwo_filterwheel.cpp - Rotator →
test_zwo_rotator.cpp - Switch →
test_zwo_switch.cpp - ObservingConditions →
test_weewx_observingconditions.cpp
Required test structure
Every test file must include:
#include "catch2_compat.h"
#include <alpacacore/vendor/<vendor>/<vendor>_<device>_driver.h>
Required test cases (minimum 8)
All 8 are mandatory. Read the existing tests to match the exact patterns used in this project.
-
Defaults
"<Vendor> <Device> Driver - Defaults"[<vendor>][<device>][unit]- Create driver with device number 0.
REQUIRE(driver.get_device_type() == alpacacore::DeviceType::<Type>);REQUIRE(driver.get_device_number() == 0);REQUIRE(driver.get_connected() == false);CHECKthe default device name.CHECKany static capability flags relevant to the device type (e.g.get_can_abort_exposure,get_can_reverse,get_absolute,get_can_pulse_guide).
-
Device metadata
"<Vendor> <Device> Driver - Device metadata"[<vendor>][<device>][unit]- Create driver with a non-zero device number (e.g. 3) so
get_unique_id()is distinguishable from device 0. CHECKall of:get_device_number,get_description,get_driver_info,get_driver_version,get_interface_version,get_unique_id.- String values must match the driver implementation exactly — read the driver source to get the correct strings. Do NOT guess.
- Create driver with a non-zero device number (e.g. 3) so
-
Not connected throws with correct error code
"<Vendor> <Device> Driver - Not connected throws"[<vendor>][<device>][unit]- Verify that operations requiring a live connection throw
alpacacore::AlpacaExceptionwitherror_code() == AlpacaError::NotConnected. - ConformU checks the Alpaca error number, not just that it throws — a generic
DriverExceptionerror code will fail ConformU. - Cover the device's primary operations. Examples by device type:
- Camera:
get_gain,start_exposure,get_image_array - Telescope:
get_right_ascension,get_tracking,slew_to_target_async - Focuser:
get_position,move,get_temperature - FilterWheel:
get_position,set_position - Rotator:
get_position,move_absolute - Switch:
get_switch,set_switch
- Camera:
- Use this pattern to verify both the exception type and error code:
try { driver->get_right_ascension(); FAIL("Expected AlpacaException"); } catch (const alpacacore::AlpacaException& e) { REQUIRE(e.error_code() == alpacacore::AlpacaError::NotConnected); }
- Verify that operations requiring a live connection throw
-
Unsupported actions
"<Vendor> <Device> Driver - Unsupported actions"[<vendor>][<device>][unit]CHECK(driver.get_supported_actions().empty());(unless the driver defines custom actions)CHECK(driver.can_action("anything") == false);- Assert the exact codes with the
require_alpaca_errorhelper (case 6), notCHECK_THROWS_AS(..., alpacacore::AlpacaException), which passes for any code:action()with an unlisted name isActionNotImplemented(0x40C), and the threecommand_*methods areMethodNotImplemented(0x400) unless the driver implements command pass-through. A pass-through driver checks the connection first, so disconnected it throwsNotConnected(0x407): assert that code for its threecommand_*calls instead of theMethodNotImplementedlines below.require_alpaca_error([&]() { driver.action("test", ""); }, alpacacore::AlpacaError::ActionNotImplemented); require_alpaca_error([&]() { driver.command_blind("test", false); }, alpacacore::AlpacaError::MethodNotImplemented); require_alpaca_error([&]() { driver.command_bool("test", false); }, alpacacore::AlpacaError::MethodNotImplemented); require_alpaca_error([&]() { driver.command_string("test", false); }, alpacacore::AlpacaError::MethodNotImplemented);
-
Device-specific behavior
[<vendor>][<device>][unit]At least one test covering behavior unique to the device type. Examples:- Camera: sub-exposure support (
get_sub_exposure_duration/set_sub_exposure_durationthrow if unsupported). - Telescope: target coordinate persistence (set RA/Dec, read back, verify independence), site property validation (elevation [-300,10000], latitude [-90,90], longitude [-180,180]), telescope properties (
EquatorialSystem,AlignmentMode,TrackingRatesnon-empty,SlewSettleTime>= 0), axis rate ranges. - Focuser:
get_absolute,get_temp_comp_available, device state telemetry shape. - FilterWheel: names/offsets defaults, invalid position handling.
- Switch:
get_max_switch, invalid switch ID handling. - Rotator:
get_can_reverse, device state telemetry shape. - ObservingConditions:
get_average_period, sensor description throws.
- Camera: sub-exposure support (
ASCOM contract tests (ConformU alignment)
These three test cases catch the bugs that cause ConformU failures. They run without hardware and verify that the driver follows the ASCOM Alpaca specification at the error code and state machine level — not just "does it throw?"
-
Value range validation
"<Vendor> <Device> Driver - Value range validation"[<vendor>][<device>][unit]- Invalid inputs must throw
AlpacaExceptionwitherror_code() == AlpacaError::InvalidValue, not silently normalize or throw a generic error. - ConformU specifically tests boundary values. Device-type examples:
- Telescope: RA outside [0, 24), Dec outside [-90, 90], site elevation outside [-300, 10000], site latitude outside [-90, 90], site longitude outside [-180, 180],
SlewSettleTimenegative,PulseGuidewith negative duration - Camera:
StartExposurewith negative duration,BinX/BinYoutside [1, MaxBinX/MaxBinY],Gainoutside [GainMin, GainMax] - Focuser:
MovebeyondMaxStep,Movewith negative position - Rotator:
MoveAbsoluteoutside [0, 360) - Switch: operations with switch ID outside [0, MaxSwitch)
- Telescope: RA outside [0, 24), Dec outside [-90, 90], site elevation outside [-300, 10000], site latitude outside [-90, 90], site longitude outside [-180, 180],
- All test files use the
require_alpaca_errorhelper to verify both exception type and error code:require_alpaca_error([&]() { driver->set_target_right_ascension(-0.1); }, alpacacore::AlpacaError::InvalidValue); require_alpaca_error([&]() { driver->set_site_elevation(10000.1); }, alpacacore::AlpacaError::InvalidValue); - Note: some drivers check connection before validating values (check the driver; iOptron now validates static ranges first). Only test value validation for operations that work disconnected — check existing tests for the same device type.
- Invalid inputs must throw
-
State machine contracts
"<Vendor> <Device> Driver - State machine"[<vendor>][<device>][unit]- Verify that device state follows ASCOM rules without needing hardware.
- Camera:
CameraStateisIdlebefore any exposure,ImageReadyis false (or throws NotConnected) before any exposure,IsPulseGuidingis false when idle. - Telescope:
Slewingis false when not connected,IsPulseGuidingis false when idle,Trackingdefault state is correct. - Focuser:
IsMovingis false when idle. - These state machine tests have caught real bugs in past drivers:
- iOptron: settle loop prematurely declared slews complete
- SynScan:
IsPulseGuidingalways returned false - SVBONY:
CameraStategot stuck after SDK hangs
-
Unsupported method error codes
"<Vendor> <Device> Driver - Unsupported methods"[<vendor>][<device>][unit]- Methods the device doesn't support must throw
AlpacaExceptionwithAlpacaError::MethodNotImplemented(orPropertyNotImplementedfor a property, both 0x400), NOT a genericDriverException.InvalidOperation(0x40B) is for a supported member called in a state where it can't currently run, not for a member the hardware lacks — see AGENTS.md's ASCOM exception vocabulary table.Action()called with a name not inSupportedActionsthrowsActionNotImplemented(0x40C) instead — a distinct code, notMethodNotImplemented. - ConformU distinguishes between "not implemented" and "driver error" — the wrong error code fails validation.
- Examples:
- Telescope without
CanSyncAltAz:SyncToAltAzmust throw with correct code - Camera without shutter:
HasShutterreturns false, operations should behave accordingly - Focuser without temp comp:
set_temp_comp(true)must throw with correct code
- Telescope without
- Methods the device doesn't support must throw
Step 7b — The test seam and the [stress] registration (MANDATORY, same weight as the 8 cases)
The unit cases above run without hardware because they never connect. Concurrency is the #1 driver-review bug class (use-after-close, a dropped racing disconnect, destructor versus connection thread), ConformU is single-threaded, and the only thing that exercises those paths automatically is the [stress] lifecycle storm under ThreadSanitizer (sanitizers-tsan in CI, RUN_TSAN=1 ./scripts/ci_preflight.sh locally). A storm needs a hardware-free connect, and a connect needs a seam. Build both now, not later: every driver that landed without them went onto a list that nine later PRs had to work back down.
1. The seam. Pick the shape by what the driver talks to:
- Vendor SDK (camera, filter wheel, anything linking a vendor
.so): the abstract-interface pattern. An abstract<Vendor>SDKinterface, the production wrapper implementing it, a<Vendor>SDK&-taking overload on everycreate_<vendor>_*factory beside the existing one, a scripted fake intests/fake_<vendor>_sdk.h, and a mutex-taking decorator intests/locked_<vendor>_sdk.hthat wraps the fake wherever it is injected, so a TSan report points at driver code rather than at the fake. The one exception today is Player One:FakePlayerOneSDKtakes its own mutex in every method, so it has nolocked_playerone_sdk.hand its stress case and contract-sweep host use the fake directly. A new seam gets the decorator; if that internal mutex is ever removed, Player One needs one too. Worked examples: ToupTek (ToupTekSDK,tests/fake_touptek_sdk.h,tests/locked_touptek_sdk.h) shows the seam and[stress]registration; QHY has the same seam and a registered connected-camera stress case inAlpacaCore/tests/qhy_camera_concurrency_stress.cpp. Read AGENTS.md's QHY fake-SDK constraints before copying its camera worker path. Do not write a mock that subclasses the production wrapper: it is declaredfinal, so the subclass will not build. Subclass the abstract<Vendor>SDKinterface instead. - Serial or network protocol (mounts, focusers, hubs): a fake device on the other end of the real transport. A pseudo-terminal fake for serial (
tests/fake_gemini_focuser.h,tests/fake_skywatcher_serial_board.h;PtyPairintests/fake_pty_write.howns the pty), a loopback server for TCP/UDP (tests/fake_mount_server.h,tests/fake_skywatcher_mount.h). The driver connects through its real protocol wrapper, so the wrapper's own threads and timeouts are what get stormed.
A driver whose seam cannot be built yet (no fake is possible without the hardware in hand) still registers: with every connect failing fast at enumeration the storm exercises the AsyncConnectable machinery, the failure-path cleanup and the getters racing the lifecycle, which is where this driver family's findings have lived. Say so in the file's header comment, the way test_zwo_concurrency_stress.cpp does.
2. The registration. Create AlpacaCore/tests/test_<vendor>_concurrency_stress.cpp (the _concurrency_stress.cpp suffix is what scripts/check_stress_registration.py globs for; the test_ prefix is convention, not enforced), built on tests/concurrency_stress.h: a factory returning the driver, an operate callback exercising its operational surface with every call wrapped in StressCallGuard (run_lifecycle_stress wraps the whole callback in one try/catch, so without a per-call guard the first throw skips every call after it, and a hand-rolled local try/catch is rejected by the gate because it loses the guard's counting), then the scenarios: run_lifecycle_stress, run_destruction_during_connect_stress, and the racing-disconnect cases. Each TEST_CASE carries [<vendor>][<device>][stress]; the file ends each case with INFO(guard.report()); CHECK(guard.unexpected_count() == 0); CHECK(guard.total_calls() > 0);. Copy the closest existing registration (ls AlpacaCore/tests/test_*_concurrency_stress.cpp) and read concurrency_stress.h's header comment first; both the file naming and the guard idiom are gated, so the check will tell you exactly what is missing.
3. Do not add an ALLOWLIST entry. scripts/check_stress_registration.py fails a driver with no [stress] case and points you back at this step. The ALLOWLIST it mentions once held the drivers that predated the gate; every one has since been registered, so it is empty and must stay that way. A new driver built from this command has no reason to be on it; if you find yourself adding one, the seam in part 1 is the work you skipped.
4. Gate it in CMake. The stress file goes in the same if(TARGET alpacacore_<vendor>) block as the unit file (below); an unconditional listing is itself a gate failure, because a [stress] case that compiles with the vendor absent satisfies the TSan zero-test grep vacuously (issue #396).
CMake integration (also mandatory)
Update AlpacaCore/tests/CMakeLists.txt:
- Add
test_<vendor>_<device>.cppandtest_<vendor>_concurrency_stress.cppto the conditionalTEST_SOURCESlist, guarded byif(TARGET alpacacore_<vendor>). - Add
target_link_libraries(alpacacore_tests PRIVATE alpacacore_<vendor>)in the matching conditional block.
Follow the existing pattern in the file — read it first:
cat AlpacaCore/tests/CMakeLists.txt
Build and run the tests
After writing the tests, build and run them immediately. Do not defer this:
cd AlpacaCore && cmake -B build-vendors -DALPACACORE_ENABLE_<VENDOR>=ON && cmake --build build-vendors --target alpacacore_tests && ./build-vendors/tests/alpacacore_tests "[<vendor>]"
If any test fails, fix it before proceeding. The test tag filter "[<vendor>]" runs only the new vendor's tests for faster iteration.
Build into build-vendors, not build. The -D alone is not enough after a pre-flight: since
#588 the sanitized pass is the last thing to configure AlpacaCore/build, and CMake seeds
CMAKE_CXX_FLAGS from CXXFLAGS on a directory's first configure and then caches it -- so
reconfiguring build keeps -fsanitize=address,undefined no matter what you pass, and you
iterate against a slow, LSan-enabled binary with the ALPACACORE_TESTS_SANITIZED cases skipped.
(The -D is belt-and-braces on a fresh directory, where ALPACACORE_ENABLE_ALL_VENDORS defaults
ON anyway -- AlpacaCore/CMakeLists.txt:22. It earns its keep on a build-vendors left from an
earlier vendors-OFF configure, where the umbrella option is cached OFF.)
Assertion count target
Aim for at least 30 assertions across the 8 test cases. The ASCOM contract tests (cases 6-8) should add 10-15 assertions on top of the basic 5 cases. If you have significantly fewer, you're probably not testing enough error codes and state transitions.
Step 8 — Full build and verify
Build the complete test suite (not just the new vendor) to catch any regressions:
cd AlpacaCore && cmake -B build-vendors -DALPACACORE_ENABLE_<VENDOR>=ON && cmake --build build-vendors --target alpacacore_tests && ./build-vendors/tests/alpacacore_tests
All tests must pass — not just the new driver's tests. build-vendors rather than build for the
reason given in Step 7: this is the regression check, so it is the last place you want it run
against a leftover sanitized binary that skips the ALPACACORE_TESTS_SANITIZED cases.
Then run the registration gate locally, exactly as CI and the pre-flight do, so a missing or misplaced [stress] case is caught here rather than in review:
cd "$(git rev-parse --show-toplevel)" && python3 scripts/check_stress_registration.py --self-test && python3 scripts/check_stress_registration.py
(The cd matters: the build block above leaves the shell in AlpacaCore/, and from there the script's git ls-files sees no driver and reports every ALLOWLIST entry as stale.)
It must print Stress-test registration OK and the new (vendor, device type) pair must not be on the ALLOWLIST (Step 7b). Run the storm itself under ThreadSanitizer before opening the PR: RUN_TSAN=1 ./scripts/ci_preflight.sh. A bare ./scripts/ci_preflight.sh does NOT build a TSan binary (TSan is still an opt-in — unlike the ASan+UBSan pass, which runs by default since #588 and is skipped with RUN_SANITIZERS=0), so the [stress] cases would only have run in the ordinary test build.
Step 9 — Vendor-specific notes
Before implementing, read the applicable .github/instructions/<vendor>.instructions.md file. Find it with:
rg --files .github/instructions | rg "/<vendor>\.instructions\.md$"
Read the entire file; vendor sections are no longer in AGENTS.md.
Apply any vendor-specific quirks, workarounds, or conventions documented there.
Step 10 — ConformU validation (MANDATORY — Linux arm64)
SAFETY: never run the telescope ConformU suite with an OTA mounted. The suite slews at maximum rate to targets halfway to the horizon, aborts mid-slew, and forces meridian flips; mid-slew arcs can dip to ~5 degrees altitude. Validate on a bare mount. See /conformu for the full hard rule.
After the driver builds and unit tests pass, the user MUST run ConformU against the driver with real hardware on Linux arm64, the only supported target. A driver is NOT complete until it has arm64 ConformU results.
ConformU: https://github.com/ASCOMInitiative/ConformU — the official ASCOM conformance test suite. Use 4.5.1+ on arm64; 4.5.0 has a known arm64 timing defect (see AGENTS.md).
Required validation matrix
Every driver must be validated on arm64:
| Platform | Architecture | Example hardware |
|---|---|---|
| Linux arm64 | 64-bit ARMv8 | Raspberry Pi 4/5, OrangePi, Rockchip SBCs, iOptron iMate |
arm64 only — no amd64/x86_64, no 32-bit (x86, armv7, armhf).
arm64 is not just Raspberry Pi — the project supports a growing range of arm64 SBCs and embedded astronomy computers. See the OpenAstro docs for the current list: https://www.openastro.net/docs/sbc-install/overview
Validation steps
-
Build AlpacaBridge with the new driver enabled.
-
Start AlpacaBridge with the new driver configured and connected to real hardware.
-
Run ConformU against the device. Target: 0 errors, 0 issues, 0 timing issues.
-
Verify the Timing Summary at the end of the ConformU log — this is a separate pass criterion from the main "no errors, warnings or issues found" message. A clean run MUST satisfy all of:
- No
OUTSIDE FAST RESPONSE TIME TARGETlines - No
OUTSIDE STANDARD RESPONSE TIME TARGETlines - No
OUTSIDE EXTENDED RESPONSE TIME TARGETlines - No trailing
N member(s) took longer than its target response time.summary line - If a JSON report (
conform.report.txt) is produced,"TimingIssuesCount": 0
Per-spec response time targets (reported in the Timing Summary header):
- FAST — 0.1 seconds (configuration and state reporting members, e.g.
Name,Connected,CanXxx, position reads) - STANDARD — 1.0 second (property writes and asynchronous initiators, e.g.
Tracking Write,SlewToCoordinatesAsync) - EXTENDED — 600.0 seconds (synchronous methods,
ImageArray,ImageArrayVariant)
Quick check after a run:
grep -E "OUTSIDE (FAST|STANDARD|EXTENDED) RESPONSE TIME TARGET|took longer than its target response time" <conformu.log> grep '"TimingIssuesCount"' <conform.report.txt> # if JSON report is producedBoth must return either nothing or
"TimingIssuesCount": 0.If any member exceeds its target, treat it as a failure. Common root causes (see "Common ConformU failure patterns" below for the full list):
- Slow SDK control writes — defer to start of operation or cache
- Network I/O while holding the driver mutex — release before I/O, re-acquire after
- Repeated round-trips for cached values (RA/Dec, capabilities) — add a short TTL cache
- First-call SDK warm-up on
Name/identity reads — warm up at connect
- No
-
Save the ConformU results to
AlpacaCore/conformu/<vendor>/<model>/Linux-arm64.txt.- If the device supports multiple connection types, save separate results:
Linux-arm64-usb.txt,Linux-arm64-wifi.txt. - If ConformU also produced a JSON report, save it alongside the text log (e.g.
Linux-arm64.report.json) so the timing data is preserved.
- If the device supports multiple connection types, save separate results:
-
If ConformU reveals failures (errors, issues, OR timing issues), fix them and re-run until clean.
After validation — update SUPPORTED-DRIVERS.md (MANDATORY)
Read the current SUPPORTED-DRIVERS.md and add or update the entry for the new driver. Follow the existing format. Required information:
-
Driver table entry — add a row in the appropriate device type section with:
- Model name
- Connection type (USB, Wi-Fi, Serial, TCP)
- ConformU link (relative path to the results file)
- Validated platform (Linux arm64)
- ConformU version used
-
Driver Notes section — add or update the vendor's driver notes with:
- SDK version or protocol spec version
- Tested firmware version(s) and model(s)
- Connection types tested
- Any notable quirks or limitations for end users
- Verified OS/Architecture list
-
Also update the applicable
.github/instructions/<vendor>.instructions.mdwith vendor-specific quirks or workarounds discovered during validation.
Common ConformU failure patterns from past drivers
- SideOfPier returning raw mount value instead of ASCOM convention (hour-angle based).
- IsPulseGuiding always returning false instead of tracking actual pulse state.
- Slew completion declared too early via position tolerance shortcuts — trust the mount's status register.
- MoveAxis tertiary axis (axis=2) should throw
InvalidValue, notMethodNotImplemented. - AxisRates returning wrong structure — must be vector of rate ranges per ASCOM spec.
- Property writes after alignment corrupting mount pointing model (site lat/lon, UTC date).
- SDK control writes taking >1 second and blowing ASCOM client timing budgets — defer to start of operation.
- Wi-Fi vs USB differences: a driver that passes ConformU over USB may fail over Wi-Fi due to timing, stale bytes, or mutex contention. Test both connection types if the device supports them.
Lessons from past driver implementations
These are recurring issues that have burned
Truncated - read the full file at https://github.com/open-astro/AlpacaBridge/blob/785ad46dbcf46ac404437ddcdd81f0f681edf2d0/.claude/commands/driver-build.md.
