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