airtouch5 0.2.0

A library for communicating with AirTouch 5 air conditioning system control consoles
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
421
422
423
424
425
426
427
428
429
430
431
432
//! Discover an AirTouch 5 console on the local network.
//!
//! This module provides an interface to the AirTouch 5 discovery protocol.
//! This protocol uses a UDP broadcast to elicit a (UDP unicast) response from
//! any AirTouch 5 console on the local network. See ยง2.a of the protocol
//! documentation for details.
//!
//! # Caching
//!
//! The protocol documentation recommends only discovering the console during
//! initial setup of new clients; currently it is left to the application to
//! cache the discovered console address for later reconnection.
//!
//! # IPv6
//!
//! It seems likely that the AirTouch 5 console only supports IPv4. In
//! particular the documentation uses the term "broadcast", which strictly
//! speaking only applies to IPv4 (IPv6 uses multicast addressing more
//! extensively instead). For now we assume this is the case, and only attempt
//! to use IPv4.
//!
//! # Examples
//!
//! ```no_run
//! # use airtouch5::{discovery::{discover, Console, DiscoveryError}, AirTouch5};
//! # #[tokio::main(flavor = "current_thread")]
//! # async fn main() -> Result<(), DiscoveryError> {
//! # let cached_console: Option<Console> = None;
//! let console = cached_console.unwrap_or(discover().await?);
//! println!("Connecting to {} at {}", console.name, console.address);
//! let at5 = AirTouch5::with_ipaddr(console.address);
//! # Ok(()) }
//! ```

// # Goals
// - highest-level interface: broadcast request, recieve response(s) and cache the result
// - uncached: skip or clear the cache, for forcing re-discovery
// - low-level: broadcast request (periodically?) and send all (valid) responses over a channel
//
// # Challenges
// - docs say there can be two consoles (or more?), how to know when we've seen all
//
// # Misc TODO
// - make discovery an optional feature (and thus e.g. network-interface a conditional dependency)

use std::{
    error::Error,
    fmt::Display,
    io::Write,
    net::{IpAddr, Ipv4Addr},
    str::Utf8Error,
    time::Duration,
};

use log::{debug, trace};
use tokio::net::UdpSocket;

/// A discovered AirTouch 5 console.
///
/// Contains the IP address to connect to the console, as well as identifying
/// fields to select between multiple consoles that may be visible on the same
/// network.
#[derive(Clone, Debug, PartialEq)]
pub struct Console {
    /// The IP address of the Airtouch 5 console
    pub address: IpAddr,

    /// The name of the AirTouch 5 system.
    ///
    /// Editable as the "System Name" in the console GUI settings menu, and the
    /// System menu of the mobile app. Also shown at the top of the mobile app
    /// settings menu and in the device search screen of the mobile app.
    pub name: String,

    /// A numeric ID of the AirTouch 5 system.
    ///
    /// This identifier is visible as the "System ID" in the installer settings
    /// GUI, and as "ID:" in the lower corner of the main console GUI hamburger
    /// menu. It is also displayed, along with the [`name`][Self::name], in the
    /// device search screen of the mobile app.
    pub airtouch_id: u32,

    /// An alphanumeric unique ID of the Airtouch 5 console.
    ///
    /// This appears to be the serial number of the console. It is visible as the
    /// "Console ID" in the installer settings GUI, and is printed on a sticker on
    /// the back of the console PCB.
    ///
    /// On the examined system, the serial number appears to consist of a model
    /// code `AT5C`, followed by a date-of-manufacture in `YYYYMM` format, and
    /// finally a unique 6-digit integer. However this serial number format may be
    /// subject to change and should not be relied upon.
    pub console_id: String,
}

/// An error encountered during the discovery process.
#[derive(Debug)]
pub enum DiscoveryError {
    /// An I/O error when sending or receiving the discovery network packets.
    IoError(std::io::Error),

