heos 0.3.1

Rust bindings for HEOS ecosystem API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! Rust bindings for the HEOS control protocol.
//!
//! The published specifications for the latest version of the CLI (1.17 at time of writing) can be
//! found here:
//! https://rn.dmglobal.com/usmodel/HEOS_CLI_ProtocolSpecification-Version-1.17.pdf
//!
//! If that links gets stale and no longer works, a newer version may be able to be found on the
//! Denon support website, here:
//! https://support.denon.com/app/answers/detail/a_id/6953/~/heos-control-protocol-%28cli%29
//!
//! # Getting a Connection
//!
//! A HEOS system on the local network can be found via SSDP discovery. The following initiates SSDP
//! discovery and yields an asynchronous stream of possible HEOS connection endpoints as they're
//! discovered:
//!
//! ```
//! use heos::HeosConnection;
//! # use heos::{Created, ScanError};
//! use std::time::Duration;
//! # use tokio_stream::Stream;
//!
//! # async fn wrapper() -> Result<impl Stream<Item=HeosConnection<Created>>, ScanError> {
//! let endpoints = HeosConnection::scan(Duration::from_secs(10)).await?;
//! # Ok(endpoints)
//! # }
//! ```
//!
//! Once endpoints have been discovered, any of them can be chosen to be used as the connection. The
//! HEOS CLI uses a distributed system where a connection to any HEOS device can control all HEOS
//! devices on the same network.
//!
//! ```
//! use heos::{ConnectError, HeosConnection};
//! # use heos::AdHoc;
//! use std::time::Duration;
//! use tokio_stream::StreamExt;
//!
//! # async fn wrapper() -> Result<HeosConnection<AdHoc>, ConnectError> {
//! let mut endpoints = HeosConnection::scan(Duration::from_secs(10)).await?;
//! let connection = endpoints.next().await
//!     .ok_or(ConnectError::NoDevicesFound)?
//!     .connect().await?;
//! # Ok(connection)
//! # }
//! ```
//!
//! Or, to do all of the above in one method:
//!
//! ```
//! use heos::HeosConnection;
//! # use heos::{AdHoc, ConnectError};
//! use std::time::Duration;
//!
//! # async fn wrapper() -> Result<HeosConnection<AdHoc>, ConnectError> {
//! let connection = HeosConnection::connect_any(Duration::from_secs(10)).await?;
//! # Ok(connection)
//! # }
//! ```
//!
//! # Stateful Connections
//!
//! The HEOS system supports sending change events whenever any part of the internal state changes.
//! Using these, we can maintain a stateful representation of the system without needing to re-query
//! all the time.
//!
//! A stateful connection can be initiated like so:
//!
//! ```
//! use heos::HeosConnection;
//! # use heos::{Stateful, ConnectError};
//! use std::time::Duration;
//!
//! # async fn wrapper() -> Result<HeosConnection<Stateful>, ConnectError> {
//! let connection = HeosConnection::connect_any(Duration::from_secs(10)).await?;
//! let stateful = connection.init_stateful().await?;
//! # Ok(stateful)
//! # }
//! ```

use ssdp_client::{SearchTarget, URN};
use std::net::{IpAddr, SocketAddr};
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{
    broadcast::Receiver as BroadcastReceiver,
    broadcast::Sender as BroadcastSender,
    Mutex as AsyncMutex,
    MutexGuard as AsyncMutexGuard,
};
use tokio_stream::{Stream, StreamExt};
use tracing::{trace, warn};
use url::{Host, Url};

pub use ssdp_client::Error as ScanError;

use crate::channel::{Channel, TcpChannel};
use crate::command::raw::RawCommand;
use crate::command::system::RegisterForChangeEvents;
use crate::command::{Command, CommandError};
use crate::data::event::Event;
use crate::data::response::RawResponse;
use crate::data::system::ChangeEventsEnabled;
use crate::doctest::try_doctest_channel;
use crate::state::State;

pub mod channel;
pub mod command;
pub mod data;
mod doctest;
pub mod mock;
pub mod state;

#[doc(hidden)]
pub use doctest::install_doctest_handler;

/// Inner state for a [HeosConnection] object that has been created but not connected.
///
/// Connections of this type represent a valid IP endpoint as determined by an SSDP scan, but no
/// attempt to actually connect has been made yet.
#[derive(Debug)]
pub struct Created {
    ip: IpAddr,
}

