ascom_alpaca/
lib.rs

1/*!
2This is a Rust implementation of the standard [ASCOM Alpaca API](https://ascom-standards.org/api/) for astronomy devices.
3
4It implements main Alpaca API clients and servers, as well as transparent support for auto-discovery mechanism and `ImageBytes` encoding for camera images.
5
6## Usage
7
8### Compilation features
9
10This crate defines two sets of compilation features that help to keep binary size & compilation speed in check by opting into only the features you need.
11
12First set is along the client-server axis:
13
14- `client`: Enables client-side access to Alpaca-capable devices.
15- `server`: Allows to expose your own devices as Alpaca servers.
16
17The second set of features is based on the device type and enables the corresponding trait:
18
19- `all-devices`: Enables all of the below. Not recommended unless you're building a universal astronomy application.
20- `camera`: Enables support for cameras via the [`Camera`](crate::api::Camera) trait.
21- `cover_calibrator`: Enables [...] the [`CoverCalibrator`](crate::api::CoverCalibrator) trait.
22- `dome`: Enables [`Dome`](crate::api::Dome).
23- `filter_wheel`: Enables [`FilterWheel`](crate::api::FilterWheel).
24- `focuser`: Enables [`Focuser`](crate::api::Focuser).
25- `observing_conditions`: Enables [`ObservingConditions`](crate::api::ObservingConditions).
26- `rotator`: Enables [`Rotator`](crate::api::Rotator).
27- `switch`: Enables [`Switch`](crate::api::Switch).
28- `telescope`: Enables [`Telescope`](crate::api::Telescope).
29
30Once you decided on the features you need, you can add this crate to your `Cargo.toml`. For example, if I'm implementing an Alpaca camera driver, I'd add the following to my `Cargo.toml`:
31
32```toml
33[dependencies]
34ascom-alpaca = { version = "0.1", features = ["client", "camera"] }
35```
36
37### Device methods
38
39All the device type trait methods are async and correspond to the [ASCOM Alpaca API](https://ascom-standards.org/api/). They all return [`ASCOMResult<...>`](crate::ASCOMResult).
40
41The [`Device`](crate::api::Device) supertrait includes "ASCOM Methods Common To All Devices" from the Alpaca API, as well as a few custom metadata methods used for the device registration:
42
43- [`fn static_name(&self) -> &str`](crate::api::Device::static_name): Returns the static device name.
44- [`fn unique_id(&self) -> &str`](crate::api::Device::unique_id): Returns globally-unique device ID.
45
46### Implementing a device server
47
48Since async traits are not yet natively supported on stable Rust, the traits are implemented using the [async-trait](https://crates.io/crates/async-trait) crate. Other than that, you should implement trait with all the Alpaca methods as usual:
49
50```no_run
51use ascom_alpaca::ASCOMResult;
52use ascom_alpaca::api::{Device, Camera};
53use async_trait::async_trait;
54
55#[derive(Debug)]
56struct MyCamera {
57    // ...
58}
59
60#[async_trait]
61impl Device for MyCamera {
62    fn static_name(&self) -> &str {
63        "My Camera"
64    }
65
66    fn unique_id(&self) -> &str {
67        "insert GUID here"
68    }
69
70    // ...
71}
72
73#[async_trait]
74impl Camera for MyCamera {
75    async fn bayer_offset_x(&self) -> ASCOMResult<i32> {
76        Ok(0)
77    }
78
79    async fn bayer_offset_y(&self) -> ASCOMResult<i32> {
80        Ok(0)
81    }
82
83    // ...
84}
85```
86
87Any skipped methods will default to the following values:
88
89- `can_*` feature detection methods - to `false`.
90- [`Device::name`](crate::api::Device::name) - to the result of [`Device::static_name()`](crate::api::Device::static_name).
91- [`Device::supported_actions`](crate::api::Device::supported_actions) - to an empty list.
92- All other methods - to [`Err(ASCOMError::NOT_IMPLEMENTED)`](crate::ASCOMError::NOT_IMPLEMENTED). It's your responsibility to consult documentation and implement mandatory methods.
93
94Once traits are implemented, you can create a server, register your device(s), and start listening:
95
96```no_run
97use ascom_alpaca::Server;
98use ascom_alpaca::api::CargoServerInfo;
99use std::convert::Infallible;
100
101// ...implement MyCamera...
102# use ascom_alpaca::{api, ASCOMResult};
103# use async_trait::async_trait;
104#
105# #[derive(Debug)]
106# struct MyCamera {}
107# impl api::Device for MyCamera {
108# fn static_name(&self) -> &str { todo!() }
109# fn unique_id(&self) -> &str { todo!() }
110# }
111# impl api::Camera for MyCamera {}
112
113#[tokio::main]
114async fn main() -> eyre::Result<Infallible> {
115    // create with the helper macro that populate server information from your own Cargo.toml
116    let mut server = Server::new(CargoServerInfo!());
117
118    // By default, the server will listen on dual-stack (IPv4 + IPv6) unspecified address with a randomly assigned port.
119    // You can change that by modifying the `listen_addr` field:
120    server.listen_addr.set_port(8000);
121
122    // Create and register your device(s).
123    server.devices.register(MyCamera { /* ... */ });
124
125    // Start the infinite server loop.
126    server.start().await
127}
128```
129
130This will start both the main Alpaca server as well as an auto-discovery responder.
131
132**Examples:**
133
134- [`examples/camera-server.rs`](https://github.com/RReverser/ascom-alpaca-rs/blob/main/examples/camera-server.rs):
135  A cross-platform example exposing your connected webcam(s) as Alpaca `Camera`s.
136
137  ```log
138  > env RUST_LOG=debug { cargo run --example camera-server --release }
139        Finished release [optimized] target(s) in 0.60s
140         Running `target\release\examples\camera-server.exe`
141    2023-05-27T15:21:43.336191Z DEBUG camera_server: Registering webcam webcam=Webcam { unique_id: "150ddacb-7ad9-4754-b289-ae56210693e8::0", name: "Integrated Camera", description: "MediaFoundation Camera", max_format: CameraFormat { resolution: Resolution { width_x: 1280, height_y: 720 }, format: MJPEG, frame_rate: 30 }, subframe: RwLock { data: Subframe { bin: Size { x: 1, y: 1 }, offset: Point { x: 0, y: 0 }, size: Size { x: 1280, y: 720 } } }, last_exposure_start_time: RwLock { data: None }, last_exposure_duration: RwLock { data: None }, valid_bins: [1, 2, 4] }
142    2023-05-27T15:21:43.339433Z DEBUG ascom_alpaca::server: Binding Alpaca server addr=[::]:8000
143    2023-05-27T15:21:43.342897Z  INFO ascom_alpaca::server: Bound Alpaca server bound_addr=[::]:8000
144    2023-05-27T15:21:43.369040Z  WARN join_multicast_groups{listen_addr=::}: ascom_alpaca::server::discovery: err=An unknown,
145    invalid, or unsupported option or level was specified in a getsockopt or setsockopt call. (os error 10042)
146    2023-05-27T15:21:43.370932Z DEBUG join_multicast_groups{listen_addr=::}: ascom_alpaca::server::discovery: return=()
147    2023-05-27T15:21:43.371861Z DEBUG ascom_alpaca::server: Bound Alpaca discovery server
148    ```
149
150  Binning is implemented by switching the webcam to other supported resolutions which are proportional to the original.
151
152  Long exposures are simulated by stacking up individual frames up to the total duration.
153  This approach can't provide precise requested exposure, but works well enough otherwise.
154
155- [`star-adventurer-alpaca`](https://github.com/jsorrell/star-adventurer-alpaca): An implentation of the Alpaca protocol for Star Adventurer trackers.
156- [`qhyccd-alpaca`](https://github.com/ivonnyssen/qhyccd-alpaca): Alpaca driver for QHYCCD cameras and filter wheels written in Rust.
157
158### Accessing devices from a client
159
160If you know address of the device server you want to access, you can access it directly via `Client` struct:
161
162```no_run
163# #[tokio::main]
164# async fn main() -> eyre::Result<()> {
165use ascom_alpaca::Client;
166
167let client = Client::new("http://localhost:8000")?;
168
169// `get_server_info` returns high-level metadata of the server.
170println!("Server info: {:#?}", client.get_server_info().await?);
171
172// `get_devices` returns an iterator over all the devices registered on the server.
173// Each is represented as a `TypedDevice` tagged enum encompassing all the device types as corresponding trait objects.
174// You can either match on them to select the devices you're interested in, or, say, just print all of them:
175println!("Devices: {:#?}", client.get_devices().await?.collect::<Vec<_>>());
176# Ok(())
177# }
178```
179
180If you want to discover device servers on the local network, you can do that via the `discovery::DiscoveryClient` struct:
181
182```no_run
183# #[tokio::main]
184# async fn main() -> eyre::Result<()> {
185use ascom_alpaca::discovery::DiscoveryClient;
186use ascom_alpaca::Client;
187use futures::prelude::*;
188
189// This holds configuration for the discovery client.
190// You can customize prior to binding if you want.
191let discovery_client = DiscoveryClient::default();
192// This results in a discovery client bound to a local socket.
193// It's intentionally split out into a separate API step to encourage reuse,
194// for example so that user could click "Refresh devices" button in the UI
195// and the application wouldn't have to re-bind the socket every time.
196let mut bound_client = discovery_client.bind().await?;
197// Now you can discover devices on the local networks.
198bound_client.discover_addrs()
199    // create a `Client` for each discovered address
200    .map(|addr| Ok(Client::new_from_addr(addr)))
201    .try_for_each(async move |client| {
202        /* ...do something with devices via each client... */
203        Ok::<_, eyre::Error>(())
204    })
205    .await?;
206# Ok(())
207# }
208```
209
210Or, if you just want to list all available devices and don't care about per-server information or errors:
211
212```no_run
213# #[tokio::main]
214# async fn main() -> eyre::Result<()> {
215# use ascom_alpaca::discovery::DiscoveryClient;
216# use ascom_alpaca::Client;
217# use futures::prelude::*;
218# let mut bound_client = DiscoveryClient::default().bind().await?;
219bound_client.discover_devices()
220    .for_each(async move |device| {
221        /* ...do something with each device... */
222    })
223    .await;
224# Ok(())
225# }
226```
227
228Keep in mind that discovery is a UDP-based protocol, so it's not guaranteed to be reliable.
229
230Also, same device server can be discovered multiple times if it's available on multiple network interfaces.
231While it's not possible to reliably deduplicate servers, you can deduplicate devices by storing them in something like [`HashSet`](::std::collections::HashSet)
232or in the same [`Devices`](crate::api::Devices) struct that is used for registering arbitrary devices on the server:
233
234```no_run
235# #[tokio::main]
236# async fn main() -> eyre::Result<()> {
237use ascom_alpaca::api::{Camera, Devices};
238use ascom_alpaca::discovery::DiscoveryClient;
239use ascom_alpaca::Client;
240use futures::prelude::*;
241
242let devices =
243    DiscoveryClient::default()
244    .bind()
245    .await?
246    .discover_devices()
247    .collect::<Devices>()
248    .await;
249
250// Now you can iterate over all the discovered devices via `iter_all`:
251for (typed_device, index_within_category) in devices.iter_all() {
252    println!("Discovered device: {typed_device:#?} (index: {index_within_category})");
253}
254
255// ...or over devices in a specific category via `iter<dyn Trait>`:
256for camera in devices.iter::<dyn Camera>() {
257    println!("Discovered camera: {camera:#?}");
258}
259# Ok(())
260# }
261```
262
263**Examples:**
264
265- [`examples/discover.rs`](https://github.com/RReverser/ascom-alpaca-rs/blob/main/examples/discover.rs):
266  A simple discovery example listing all the found servers and devices.
267
268- [`examples/camera-client.rs`](https://github.com/RReverser/ascom-alpaca-rs/blob/main/examples/camera-client.rs):
269  A cross-platform GUI example showing a live preview stream from discovered Alpaca cameras.
270
271  Includes support for colour, monochrome and Bayer sensors with automatic colour conversion for the preview.
272
273  <img alt="Screenshot of a live view from the simulator camera" src="https://github.com/RReverser/ascom-alpaca-rs/assets/557590/faecb549-dc0c-4f07-902f-7d49429b6458" width="50%" />
274
275### Logging and tracing
276
277This crate uses [`tracing`](https://crates.io/crates/tracing) framework for logging spans and events, integrating with the Alpaca `ClientID`, `ClientTransactionID` and `ServerTransactionID` fields.
278
279You can enable logging in your app by using any of the [subscriber crates](https://crates.io/crates/tracing#ecosystem).
280
281For example, [`tracing_subscriber::fmt`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/fmt/index.html) will log all the events to stderr depending on the `RUST_LOG` environment variable:
282
283```no_run
284tracing_subscriber::fmt::init();
285```
286
287## Testing
288
289Since this is a library for communicating to networked devices, it should be tested against real devices at a higher level.
290
291In particular, if you're implementing an Alpaca device, make sure to run [ConformU](https://github.com/ASCOMInitiative/ConformU) - ASCOM's official conformance checker - against your device server.
292
293## License
294
295Licensed under either of
296
297- Apache License, Version 2.0 ([LICENSE-APACHE-2.0](LICENSE-APACHE-2.0))
298- MIT license ([LICENSE-MIT](LICENSE-MIT))
299*/
300#![cfg_attr(docsrs, feature(doc_auto_cfg))]
301
302#[cfg(not(feature = "__anydevice"))]
303compile_error!(
304    "At least one of the device features must be enabled (`camera`, `telescope`, `dome`, etc)."
305);
306
307#[cfg(not(feature = "__anynetwork"))]
308compile_error!("At least one of the network features must be enabled (`client` and/or `server`).");
309
310pub mod api;
311pub use api::Devices;
312
313/// Utilities for testing Alpaca client and server implementations.
314#[cfg(feature = "test")]
315#[macro_use]
316pub mod test;
317
318macro_rules! auto_increment {
319    () => {{
320        use std::sync::atomic::{AtomicU32, Ordering};
321
322        static COUNTER: AtomicU32 = AtomicU32::new(1);
323        std::num::NonZeroU32::new(COUNTER.fetch_add(1, Ordering::Relaxed)).unwrap()
324    }};
325}
326
327#[cfg(feature = "client")]
328mod client;
329#[cfg(feature = "client")]
330pub use client::Client;
331
332#[cfg(feature = "server")]
333mod server;
334#[cfg(feature = "server")]
335pub use server::{BoundServer, Server};
336
337pub mod discovery;
338
339mod errors;
340pub use errors::{ASCOMError, ASCOMErrorCode, ASCOMResult};
341
342mod params;
343mod response;
344
345/// Benchmark groups for Criterion.
346///
347/// They're defined in the library for access to the private types, but actually used from `benches/benches.rs`.
348#[cfg(feature = "criterion")]
349#[doc(hidden)]
350pub mod benches {
351    #[cfg(feature = "client")]
352    pub use crate::client::benches as client;
353}