    /// An error decoding a string as UTF-8.
    EncodingError(Utf8Error),

    /// An unexcepted or malformed response was received.
    ProtocolError(String),

    /// The IP address reported by the console does not match the address from
    /// which the response was received.
    AddressError { reported: IpAddr, actual: IpAddr },

    /// No response was received in the alloted time limit.
    ///
    /// Only returned by [`discover_timeout()`], and hence only when the `timeout`
    /// feature is enabled.
    NoResponse,
}

// TODO implement Display and Error properly, or use the `thiserror` crate to derive
impl Display for DiscoveryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}
impl Error for DiscoveryError {}
impl From<std::io::Error> for DiscoveryError {
    fn from(value: std::io::Error) -> Self {
        Self::IoError(value)
    }
}
impl From<Utf8Error> for DiscoveryError {
    fn from(value: Utf8Error) -> Self {
        Self::EncodingError(value)
    }
}
#[cfg(feature = "timeout")]
impl From<tokio::time::error::Elapsed> for DiscoveryError {
    fn from(_: tokio::time::error::Elapsed) -> Self {
        Self::NoResponse
    }
}

const DISCOVERY_REQUEST: &str = "::REQUEST-POLYAIRE-AIRTOUCH-DEVICE-INFO:;";
const DISCOVERY_PORT: u16 = 49005;
#[cfg(not(test))]
const LISTEN_PORT: u16 = DISCOVERY_PORT;

/// Attempt to discover AirTouch 5 consoles on the network.
///
/// Broadcasts a discovery request, and waits indefinitely for a response.
///
/// # Errors
///
/// Returns [`DiscoveryError`] if unable to send the discovery request, or to
/// parse the response from the console.
///
/// # Examples
///
/// ```no_run
/// # use airtouch5::discovery::{discover, DiscoveryError};
///
/// # #[tokio::main(flavor = "current_thread")]
/// # async fn main() {
/// match discover().await {
///     Ok(c) => println!("Found AirTouch 5 console \"{}\" at {}", c.name, c.address),
///     Err(e) => println!("Error: {}", e),
/// };
/// # }
/// ```
pub async fn discover() -> Result<Console, DiscoveryError> {
    let sock = create_socket(LISTEN_PORT).await?;
    debug!("discover listening on {}", sock.local_addr()?);
    for a in broadcast_addresses()? {
        debug!("discover sending req to {}... ", a);
        std::io::stderr().flush()?;
        if sock
            .send_to(DISCOVERY_REQUEST.as_bytes(), (a, DISCOVERY_PORT))
            .await?
            != DISCOVERY_REQUEST.len()
        {
            return Err(DiscoveryError::IoError(std::io::Error::other("short send")));
        }
        trace!("discover send done")
    }

    // wait for response from somewhere
    let mut buf = vec![0u8; 4096]; // hopefully big enough

    // we have to loop in order to ignore our own request
    loop {
        trace!("discover waiting for reply...");
        let (len, sockaddr) = sock.recv_from(&mut buf).await?;
        let reply = std::str::from_utf8(&buf[..len])?;
        debug!("discover received {:?}... ", reply);
        match reply.splitn(5, ',').collect::<Vec<&str>>()[..] {
            // FIXME: refactor this so we only have to .parse() everything once
            [addr, conid, "AirTouch5", atid, name]
                if addr.parse::<IpAddr>().is_ok_and(|a| a == sockaddr.ip())
                    && atid.parse::<u32>().is_ok() =>
            {
                return Ok(Console {
                    address: addr.parse().unwrap(),
                    console_id: conid.to_string(),
                    airtouch_id: atid.parse().unwrap(),
                    name: name.to_string(),
                })
            }
            [addr, _, "AirTouch5", atid, _name]
                if addr.parse::<IpAddr>().is_ok() && atid.parse::<u32>().is_ok() =>
            {
                return Err(DiscoveryError::AddressError {
                    reported: addr.parse().unwrap(),
                    actual: sockaddr.ip(),
                })
            }
            [req] if req == DISCOVERY_REQUEST => continue,
            _ => return Err(DiscoveryError::ProtocolError(reply.to_string())),
        }
    }
}