/// Main connection object of the library.
///
/// A HeosConnection is the centralized object where all other operations stem from. This object
/// can be in several states, depending on how far along the connection process is.
#[derive(Debug)]
pub struct HeosConnection<S> {
    state: S,
}

/// Errors that can occur when connecting a [HeosConnection].
#[derive(thiserror::Error, Debug)]
pub enum ConnectError {
    /// There was an error while scanning for valid endpoints to connect to.
    #[error("SSDP scan error: {0}")]
    ScanError(#[from] ScanError),
    /// There are no valid HEOS devices on the local network to connect to.
    #[error("No HEOS devices were found on the network")]
    NoDevicesFound,
    /// Some other IO error occurred.
    #[error("IO Error: {0}")]
    IoError(#[from] std::io::Error),
    /// An error occurred when sending commands during initialization.
    #[error("Command failed while initializing: {0}")]
    CommandError(#[from] CommandError),
}

impl HeosConnection<Created> {
    const HEOS_PORT: u16 = 1255;

    /// Perform a SSDP scan on a local network to find valid HEOS endpoints to connect to.
    ///
    /// Note that this method does not attempt to connect to any endpoints; it only discovers them.
    pub async fn scan(
        timeout: Duration,
    ) -> Result<impl Stream<Item=Self>, ScanError> {
        let search_target = SearchTarget::URN(URN::device(
            "schemas-denon-com",
            "ACT-Denon",
            1,
        ));

        let mx = 2.min(timeout.as_secs()).max(1) as usize;

        let responses = ssdp_client::search(
            &search_target,
            timeout,
            mx,
            None,
        ).await?;

        Ok(responses
            .filter_map(|result| match result {
                Ok(response) => {
                    trace!(?response, "Received SSDP response");
                    match Url::parse(response.location()) {
                        Ok(location) => {
                            let ip: Option<IpAddr> = match location.host() {
                                Some(host) => match host {
                                    Host::Ipv4(ip) => Some(ip.into()),
                                    Host::Ipv6(ip) => Some(ip.into()),
                                    host => {
                                        warn!(?location, ?host, "Unsupported host type");
                                        None
                                    }
                                },
                                None => {
                                    warn!(?location, "No host type found");
                                    None
                                },
                            };

                            ip.map(|ip| Self {
                                state: Created {
                                    ip,
                                }
                            })
                        },
                        Err(error) => {
                            warn!(?error, url = ?response.location(), "Could not parse device URL");
                            None
                        }
                    }
                },
                Err(error) => {
                    warn!(?error, "Failed search request");
                    None
                },
            }))
    }

    /// Connect to the endpoint currently represented by this HeosConnection.
    ///
    /// This will transition the internal state from [Created] to [AdHoc].
    pub async fn connect(self) -> Result<HeosConnection<AdHoc>, ConnectError> {
        let socket_addr = SocketAddr::new(self.state.ip, Self::HEOS_PORT);
        let channel = Channel::new(TcpChannel::new(socket_addr)).await?;

        let connection = HeosConnection::from_channel(channel).await?;

        Ok(connection)
    }

    /// Connect to any valid HEOS endpoint on the local network.
    pub async fn connect_any(
        timeout: Duration,
    ) -> Result<HeosConnection<AdHoc>, ConnectError> {
        if let Some(doctest_channel) = try_doctest_channel() {
            Ok(HeosConnection::from_channel(Channel::new(doctest_channel).await?).await?)
        } else {
            Self::scan(timeout).await?
                .next().await.ok_or(ConnectError::NoDevicesFound)?
                .connect().await
        }
    }

    /// The IP address of this possible connection.
    #[inline]
    pub fn ip(&self) -> IpAddr {
        self.state.ip
    }
}

trait ConnectedState {
    fn channel(&self) -> &AsyncMutex<Channel>;
}

#[allow(private_bounds)]
impl<S: ConnectedState> HeosConnection<S> {
    /// Acquire a reference to the [Channel].
    pub async fn channel(&self) -> AsyncMutexGuard<'_, Channel> {
        self.state.channel().lock().await
    }

    /// Send a [RawCommand] over this connection.
    ///
    /// # Errors
    ///
    /// Errors if the connection has an IO error while sending the command and receiving the
    /// response.
    pub async fn raw_command(&self, command: RawCommand) -> Result<RawResponse, std::io::Error> {
        self.state.channel().lock().await.send_raw_command(command).await
    }

    /// Send a [Command] over this connection.
    ///
    /// # Errors
    ///
    /// Errors for any reason found in [CommandError].
    pub async fn command<C>(&self, command: C) -> Result<C::Response, CommandError>
    where
        C: Command,
    {
        self.state.channel().lock().await.send_command(command).await
    }
}

/// Inner state for a [HeosConnection] object that is actively connected to a HEOS endpoint, but
/// does not manage any state.
///
/// AdHoc connections can be used to send commands and receive responses, but do no tracking of the
/// HEOS system's state.
#[derive(Debug)]
pub struct AdHoc {
    channel: AsyncMutex<Channel>,
}

impl ConnectedState for AdHoc {
    #[inline]
    fn channel(&self) -> &AsyncMutex<Channel> {
        &self.channel
    }
}

impl HeosConnection<AdHoc> {
    /// Create a connection directly from a [Channel].
    ///
    /// This is an advanced use case, and only useful if you have a custom
    /// [ChannelBackend](channel::ChannelBackend). Usually, you should use e.g.
    /// [`HeosConnection<Created>::connect_any()`].
    pub async fn from_channel(channel: Channel) -> Result<Self, CommandError> {
        let channel = AsyncMutex::new(channel);
        let connection = HeosConnection {
            state: AdHoc {
                channel,
            }
        };

        connection.command(RegisterForChangeEvents {
            enable: ChangeEventsEnabled::Off,
        }).await?;

        Ok(connection)
    }

    /// Subscribe to [change events](data::event) emitted by the HEOS system.
    pub async fn subscribe_event_broadcast(&self) -> BroadcastReceiver<Event> {
        self.state.channel().lock().await.subscribe_event_broadcast()
    }

    /// Initialize a [Stateful] connection.
    ///
    /// This will transition the internal state from [AdHoc] to [Stateful], and the state of the
    /// HEOS system will start being tracked.
    pub async fn init_stateful(self) -> Result<HeosConnection<Stateful>, CommandError> {
        let state = Arc::new(State::init(self.state.channel.into_inner()).await?);
        let event_broadcast = BroadcastSender::new(Channel::EVENT_BROADCAST_BUFFER);
        let event_handle = {
            let state = state.clone();
            let weak_event_broadcast = event_broadcast.downgrade();
            let mut event_recv = state.channel.lock().await.subscribe_event_broadcast();
            tokio::spawn(async move {
                loop {
                    let event = match event_recv.recv().await {
                        Ok(event) => event,
                        Err(_) => break,
                    };

                    match state.handle_event(event.clone()).await {
                        Ok(_) => {},
                        Err(error) => {
                            warn!(?error, "Failed to handle event");
                        }
                    }

                    if let Some(event_broadcast) = weak_event_broadcast.upgrade() {
                        let _ = event_broadcast.send(event);
                    }
                }
            })
        };

        state.channel.lock().await
            .send_command(RegisterForChangeEvents {
                enable: ChangeEventsEnabled::On,
            }).await?;

        // TODO: Does the state need to be refreshed after registering for change events?
        //  Theoretically something could change between init and registering

        Ok(HeosConnection {
            state: Stateful {
                state,
                event_broadcast,
                event_handle,
            },
        })
    }
}

/// Inner state for a [HeosConnection] object that is actively connected to a HEOS endpoint, and is
/// tracking the overall state of the HEOS system.
///
/// Stateful connections can still be used to directly send commands and receive responses, but it
/// is usually more convenient to use the stateful wrappers around commands that can be found in the
/// [State] object that can be dereferenced from a connection of this type.
#[derive(Debug)]
pub struct Stateful {
    state: Arc<State>,
    event_broadcast: BroadcastSender<Event>,
    event_handle: tokio::task::JoinHandle<()>,
}

impl Drop for Stateful {
    fn drop(&mut self) {
        self.event_handle.abort();
    }
}

impl ConnectedState for Stateful {
    #[inline]
    fn channel(&self) -> &AsyncMutex<Channel> {
        &self.state.channel
    }
}

impl Deref for HeosConnection<Stateful> {
    type Target = State;

    #[inline]
    fn deref(&self) -> &Self::Target {
        &self.state.state
    }
}

impl HeosConnection<Stateful> {
    /// Subscribe to [change events](data::event) emitted by the HEOS system.
    ///
    /// When subscribing via this method, events will first be fully processed by the stateful
    /// connection before being passed to the user, ensuring that the stateful connection is
    /// up-to-date before user hooks run their logic.
    pub async fn subscribe_event_broadcast(&self) -> BroadcastReceiver<Event> {
        self.state.event_broadcast.subscribe()
    }
}