# ascom-alpaca-core
[](https://crates.io/crates/ascom-alpaca-core)
[](https://docs.rs/ascom-alpaca-core)
[](https://github.com/nightwatch-astro/ascom-alpaca-core/actions)
[](#license)
Framework-agnostic ASCOM Alpaca protocol types and traits for Rust.
Provides the complete protocol abstraction for all 10 ASCOM device types (~220 methods) without any HTTP framework, async runtime, or networking dependency. Works on ESP32 (via esp-idf) and desktop alike.
## Quick Start
```bash
cargo add ascom-alpaca-core
```
Implement a device:
```rust
use ascom_alpaca_core::prelude::*;
struct MySafetyMonitor {
wind_speed: f64,
mount_powered: bool,
}
impl Device for MySafetyMonitor {
fn static_name(&self) -> &str { "Observatory Safety" }
fn unique_id(&self) -> &str { "safety-001" }
fn device_type(&self) -> DeviceType { DeviceType::SafetyMonitor }
fn connected(&self) -> AlpacaResult<bool> { Ok(true) }
fn set_connected(&self, _v: bool) -> AlpacaResult<()> { Ok(()) }
fn description(&self) -> AlpacaResult<String> { Ok("Observatory safety monitor".into()) }
fn driver_info(&self) -> AlpacaResult<String> { Ok("my-safety v1.0".into()) }
fn driver_version(&self) -> AlpacaResult<String> { Ok("1.0.0".into()) }
fn interface_version(&self) -> AlpacaResult<i32> { Ok(3) }
fn name(&self) -> AlpacaResult<String> { Ok("Observatory Safety".into()) }
}
impl SafetyMonitor for MySafetyMonitor {
fn is_safe(&self) -> AlpacaResult<bool> {
Ok(self.wind_speed < 30.0 && self.mount_powered)
}
}
```
## Core Concepts
### Device Traits
Every ASCOM device type is a Rust trait extending `Device`. The `Device` trait provides common properties (name, description, connected state, etc.), and each device trait adds type-specific methods.
All trait methods have default implementations returning `NotImplemented`, so you only override what your hardware supports:
```rust
impl Camera for MyCamera {
// Required: what your camera actually does
fn camera_xsize(&self) -> AlpacaResult<i32> { Ok(4656) }
fn camera_ysize(&self) -> AlpacaResult<i32> { Ok(3520) }
fn start_exposure(&self, duration: f64, light: bool) -> AlpacaResult<()> {
// your exposure logic
Ok(())
}
// Optional: returns NotImplemented by default
// fn can_pulse_guide(&self) -> AlpacaResult<bool> { ... }
// fn pulse_guide(&self, direction: GuideDirection, duration: i32) -> AlpacaResult<()> { ... }
}
```
### Response Envelopes
All Alpaca endpoints return JSON with PascalCase field names, error codes, and transaction IDs:
```rust
// Value-returning endpoint (GET)
let response = AlpacaResponse::ok(42.5)
.with_transaction(client_tx, server_tx);
// {"Value":42.5,"ErrorNumber":0,"ErrorMessage":"","ClientTransactionID":1,"ServerTransactionID":1}
// Error response
let response = AlpacaResponse::<bool>::from_error(
AlpacaError::InvalidValue("Temperature out of range".into())
).with_transaction(client_tx, server_tx);
// {"ErrorNumber":1025,"ErrorMessage":"Temperature out of range","ClientTransactionID":1,"ServerTransactionID":1}
// Void endpoint (PUT)
let response = MethodResponse::ok().with_transaction(client_tx, server_tx);
// {"ErrorNumber":0,"ErrorMessage":"","ClientTransactionID":1,"ServerTransactionID":1}
```
### Device Registry
The registry stores heterogeneous devices and provides typed lookup. Device numbers are assigned automatically per type (first Camera = 0, second Camera = 1, etc.):
```rust
let mut registry = DeviceRegistry::new();
let sm: Box<dyn SafetyMonitor> = Box::new(MySafetyMonitor { /* ... */ });
let cam: Box<dyn Camera> = Box::new(MyCamera { /* ... */ });
registry.register(sm);
registry.register(cam);
// For management API
let devices = registry.configured_devices();
// Typed lookup
let cam = registry.get_camera(0)?; // &dyn Camera
let sm = registry.get_safety_monitor(0)?; // &dyn SafetyMonitor
// Generic lookup (any device type)
let dev = registry.get_device(DeviceType::Camera, 0)?; // &dyn Device
```
### Error Handling
ASCOM errors are protocol-level (HTTP 200 with error in JSON body), not HTTP errors:
```rust
use ascom_alpaca_core::types::{AlpacaError, AlpacaResult};
fn set_temperature(&self, temp: f64) -> AlpacaResult<()> {
if temp < -273.15 {
return Err(AlpacaError::InvalidValue("Below absolute zero".into()));
}
if !self.is_connected() {
return Err(AlpacaError::NotConnected("Camera not connected".into()));
}
// ...
Ok(())
}
```
| `NotImplemented` | 0x400 | Method not supported by this hardware |
| `InvalidValue` | 0x401 | Parameter out of valid range |
| `ValueNotSet` | 0x402 | Required value not yet assigned (e.g., target coordinates) |
| `NotConnected` | 0x407 | Device not connected |
| `InvalidWhileParked` | 0x408 | Telescope/dome operation while parked |
| `InvalidWhileSlaved` | 0x409 | Operation while dome is slaved |
| `InvalidOperationException` | 0x40B | General invalid state |
| `ActionNotImplemented` | 0x40C | Custom action not recognized |
| `OperationCancelled` | 0x40E | Async operation was cancelled |
| `DriverError` | 0x500-0xFFF | Hardware-specific errors |
### Discovery & Management
```rust
use ascom_alpaca_core::discovery::*;
// UDP discovery constants
let port = DEFAULT_DISCOVERY_PORT; // 32227
let probe = DISCOVERY_PROBE; // "alpacadiscovery1"
let ipv6 = IPV6_MULTICAST; // ff12::a1:9aca
// Management API types
use ascom_alpaca_core::management::*;
let description = ServerDescription {
server_name: "My Alpaca Server".into(),
manufacturer: "My Company".into(),
manufacturer_version: "1.0.0".into(),
location: "Backyard Observatory".into(),
};
```
### Transaction Tracking
```rust
let counter = TransactionCounter::new();
let server_tx = counter.next(); // thread-safe atomic increment
let tracker = ClientTracker::new();
tracker.record_client(client_id);
```
## Device Types
| [`SafetyMonitor`](#safetymonitor) | 1 | Generic unsafe condition trigger (IsSafe) |
| [`Switch`](#switch) | 16 | Multi-channel on/off and analog switches |
| [`Camera`](#camera) | ~55 | Imaging with exposure, binning, gain, cooling |
| [`CoverCalibrator`](#covercalibrator) | 12 | Flat panel and dust cover control |
| [`Dome`](#dome) | 22 | Observatory dome rotation and shutter |
| [`FilterWheel`](#filterwheel) | 7 | Filter wheel position and naming |
| [`Focuser`](#focuser) | 12 | Focus motor with temperature compensation |
| [`ObservingConditions`](#observingconditions) | 15 | Weather station (13 sensors) |
| [`Rotator`](#rotator) | 12 | Camera rotator (mechanical + logical position) |
| [`Telescope`](#telescope) | ~60 | Mount control, tracking, guiding, slewing |
### SafetyMonitor
A generic trigger for unsafe conditions — not just weather. Any condition that should halt imaging operations: wind, rain, cloud cover, door open, power failure, equipment malfunction, dew heater offline, or a dead man's switch timeout. Returns a single boolean: is it safe to continue?
```rust
impl SafetyMonitor for MyDevice {
fn is_safe(&self) -> AlpacaResult<bool> {
// Combine any conditions that make observing unsafe
let weather_ok = self.wind_speed < 30.0 && !self.is_raining;
let equipment_ok = self.dew_heater_active && self.mount_powered;
let heartbeat_ok = self.last_heartbeat.elapsed() < Duration::from_secs(60);
Ok(weather_ok && equipment_ok && heartbeat_ok)
}
}
```
### Switch
Multi-channel device with boolean, multi-state, and analog channels:
```rust
impl Switch for MyPowerBox {
fn max_switch(&self) -> AlpacaResult<i32> { Ok(4) } // 4 channels
fn can_write(&self, id: u32) -> AlpacaResult<bool> { Ok(true) }
fn get_switch(&self, id: u32) -> AlpacaResult<bool> { /* ... */ }
fn set_switch(&self, id: u32, state: bool) -> AlpacaResult<()> { /* ... */ }
fn get_switch_value(&self, id: u32) -> AlpacaResult<f64> { /* ... */ }
fn set_switch_value(&self, id: u32, value: f64) -> AlpacaResult<()> { /* ... */ }
fn min_switch_value(&self, id: u32) -> AlpacaResult<f64> { Ok(0.0) }
fn max_switch_value(&self, id: u32) -> AlpacaResult<f64> { Ok(1.0) }
fn switch_step(&self, id: u32) -> AlpacaResult<f64> { Ok(1.0) }
// ISwitchV3 async methods optional: can_async, set_async, set_async_value, etc.
}
```
### Camera
The most complex device type. Key capability groups:
- **Exposure**: `start_exposure`, `stop_exposure`, `abort_exposure`, `image_ready`, `image_array`
- **Binning**: `bin_x/y`, `max_bin_x/y`, `can_asymmetric_bin`
- **Subframe**: `start_x/y`, `num_x/y`
- **Cooling**: `cooler_on`, `set_ccd_temperature`, `cooler_power` (gated by `can_set_ccd_temperature`)
- **Gain**: Two mutually exclusive modes:
- *Numeric*: `gain`, `gain_min`, `gain_max` — `gains()` returns NotImplemented
- *Named*: `gains()` returns a list — `gain_min/gain_max` return NotImplemented
- **Offset**: Same two modes as gain (`offset`/`offset_min`/`offset_max` vs `offsets()`)
- **Pulse guide**: `pulse_guide`, `is_pulse_guiding` (gated by `can_pulse_guide`)
- **Image data**: `ImageData` enum with 2D/3D i32/i16 arrays, plus ImageBytes binary encoding
### Telescope
The most method-rich device (~60 methods). Key areas:
- **Coordinates**: `right_ascension`, `declination`, `altitude`, `azimuth`, `sidereal_time`
- **Slewing**: `slew_to_coordinates[_async]`, `slew_to_alt_az[_async]`, `slew_to_target[_async]`
- **Tracking**: `tracking`, `set_tracking`, `tracking_rate`, `right_ascension_rate`, `declination_rate`
- **Guiding**: `pulse_guide`, `guide_rate_right_ascension/declination`
- **German equatorial**: `side_of_pier`, `set_side_of_pier`, `destination_side_of_pier`
- **Site**: `site_latitude`, `site_longitude`, `site_elevation`
Important ASCOM semantics:
- Sidereal tracking does NOT change the reported RA — the mount compensates for Earth's rotation
- Only `RightAscensionRate` (offset from sidereal) causes RA drift
- Guide rates are in degrees per sidereal second (RA) or degrees per SI second (Dec)
### Domain Types
All ASCOM enums serialize to their integer values via `serde_repr`:
```rust
use ascom_alpaca_core::camera::{CameraState, SensorType};
use ascom_alpaca_core::telescope::{AlignmentMode, DriveRate, SideOfPier};
use ascom_alpaca_core::dome::ShutterState;
use ascom_alpaca_core::cover_calibrator::{CoverState, CalibratorState};
use ascom_alpaca_core::types::GuideDirection;
let state = CameraState::Exposing; // serializes as 2
let side = SideOfPier::East; // serializes as 0
```
## Feature Flags
All device types are enabled by default. Disable defaults to reduce binary size on embedded:
```toml
[dependencies]
ascom-alpaca-core = { default-features = false, features = ["safety_monitor", "switch"] }
```
| `camera` | Camera | ~55 |
| `cover_calibrator` | CoverCalibrator | 12 |
| `dome` | Dome | 22 |
| `filter_wheel` | FilterWheel | 7 |
| `focuser` | Focuser | 12 |
| `observing_conditions` | ObservingConditions | 15 |
| `rotator` | Rotator | 12 |
| `safety_monitor` | SafetyMonitor | 1 |
| `switch` | Switch | 16 |
| `telescope` | Telescope | ~60 |
| `all-devices` | All of the above | (default) |
| `conformu` | ConformU test harness | Adds tiny_http, dispatch layer, mock devices |
## Integration Guide
### Wiring to an HTTP Server
This crate provides protocol types only — you bring your own HTTP server. Here's the pattern:
```rust
use ascom_alpaca_core::prelude::*;
use ascom_alpaca_core::types::params::normalize_params;
// 1. Parse the URL: /api/v1/{device_type}/{device_number}/{method}
// 2. Extract query params + PUT body params, normalize keys to lowercase
// 3. Look up the device in the registry
// 4. Call the trait method
// 5. Build the response envelope
fn handle_request(
registry: &DeviceRegistry,
device_type: DeviceType,
device_number: u32,
method: &str,
params: &HashMap<String, String>,
is_put: bool,
server_tx: u32,
) -> String {
let client_tx: u32 = params.get("clienttransactionid")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
match device_type {
DeviceType::SafetyMonitor => {
let dev = registry.get_safety_monitor(device_number).unwrap();
match (method, is_put) {
("issafe", false) => {
match dev.is_safe() {
Ok(v) => serde_json::to_string(
&AlpacaResponse::ok(v).with_transaction(client_tx, server_tx)
).unwrap(),
Err(e) => serde_json::to_string(
&AlpacaResponse::<bool>::from_error(e).with_transaction(client_tx, server_tx)
).unwrap(),
}
}
_ => { /* handle other methods */ todo!() }
}
}
// ... other device types
_ => todo!()
}
}
```
### Management API
Every Alpaca server must expose three management endpoints:
```rust
use ascom_alpaca_core::management::*;
// GET /management/apiversions → [1]
let versions = ApiVersions { value: vec![1] };
// GET /management/v1/description → server info
let desc = ServerDescription {
server_name: "My Server".into(),
manufacturer: "Acme".into(),
manufacturer_version: "1.0".into(),
location: "Home".into(),
};
// GET /management/v1/configureddevices → device list
let devices = registry.configured_devices();
```
### UDP Discovery
Alpaca clients discover servers via UDP broadcast on port 32227:
```rust
use ascom_alpaca_core::discovery::*;
// Listen for "alpacadiscovery1" on port 32227
// Respond with: {"AlpacaPort": 32888}
let response = DiscoveryResponse { alpaca_port: 32888 };
```
### ESP32 / Embedded
The crate has no `std` networking dependency. For ESP32:
```toml
[dependencies]
ascom-alpaca-core = { default-features = false, features = ["safety_monitor"] }
```
Use with `esp_http_server` (C bindings) or any ESP-IDF HTTP server. The response types serialize to JSON via serde, which you send as the HTTP response body.
## ConformU Validation
The `conformu` feature provides a complete test harness for validating your protocol implementation against the [ASCOM ConformU](https://github.com/ASCOMInitiative/ConformU) conformance checker:
```bash
cargo run --example conformu_harness --features conformu
# Then run ConformU against http://127.0.0.1:32888
```
The harness includes:
- Mock implementations for all 10 device types with configurable capabilities
- Complete URL-to-trait dispatch layer (`dispatch_request`)
- Management API handlers
- All 11 mock devices pass ConformU with 0 errors, 0 issues
### Using the Dispatch Layer
The `conformu` dispatch module provides a ready-made URL router:
```rust
use ascom_alpaca_core::conformu::dispatch::{dispatch_request, parse_device_path, AlpacaRequest};
if let Some((device_type, device_number, method)) = parse_device_path(&url_path) {
let req = AlpacaRequest {
device_type,
device_number,
method,
params, // HashMap<String, String>
is_put, // true for PUT requests
};
let (json_body, status_code) = dispatch_request(®istry, &req, server_tx);
// Send json_body as HTTP response
}
```
### Mock Devices for Testing
Each mock is configurable. Example with Camera feature flags:
```rust
use ascom_alpaca_core::conformu::mocks::camera::{MockCamera, CameraFeatures, GainOffsetMode};
// Minimal camera
let cam = MockCamera::new();
// Full-featured with numeric gain
let cam = MockCamera::full_featured();
// Color camera with named gain/offset
let cam = MockCamera::with_features(CameraFeatures {
cooler: true,
pulse_guide: true,
gain_mode: GainOffsetMode::Named(vec!["Low".into(), "High".into()]),
offset_mode: GainOffsetMode::Named(vec!["Normal".into(), "Low Noise".into()]),
sensor_type: SensorType::RGGB,
..Default::default()
});
```
## Comparison with `ascom-alpaca`
| Protocol types | Yes | Yes |
| HTTP server | No (bring your own) | Built-in (axum) |
| Async runtime | None required | tokio required |
| ESP32 compatible | Yes | No |
| Binary size impact | Minimal (~types only) | Large (pulls in tokio, hyper, axum) |
| ConformU harness | Optional feature | No |
Use `ascom-alpaca-core` when:
- You need ESP32 or embedded support
- You have your own HTTP server (esp-idf, actix, warp, etc.)
- You want minimal dependencies
- You need the dispatch/mock layer for testing
Use [`ascom-alpaca`](https://crates.io/crates/ascom-alpaca) when:
- You want batteries-included client+server on desktop
- You're already using tokio/axum
## MSRV
Rust 1.75 or later.
## License
Licensed under the Apache License, Version 2.0 ([LICENSE](LICENSE) or <http://www.apache.org/licenses/LICENSE-2.0>).
Unless required by applicable law or agreed to in writing, software distributed under this license is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.