/// Attempt to discover AirTouch 5 consoles on the network, with timeout.
///
/// Broadcasts a discovery request, and waits at least `duration` (or one second,
/// if `None` is passed) for a response.
///
/// # Errors
///
/// Returns [`NoResponse`][DiscoveryError::NoResponse] if no AirTouch 5 is
/// dicovered within the given duration. May also fail with any of the same erorrs
/// as [`discover()`].
///
/// # Examples
///
/// ```no_run
/// # use core::time::Duration;
/// # use airtouch5::discovery::{discover_timeout, DiscoveryError};
///
/// # #[tokio::main(flavor = "current_thread")]
/// # async fn main() {
/// match discover_timeout(Some(Duration::from_millis(250))).await {
///     Ok(c) => println!("Found AirTouch 5 console \"{}\" at {}", c.name, c.address),
///     Err(DiscoveryError::NoResponse) => println!("No AirTouch 5 console found"),
///     Err(e) => println!("Error: {}", e),
/// };
/// # }
/// ```
#[cfg(feature = "timeout")]
pub async fn discover_timeout(duration: Option<Duration>) -> Result<Console, DiscoveryError> {
    tokio::time::timeout(duration.unwrap_or(Duration::from_secs(1)), discover()).await?
}

/// Create a UDP socket to broadcast discovery requests from, and recieve responses on
///
/// The socket is bound to the given `port` on any address. Currently binds only to IPv4 addresses;
/// see [IPv6].
///
/// [IPv6]: index.html#ipv6
async fn create_socket(port: u16) -> std::io::Result<UdpSocket> {
    let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).await?;
    socket.set_broadcast(true)?;
    Ok(socket)
}

/// Get a list of broadcast addresses to send to.
///
/// This is the actual implementation used by `broadcast_addresses()` below; in all cases except
/// unit testing, use that function instead of calling this one directly.
fn broadcast_addresses_real() -> Result<Vec<IpAddr>, DiscoveryError> {
    use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};

    let addrs: Vec<IpAddr> = NetworkInterface::show()
        .map_err(|x| DiscoveryError::IoError(std::io::Error::other(x.to_string())))?
        .iter()
        .flat_map(|iface| iface.addr.iter())
        .filter_map(|addr| match addr {
            Addr::V4(a) if a.ip != Ipv4Addr::LOCALHOST => a.broadcast,
            _ => None,
        })
        .map(IpAddr::V4)
        .collect();

    if addrs.is_empty() {
        Err(DiscoveryError::IoError(std::io::Error::other(
            "no broadcast interfaces",
        )))
    } else {
        Ok(addrs)
    }
}

/// Get a list of broadcast addresses to send to.
///
/// Currently only lists IPv4 addresses; see [IPv6].
///
/// # Errors
///
/// May fail if no network interface information can be retrieved from the operating system, or if
/// the retrieved insterface information contains no broadcast addresses. In either case an
/// `IoError(Other)` will be returned.
///
/// [IPv6]: index.html#ipv6
#[cfg(not(test))]
fn broadcast_addresses() -> Result<Vec<IpAddr>, DiscoveryError> {
    broadcast_addresses_real()
}

/// We don't actually want to broadcast during unit testing, so this just returns localhost.
#[cfg(test)]
fn broadcast_addresses() -> Result<Vec<IpAddr>, DiscoveryError> {
    Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)])
}

/// Since we're unit testing by sending to ourselves, we need to use a different UDP port for one
/// of the sockets.
#[cfg(test)]
const LISTEN_PORT: u16 = DISCOVERY_PORT - 1;

