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}