Skip to main content

esphome_client/
client.rs

1/// Represents a TCP stream connection to an ESPHome API server.
2///
3/// This struct manages the underlying stream reader and writer, and provides methods for sending and receiving
4/// ESPHome protocol messages. It can optionally handle ping requests automatically to keep the connection alive.
5///
6/// Use [`EspHomeTcpStream::builder`] to create a builder for establishing a connection.
7mod noise;
8mod plain;
9
10mod stream_reader;
11mod stream_writer;
12use std::{fmt::Debug, time::Duration};
13
14use stream_reader::StreamReader;
15use stream_writer::StreamWriter;
16use tokio::time::timeout;
17
18use crate::{
19    error::{ClientError, ProtocolError},
20    proto::{DisconnectRequest, EspHomeMessage, HelloRequest, PingResponse},
21    API_VERSION,
22};
23
24type StreamPair = (StreamReader, StreamWriter);
25
26/// Client for sending and receiving messages to an ESPHome API server.
27#[derive(Debug)]
28pub struct EspHomeClient {
29    streams: StreamPair,
30    handle_ping: bool,
31}
32
33impl EspHomeClient {
34    /// Creates a new builder for configuring and connecting to an ESPHome API server.
35    #[must_use]
36    pub fn builder() -> EspHomeClientBuilder {
37        EspHomeClientBuilder::new()
38    }
39
40    /// Sends a message to the ESPHome device.
41    ///
42    /// # Errors
43    ///
44    /// Will return an error if the write operation fails for example due to a disconnected stream.
45    pub async fn try_write<M>(&mut self, message: M) -> Result<(), ClientError>
46    where
47        M: Into<EspHomeMessage> + Debug,
48    {
49        tracing::debug!("Send: {message:?}");
50        let message: EspHomeMessage = message.into();
51        let payload: Vec<u8> = message.into();
52        self.streams.1.write_message(payload).await
53    }
54
55    /// Reads the next message from the stream.
56    ///
57    /// It will automatically handle ping requests if ping handling is enabled.
58    ///
59    /// # Errors
60    ///
61    /// Will return an error if the read operation fails, for example due to a disconnected stream
62    pub async fn try_read(&mut self) -> Result<EspHomeMessage, ClientError> {
63        loop {
64            let payload = self.streams.0.read_next_message().await?;
65            let message: EspHomeMessage =
66                payload
67                    .clone()
68                    .try_into()
69                    .map_err(|e| ProtocolError::ValidationFailed {
70                        reason: format!("Failed to decode EspHomeMessage: {e}"),
71                    })?;
72            tracing::debug!("Receive: {message:?}");
73            match message {
74                EspHomeMessage::PingRequest(_) if self.handle_ping => {
75                    self.try_write(PingResponse {}).await?;
76                }
77                msg => return Ok(msg),
78            }
79        }
80    }
81
82    /// Closes the connection gracefully by sending a `DisconnectRequest` message.
83    ///
84    /// # Errors
85    ///
86    /// Will return an error if the write operation fails, for example due to a disconnected stream
87    pub async fn close(mut self) -> Result<(), ClientError> {
88        self.try_write(DisconnectRequest {}).await?;
89        // Dropping self & self.streams will close the streams automatically.
90        Ok(())
91    }
92
93    /// Returns a clone-able write stream for sending messages to the ESPHome device.
94    #[must_use]
95    pub fn write_stream(&self) -> EspHomeClientWriteStream {
96        EspHomeClientWriteStream {
97            writer: self.streams.1.clone(),
98        }
99    }
100}
101
102/// Clone-able write stream for sending messages to the ESPHome device.
103#[derive(Debug, Clone)]
104pub struct EspHomeClientWriteStream {
105    writer: StreamWriter,
106}
107impl EspHomeClientWriteStream {
108    /// Sends a message to the ESPHome device.
109    ///
110    /// # Errors
111    ///
112    /// Will return an error if the write operation fails for example due to a disconnected stream.
113    pub async fn try_write<M>(&self, message: M) -> Result<(), ClientError>
114    where
115        M: Into<EspHomeMessage> + Debug,
116    {
117        tracing::debug!("Send: {message:?}");
118        let message: EspHomeMessage = message.into();
119        let payload: Vec<u8> = message.into();
120        self.writer.write_message(payload).await
121    }
122}
123
124/// Builder for configuring and connecting to an ESPHome API server.
125#[derive(Debug)]
126pub struct EspHomeClientBuilder {
127    addr: Option<String>,
128    key: Option<String>,
129    password: Option<String>,
130    client_info: String,
131    timeout: Duration,
132    connection_setup: bool,
133    handle_ping: bool,
134}
135
136impl EspHomeClientBuilder {
137    fn new() -> Self {
138        Self {
139            addr: None,
140            key: None,
141            password: None,
142            client_info: format!("{}:{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
143            timeout: Duration::from_secs(30),
144            connection_setup: true,
145            handle_ping: true,
146        }
147    }
148
149    /// Sets the host address of the ESPHome API server to connect to.
150    ///
151    /// Takes the address of the server in the format "host:port".
152    #[must_use]
153    pub fn address(mut self, addr: &str) -> Self {
154        self.addr = Some(addr.to_owned());
155        self
156    }
157
158    /// Enables the use of a 32-byte base64-encoded key for encrypted communication.
159    ///
160    /// If no key is provided, the connection will be established in plain text.
161    /// Further reference: <https://esphome.io/components/api.html#configuration-variables>
162    #[must_use]
163    pub fn key(mut self, key: &str) -> Self {
164        self.key = Some(key.to_owned());
165        self
166    }
167
168    /// Enables the use of a password to authenticate the client.
169    ///
170    /// Note that this password is deprecated and will be removed in a future version of ESPHome.
171    /// This only works if connection setup is enabled.
172    #[must_use]
173    pub fn password(mut self, password: &str) -> Self {
174        self.password = Some(password.to_owned());
175        self
176    }
177
178    /// Sets the timeout duration during the tcp connection.
179    #[must_use]
180    pub const fn timeout(mut self, timeout: Duration) -> Self {
181        self.timeout = timeout;
182        self
183    }
184
185    /// Sets the client info string that will be sent in the `HelloRequest`.
186    ///
187    /// Defaults to the package name and version of the client.
188    /// This only works if connection setup is enabled.
189    #[must_use]
190    pub fn client_info(mut self, client_info: &str) -> Self {
191        client_info.clone_into(&mut self.client_info);
192        self
193    }
194
195    /// Disable connection setup messages.
196    ///
197    /// Most api requests require a connection setup, which requires a sequence of messages to be sent and received.
198    /// - `HelloRequest` -> `HelloResponse`
199    /// - `ConnectionRequest` -> `ConnectionResponse`
200    ///
201    /// By disabling this, the connection can be established manually.
202    #[must_use]
203    pub const fn without_connection_setup(mut self) -> Self {
204        self.connection_setup = false;
205        self
206    }
207
208    /// Disable automatic handling of ping request.
209    ///
210    /// The ESPHome API server will send a ping request to the client on a regular interval.
211    /// The client needs to respond with a `PingResponse` to keep the connection alive.
212    #[must_use]
213    pub const fn without_ping_handling(mut self) -> Self {
214        self.handle_ping = false;
215        self
216    }
217
218    /// Connect to the ESPHome API server.
219    ///
220    /// # Errors
221    ///
222    /// Will return an error if the connection fails, or if the connection setup fails.
223    pub async fn connect(self) -> Result<EspHomeClient, ClientError> {
224        let addr = self.addr.ok_or_else(|| ClientError::Configuration {
225            message: "Address is not set".into(),
226        })?;
227
228        let streams = timeout(self.timeout, async {
229            match self.key {
230                Some(key) => noise::connect(&addr, &key).await,
231                None => plain::connect(&addr).await,
232            }
233        })
234        .await
235        .map_err(|_e| ClientError::Timeout {
236            timeout_ms: self.timeout.as_millis(),
237        })??;
238
239        let mut stream = EspHomeClient {
240            streams,
241            handle_ping: self.handle_ping,
242        };
243        if self.connection_setup {
244            Self::connection_setup(&mut stream, self.client_info, self.password).await?;
245        }
246        Ok(stream)
247    }
248
249    /// Sets up the connection by sending the `HelloRequest` and `ConnectRequest` messages.
250    ///
251    /// Details: <https://github.com/esphome/aioesphomeapi/blob/4707c424e5dab921fa15466ecc31148a8c0ee4a9/aioesphomeapi/api.proto#L85>
252    async fn connection_setup(
253        stream: &mut EspHomeClient,
254        client_info: String,
255        password: Option<String>,
256    ) -> Result<(), ClientError> {
257        stream
258            .try_write(HelloRequest {
259                client_info,
260                api_version_major: API_VERSION.0,
261                api_version_minor: API_VERSION.1,
262            })
263            .await?;
264        loop {
265            let response = stream.try_read().await?;
266            match response {
267                EspHomeMessage::HelloResponse(response) => {
268                    if response.api_version_major != API_VERSION.0 {
269                        return Err(ClientError::ProtocolMismatch {
270                            expected: format!("{}.{}", API_VERSION.0, API_VERSION.1),
271                            actual: format!(
272                                "{}.{}",
273                                response.api_version_major, response.api_version_minor
274                            ),
275                        });
276                    }
277                    if response.api_version_minor != API_VERSION.1 {
278                        tracing::warn!(
279                            "API version mismatch: expected {}.{}, got {}.{}, expect breaking changes in messages",
280                            API_VERSION.0,
281                            API_VERSION.1,
282                            response.api_version_major,
283                            response.api_version_minor
284                        );
285                    }
286                    break;
287                }
288                _ => {
289                    tracing::debug!("Unexpected response during connection setup: {response:?}");
290                }
291            }
292        }
293        if password.is_some() {
294            Self::authenticate(stream, password).await
295        } else {
296            Ok(())
297        }
298    }
299
300    #[cfg(not(any(
301        feature = "api-1-12",
302        feature = "api-1-10",
303        feature = "api-1-9",
304        feature = "api-1-8"
305    )))]
306    async fn authenticate(
307        stream: &mut EspHomeClient,
308        password: Option<String>,
309    ) -> Result<(), ClientError> {
310        use crate::proto::AuthenticationRequest;
311
312        stream
313            .try_write(AuthenticationRequest {
314                password: password.unwrap_or_default(),
315            })
316            .await?;
317        loop {
318            let response = stream.try_read().await?;
319            match response {
320                EspHomeMessage::AuthenticationResponse(response) => {
321                    if response.invalid_password {
322                        return Err(ClientError::Authentication {
323                            reason: "Invalid password".to_owned(),
324                        });
325                    }
326                    tracing::info!("Connection to ESPHome API established successfully.");
327                    break;
328                }
329                _ => {
330                    tracing::debug!("Unexpected response during connection setup: {response:?}");
331                }
332            }
333        }
334        Ok(())
335    }
336
337    #[cfg(any(
338        feature = "api-1-12",
339        feature = "api-1-10",
340        feature = "api-1-9",
341        feature = "api-1-8"
342    ))]
343    async fn authenticate(
344        stream: &mut EspHomeClient,
345        password: Option<String>,
346    ) -> Result<(), ClientError> {
347        use crate::proto::ConnectRequest;
348
349        stream
350            .try_write(ConnectRequest {
351                password: password.unwrap_or_default(),
352            })
353            .await?;
354        loop {
355            let response = stream.try_read().await?;
356            match response {
357                EspHomeMessage::ConnectResponse(response) => {
358                    if response.invalid_password {
359                        return Err(ClientError::Authentication {
360                            reason: "Invalid password".to_owned(),
361                        });
362                    }
363                    tracing::info!("Connection to ESPHome API established successfully.");
364                    break;
365                }
366                _ => {
367                    tracing::debug!("Unexpected response during connection setup: {response:?}");
368                }
369            }
370        }
371        Ok(())
372    }
373}