kasa_core/
lib.rs

1//! Core library for communicating with TP-Link Kasa smart home devices.
2//!
3//! This crate implements both the legacy TP-Link Smart Home Protocol (XOR cipher)
4//! and the newer KLAP protocol (AES encryption with authentication).
5//!
6//! # Protocols
7//!
8//! ## Legacy Protocol (XOR)
9//!
10//! Older devices use a simple XOR autokey cipher on TCP port 9999.
11//! No authentication is required. Use [`send_command`] for quick access.
12//!
13//! ## KLAP Protocol
14//!
15//! Newer firmware versions use the KLAP (Kasa Local Authentication Protocol)
16//! over HTTP port 80. This requires TP-Link cloud credentials. Use the
17//! [`transport`] module for KLAP support.
18//!
19//! # Quick Start
20//!
21//! For legacy devices (no credentials needed):
22//!
23//! ```no_run
24//! use kasa_core::{commands, send_command, DEFAULT_PORT, DEFAULT_TIMEOUT};
25//!
26//! #[tokio::main]
27//! async fn main() -> std::io::Result<()> {
28//!     let response = send_command(
29//!         "192.168.1.100",
30//!         DEFAULT_PORT,
31//!         DEFAULT_TIMEOUT,
32//!         commands::INFO,
33//!     ).await?;
34//!     println!("{}", response);
35//!     Ok(())
36//! }
37//! ```
38//!
39//! For newer devices with KLAP (credentials required):
40//!
41//! ```no_run
42//! use kasa_core::{Credentials, transport::{DeviceConfig, connect, Transport}};
43//!
44//! #[tokio::main]
45//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
46//!     let config = DeviceConfig::new("192.168.1.100")
47//!         .with_credentials(Credentials::new("user@example.com", "password"));
48//!
49//!     let transport = connect(config).await?;
50//!     let response = transport.send(r#"{"system":{"get_sysinfo":{}}}"#).await?;
51//!     println!("{}", response);
52//!     Ok(())
53//! }
54//! ```
55//!
56//! # Auto-Detection
57//!
58//! The [`transport::connect`] function automatically detects which protocol
59//! a device uses and connects appropriately.
60//!
61//! # Concurrency
62//!
63//! When working with multiple devices, you can communicate with them concurrently.
64//! However, **requests to a single device must be sequential** - TP-Link devices
65//! cannot handle concurrent requests and will return errors.
66//!
67//! ```no_run
68//! use kasa_core::transport::{DeviceConfig, connect, TransportExt};
69//!
70//! #[tokio::main]
71//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
72//!     // Connect to multiple devices
73//!     let device1 = connect(DeviceConfig::new("192.168.1.100")).await?;
74//!     let device2 = connect(DeviceConfig::new("192.168.1.101")).await?;
75//!
76//!     // Query both devices concurrently (safe - different devices)
77//!     let (info1, info2) = tokio::join!(
78//!         device1.get_sysinfo(),
79//!         device2.get_sysinfo(),
80//!     );
81//!
82//!     println!("Device 1: {}", info1?.alias);
83//!     println!("Device 2: {}", info2?.alias);
84//!     Ok(())
85//! }
86//! ```
87
88use std::{net::IpAddr, time::Duration};
89
90use serde::{Deserialize, Serialize};
91use tokio::{net::UdpSocket, time::timeout};
92use tracing::debug;
93
94// Public modules
95pub mod commands;
96pub mod credentials;
97pub mod crypto;
98pub mod discovery;
99pub mod error;
100pub mod response;
101pub mod transport;
102
103// Re-exports for convenience
104pub use credentials::Credentials;
105pub use discovery::DiscoveredDevice;
106pub use error::Error;
107pub use transport::{DeviceConfig, EncryptionType, Transport, TransportExt, connect};
108
109// Re-export crypto functions for backward compatibility
110pub use crypto::xor::{decrypt, encrypt};
111
112/// The version of the kasa-core library.
113pub const VERSION: &str = env!("CARGO_PKG_VERSION");
114
115/// Default TCP port for legacy TP-Link Kasa smart devices.
116///
117/// Legacy devices listen on port 9999 for the XOR-encrypted Smart Home Protocol.
118/// Newer devices use port 80 (HTTP) for the KLAP protocol.
119pub const DEFAULT_PORT: u16 = 9999;
120
121/// Default connection timeout.
122///
123/// This timeout applies to connection establishment, read, and write operations.
124pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
125
126/// Default discovery timeout.
127///
128/// How long to wait for devices to respond to broadcast discovery.
129pub const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3);
130
131/// Broadcast address for UDP discovery.
132const BROADCAST_ADDR: &str = "255.255.255.255:9999";
133
134/// Security type for WiFi networks.
135///
136/// Used when scanning for networks or joining a network.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
138#[repr(u8)]
139pub enum KeyType {
140    /// Open network (no security)
141    None = 0,
142    /// WEP encryption (legacy, insecure)
143    Wep = 1,
144    /// WPA-PSK encryption
145    Wpa = 2,
146    /// WPA2-PSK encryption (most common)
147    #[default]
148    Wpa2 = 3,
149}
150
151impl From<KeyType> for u8 {
152    fn from(key_type: KeyType) -> Self {
153        key_type as u8
154    }
155}
156
157impl TryFrom<u8> for KeyType {
158    type Error = &'static str;
159
160    fn try_from(value: u8) -> Result<Self, Self::Error> {
161        match value {
162            0 => Ok(KeyType::None),
163            1 => Ok(KeyType::Wep),
164            2 => Ok(KeyType::Wpa),
165            3 => Ok(KeyType::Wpa2),
166            _ => Err("Invalid key type: must be 0-3"),
167        }
168    }
169}
170
171impl std::fmt::Display for KeyType {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            KeyType::None => write!(f, "None"),
175            KeyType::Wep => write!(f, "WEP"),
176            KeyType::Wpa => write!(f, "WPA"),
177            KeyType::Wpa2 => write!(f, "WPA2"),
178        }
179    }
180}
181
182/// Information about a WiFi network from a scan.
183///
184/// Returned by the device when scanning for available networks.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct WifiNetwork {
187    /// Network name (SSID)
188    pub ssid: String,
189    /// Security type
190    pub key_type: u8,
191    /// Signal strength in dBm (negative, closer to 0 = stronger)
192    pub rssi: i32,
193}
194
195/// Result of a broadcast command to a single device.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct BroadcastResult {
198    /// IP address of the device.
199    pub ip: IpAddr,
200    /// Device alias/name.
201    pub alias: String,
202    /// Device model.
203    pub model: String,
204    /// Whether the command succeeded.
205    pub success: bool,
206    /// The response from the device (if successful).
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub response: Option<serde_json::Value>,
209    /// Error message (if failed).
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub error: Option<String>,
212}
213
214/// Sends a command to a TP-Link Kasa device using the legacy XOR protocol.
215///
216/// This function uses the legacy protocol (TCP port 9999, XOR encryption).
217/// For devices with newer firmware that use KLAP, use [`transport::connect`] instead.
218///
219/// # Arguments
220///
221/// * `target` - Hostname or IP address of the device
222/// * `port` - TCP port number (typically [`DEFAULT_PORT`] which is 9999)
223/// * `command_timeout` - Connection and I/O timeout
224/// * `command` - JSON command string to send
225///
226/// # Returns
227///
228/// On success, returns the decrypted JSON response from the device.
229///
230/// # Errors
231///
232/// Returns an `io::Error` if the connection fails or times out.
233///
234/// # Example
235///
236/// ```no_run
237/// use kasa_core::{commands, send_command, DEFAULT_PORT, DEFAULT_TIMEOUT};
238///
239/// #[tokio::main]
240/// async fn main() -> std::io::Result<()> {
241///     let response = send_command(
242///         "192.168.1.100",
243///         DEFAULT_PORT,
244///         DEFAULT_TIMEOUT,
245///         commands::INFO,
246///     ).await?;
247///     println!("{}", response);
248///     Ok(())
249/// }
250/// ```
251pub async fn send_command(
252    target: &str,
253    port: u16,
254    command_timeout: Duration,
255    command: &str,
256) -> std::io::Result<String> {
257    use transport::LegacyTransport;
258
259    let transport = LegacyTransport::new(target, port, command_timeout);
260    transport
261        .send(command)
262        .await
263        .map_err(|e| std::io::Error::other(e.to_string()))
264}
265
266/// Discovers Kasa devices on the local network using UDP broadcast.
267///
268/// This function sends a UDP broadcast to find all Kasa devices on the local
269/// network. Note that devices using KLAP may not respond to legacy discovery.
270///
271/// # Arguments
272///
273/// * `discovery_timeout` - How long to wait for device responses.
274///
275/// # Returns
276///
277/// A vector of discovered devices.
278///
279/// # Example
280///
281/// ```no_run
282/// use kasa_core::{discover, DEFAULT_DISCOVERY_TIMEOUT};
283///
284/// #[tokio::main]
285/// async fn main() -> std::io::Result<()> {
286///     let devices = discover(DEFAULT_DISCOVERY_TIMEOUT).await?;
287///     for device in devices {
288///         println!("Found: {} ({}) at {}", device.alias, device.model, device.ip);
289///     }
290///     Ok(())
291/// }
292/// ```
293pub async fn discover(discovery_timeout: Duration) -> std::io::Result<Vec<DiscoveredDevice>> {
294    let socket = UdpSocket::bind("0.0.0.0:0").await?;
295    socket.set_broadcast(true)?;
296
297    let encrypted = crypto::xor::encrypt_udp(commands::INFO);
298    debug!(addr = BROADCAST_ADDR, "sending discovery broadcast");
299    socket.send_to(&encrypted, BROADCAST_ADDR).await?;
300
301    let mut devices = Vec::new();
302    let mut buf = [0u8; 4096];
303
304    let deadline = tokio::time::Instant::now() + discovery_timeout;
305
306    loop {
307        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
308        if remaining.is_zero() {
309            debug!("Discovery timeout reached");
310            break;
311        }
312
313        match timeout(remaining, socket.recv_from(&mut buf)).await {
314            Ok(Ok((n, addr))) => {
315                debug!(bytes = n, addr = %addr, "received discovery response");
316                let decrypted = decrypt(&buf[..n]);
317                debug!(response = %decrypted, "decrypted discovery response");
318
319                if let Ok(response) = serde_json::from_str::<response::SysInfoResponse>(&decrypted)
320                {
321                    let info = response.system.get_sysinfo;
322
323                    devices.push(DiscoveredDevice {
324                        ip: addr.ip(),
325                        port: DEFAULT_PORT,
326                        mac: info.mac_address().to_string(),
327                        relay_state: info.is_on(),
328                        led_off: info.is_led_off(),
329                        updating: info.is_updating(),
330                        rssi: info.rssi,
331                        on_time: info.on_time,
332                        alias: info.alias,
333                        model: info.model,
334                        device_id: info.device_id,
335                        hw_ver: info.hw_ver,
336                        sw_ver: info.sw_ver,
337                        encryption_type: EncryptionType::Xor, // Legacy discovery
338                        http_port: None,
339                        new_klap: None,
340                        login_version: None,
341                    });
342                }
343            }
344            Ok(Err(e)) => {
345                debug!(error = %e, "error receiving discovery response");
346                break;
347            }
348            Err(_) => {
349                debug!("discovery timeout reached");
350                break;
351            }
352        }
353    }
354
355    debug!(device_count = devices.len(), "discovery completed");
356    Ok(devices)
357}
358
359/// Broadcasts a command to all discovered Kasa devices on the local network.
360///
361/// This function first discovers all devices, then sends the specified command
362/// to each device concurrently. It uses the appropriate protocol for each device
363/// based on discovery results (encryption hints).
364///
365/// # Arguments
366///
367/// * `discovery_timeout` - How long to wait for device discovery
368/// * `command_timeout` - Timeout for each device command
369/// * `command` - JSON command string to send to all devices
370/// * `credentials` - Optional TP-Link cloud credentials for KLAP/TPAP devices
371///
372/// # Returns
373///
374/// A vector of [`BroadcastResult`] containing the result for each discovered device.
375///
376/// # Protocol Selection
377///
378/// For each discovered device, the function uses [`DeviceConfig::from_discovered`]
379/// to configure the connection with the appropriate encryption hint. This ensures
380/// devices using KLAP or TPAP protocols are handled correctly when credentials
381/// are provided.
382///
383/// # Energy Command Handling
384///
385/// For energy commands (`{"emeter":{"get_realtime":{}}}`), this function
386/// automatically uses [`TransportExt::get_all_energy`] to fetch energy data
387/// for all plugs on power strips, not just the first plug.
388pub async fn broadcast(
389    discovery_timeout: Duration,
390    command_timeout: Duration,
391    command: &str,
392    credentials: Option<Credentials>,
393) -> std::io::Result<Vec<BroadcastResult>> {
394    let devices = discovery::discover_all(discovery_timeout).await?;
395    debug!(device_count = devices.len(), "broadcasting command");
396
397    if devices.is_empty() {
398        return Ok(Vec::new());
399    }
400
401    let command = command.to_string();
402    let credentials = credentials.map(std::sync::Arc::new);
403    let is_energy_command = command.contains("emeter") && command.contains("get_realtime");
404
405    let futures: Vec<_> = devices
406        .into_iter()
407        .map(|device| {
408            let cmd = command.clone();
409            let creds = credentials.clone();
410            async move {
411                // Build config from discovered device (includes encryption hint)
412                let mut config =
413                    DeviceConfig::from_discovered(&device).with_timeout(command_timeout);
414
415                if let Some(creds) = creds {
416                    config = config.with_credentials((*creds).clone());
417                }
418
419                // Try to connect using the appropriate protocol
420                let transport = match transport::connect(config).await {
421                    Ok(t) => t,
422                    Err(e) => {
423                        return BroadcastResult {
424                            ip: device.ip,
425                            alias: device.alias,
426                            model: device.model,
427                            success: false,
428                            response: None,
429                            error: Some(e.to_string()),
430                        };
431                    }
432                };
433
434                // Use get_all_energy() for energy commands to handle power strips
435                let result = if is_energy_command {
436                    transport
437                        .get_all_energy()
438                        .await
439                        .map(|energy| serde_json::to_value(&energy).ok())
440                } else {
441                    transport
442                        .send(&cmd)
443                        .await
444                        .map(|response| serde_json::from_str(&response).ok())
445                };
446
447                match result {
448                    Ok(json_response) => BroadcastResult {
449                        ip: device.ip,
450                        alias: device.alias,
451                        model: device.model,
452                        success: true,
453                        response: json_response,
454                        error: None,
455                    },
456                    Err(e) => BroadcastResult {
457                        ip: device.ip,
458                        alias: device.alias,
459                        model: device.model,
460                        success: false,
461                        response: None,
462                        error: Some(e.to_string()),
463                    },
464                }
465            }
466        })
467        .collect();
468
469    let results = futures::future::join_all(futures).await;
470    Ok(results)
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_encrypt_decrypt_roundtrip() {
479        let original = r#"{"system":{"get_sysinfo":{}}}"#;
480        let encrypted = encrypt(original);
481        let decrypted = decrypt(&encrypted[4..]);
482        assert_eq!(original, decrypted);
483    }
484
485    #[test]
486    fn test_encrypt_has_length_header() {
487        let input = "test";
488        let encrypted = encrypt(input);
489        let len = u32::from_be_bytes([encrypted[0], encrypted[1], encrypted[2], encrypted[3]]);
490        assert_eq!(len as usize, input.len());
491    }
492
493    #[test]
494    fn test_decrypt_empty() {
495        let result = decrypt(&[]);
496        assert_eq!(result, "");
497    }
498
499    #[test]
500    fn test_encrypt_produces_correct_length() {
501        let input = "hello world";
502        let encrypted = encrypt(input);
503        assert_eq!(encrypted.len(), 4 + input.len());
504    }
505}