#[cfg(test)]
#[serial_test::serial(mock_discoverable)]
mod tests {
    use tokio::net::UdpSocket;

    use super::*;

    /// A UDP socket listener that pretends to be an AirTouch 5 console, for
    /// testing purposes.
    struct MockDiscoverable {
        c: Console,
        socket: UdpSocket,
    }
    impl MockDiscoverable {
        async fn new() -> std::io::Result<Self> {
            let mock = Self {
                c: Console {
                    address: broadcast_addresses().unwrap()[0],
                    console_id: "AT5C202502000001".to_owned(),
                    airtouch_id: 13,
                    name: "Test unit".to_owned(),
                },
                socket: create_socket(DISCOVERY_PORT).await?,
            };
            debug!("mock unit listening on {}", mock.socket.local_addr()?);
            Ok(mock)
        }
        /// Listen for a broadcast request and respond to it, once.
        async fn single(&self) -> std::io::Result<()> {
            let mut buf = vec![0u8; 128];
            trace!("mock unit waiting for request... ");
            std::io::stderr().flush()?;
            let (len, sockaddr) = self.socket.recv_from(&mut buf).await?;
            debug!(
                "mock unit received {:?}... ",
                std::str::from_utf8(&buf[..len]).unwrap()
            );
            std::io::stderr().flush()?;
            assert_eq!(len, DISCOVERY_REQUEST.len());
            assert_eq!(&buf[..len], DISCOVERY_REQUEST.as_bytes());
            trace!("mock unit recv ok");

            let resp = format!(
                "{},{},AirTouch5,{},{}",
                self.c.address, self.c.console_id, self.c.airtouch_id, self.c.name
            );
            debug!("mock unit sending to {}... ", sockaddr);
            std::io::stderr().flush()?;
            assert_eq!(
                self.socket.send_to(resp.as_bytes(), sockaddr).await?,
                resp.len()
            );
            trace!("mock unit send done");
            Ok(())
        }
    }

    #[tokio::test]
    async fn test_ok() {
        let mock = MockDiscoverable::new()
            .await
            .expect("error constructing mock");
        let expected = mock.c.clone();
        tokio::spawn(async move {
            assert!(mock.single().await.is_ok());
        });

        assert_matches!(discover().await, Ok(c) => { assert_eq!(c, expected); });
    }

    #[cfg(feature = "timeout")]
    #[tokio::test]
    async fn test_timeout_default() {
        assert_matches!(
            discover_timeout(None).await,
            Err(DiscoveryError::NoResponse)
        );
    }

    #[cfg(feature = "timeout")]
    #[tokio::test]
    async fn test_timeout_specific() {
        assert_matches!(
            discover_timeout(Some(Duration::from_millis(250))).await,
            Err(DiscoveryError::NoResponse)
        );
    }

    #[tokio::test]
    async fn test_create_socket() {
        let sock = create_socket(LISTEN_PORT).await;
        assert_matches!(sock, Ok(udp) => {
            assert_matches!(udp.local_addr(), Ok(addr) => {
                assert_eq!(addr, std::net::SocketAddr::V4(std::net::SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, LISTEN_PORT)));
            });
            assert_matches!(udp.broadcast(), Ok(true));
        });
    }

    #[tokio::test]
    #[serial_test::parallel]
    async fn test_broadcast_addresses() {
        // use _real(), since we otherwise only see localhost
        let addrs = broadcast_addresses_real().expect("failed to get broadcast addresses");
        assert!(!addrs.is_empty(), "broadcast addresses should not be empty");
        assert!(
            !addrs.contains(&IpAddr::V4(Ipv4Addr::LOCALHOST)),
            "broadcast addresses should not include localhost"
        );
        // We could possibly check that `broadcast_addr = (if_addr & netmask) | (-1 & ~netmask)`,
        // but that feels more like testing the network-interfaces` crate, or even the underlying
        // OS calls, than our own code.
    }
}