tplink_hs110/
lib.rs

1//! A library to control TP-Link HS110 (and HS100) SmartPlugs over Wi-Fi.
2use error::TpLinkHs110Error;
3use serde_json::{json, Value};
4use std::{
5    fmt::Display,
6    io::{Read, Write},
7    mem::size_of,
8    net::{self, SocketAddr},
9    ops::Not,
10    time::Duration,
11};
12
13pub mod error;
14
15const NET_BUFFER_SIZE: usize = 8192;
16
17/// HS110 smartplug.
18#[derive(Debug)]
19pub struct HS110 {
20    /// Smartplug network address.
21    socket_addr: SocketAddr,
22
23    /// Optional timeout for network communication.
24    timeout: Option<Duration>,
25}
26
27impl HS110 {
28    /// Attempts to create a new HS110 instance using given network address.
29    pub fn new(addr: &str) -> Result<Self, TpLinkHs110Error> {
30        let socket_addr = match addr.find(':') {
31            Some(_) => addr.parse(),
32            None => (addr.to_string() + ":9999").parse(),
33        }?;
34
35        Ok(Self {
36            socket_addr,
37            timeout: None,
38        })
39    }
40
41    /// Sets a timeout for network communication with a smartplug.
42    pub fn with_timeout(mut self, duration: Duration) -> Self {
43        self.timeout = Some(duration);
44        self
45    }
46
47    /// "Encrypts" a given string (which is usually a command represented as a JSON).
48    ///
49    /// This way of encryption/scrambling is necessary before sending a command to a smartplug.
50    fn encrypt<S>(payload: S) -> Vec<u8>
51    where
52        S: AsRef<str>,
53    {
54        let mut key = 171;
55
56        (payload.as_ref().len() as u32)
57            .to_be_bytes()
58            .into_iter()
59            .chain(payload.as_ref().as_bytes().iter().map(|v| {
60                key ^= v;
61                key
62            }))
63            .collect()
64    }
65
66    /// Attempts to decrypt/unscramble data received from a smartplug.
67    fn decrypt(payload: &[u8]) -> Result<String, TpLinkHs110Error> {
68        const HEADER_LEN: usize = size_of::<u32>();
69        if payload.len() < HEADER_LEN {
70            Err(TpLinkHs110Error::ShortEncryptedResponse(payload.len()))?
71        }
72
73        let payload_len_from_header = u32::from_be_bytes(payload[..HEADER_LEN].try_into()?);
74        let payload_len_actual = payload.len() - HEADER_LEN;
75        if payload_len_actual != payload_len_from_header as usize {
76            Err(TpLinkHs110Error::EncryptedPayloadLengthMismatch {
77                payload_len_actual,
78                payload_len_from_header,
79            })?;
80        }
81
82        let mut key = 171;
83        let decrypted: String = payload[HEADER_LEN..]
84            .iter()
85            .map(|byte| {
86                let plain_char = (key ^ byte) as char;
87                key = *byte;
88                plain_char
89            })
90            .collect();
91
92        Ok(decrypted)
93    }
94
95    /// Attempts to send a provided request to a smartplug, receive a response and represent it as
96    /// as plaing text string (usually containing JSON).
97    fn request<S>(&self, request: S) -> Result<String, TpLinkHs110Error>
98    where
99        S: AsRef<str>,
100    {
101        let mut stream = match self.timeout {
102            None => net::TcpStream::connect(self.socket_addr)?,
103            Some(duration) => {
104                let stream = net::TcpStream::connect_timeout(&self.socket_addr, duration)?;
105                stream.set_read_timeout(self.timeout)?;
106                stream.set_write_timeout(self.timeout)?;
107                stream
108            }
109        };
110
111        stream.write_all(&Self::encrypt(request))?;
112        stream.flush()?;
113
114        let mut received = vec![];
115        let mut rx_buf = [0u8; NET_BUFFER_SIZE];
116        loop {
117            let nread = stream.read(&mut rx_buf)?;
118            received.extend_from_slice(&rx_buf[..nread]);
119            if nread < NET_BUFFER_SIZE {
120                break;
121            }
122        }
123
124        Self::decrypt(&received)
125    }
126
127    /// Attempts to get a general info from/about a smartplug.
128    ///
129    /// In case of success a resulting JSON Value looks similar to this:
130    /// ```text
131    /// {
132    ///   "system": {
133    ///     "get_sysinfo": {
134    ///       "active_mode": "schedule",
135    ///       "alias": "Bathroom",
136    ///       "dev_name": "Wi-Fi Smart Plug With Energy Monitoring",
137    ///       "deviceId": "800644100000BB3AC70000FB15245D6C190F936B",
138    ///       "err_code": 0,
139    ///       "feature": "TIM:ENE",
140    ///       "fwId": "00000000000000000000000000000000",
141    ///       "hwId": "47E30DA8382497D2E82691B52A3B2EB3",
142    ///       "hw_ver": "1.0",
143    ///       "icon_hash": "",
144    ///       "latitude": 47.782857,
145    ///       "led_off": 0,
146    ///       "longitude": 35.186122,
147    ///       "mac": "70:4F:57:57:A1:14",
148    ///       "model": "HS110(EU)",
149    ///       "oemId": "4D345ECE299C0641C96E27CE2430548B",
150    ///       "on_time": 8819452,
151    ///       "relay_state": 1,
152    ///       "rssi": -64,
153    ///       "sw_ver": "1.2.6 Build 200727 Rel.120821",
154    ///       "type": "IOT.SMARTPLUGSWITCH",
155    ///       "updating": 0
156    ///     }
157    ///   }
158    /// }
159    /// ```
160    pub fn info(&self) -> Result<Value, TpLinkHs110Error> {
161        Ok(serde_json::from_str::<Value>(&self.request(
162            json!({"system": {"get_sysinfo": {}}}).to_string(),
163        )?)?)
164    }
165
166    /// Helper function which attempts to extract an object/field under specified hierarchical
167    /// path in a JSON obtained with `get_sysinfo` command.
168    fn info_field_value(&self, field: &'static str) -> Result<Value, TpLinkHs110Error> {
169        self.info()?
170            .extract_hierarchical(&["system", "get_sysinfo", field])
171    }
172
173    /// Attempts to get current LED state (which could be ON or OFF).
174    pub fn led_state(&self) -> Result<LedState, TpLinkHs110Error> {
175        Ok((self
176            .info_field_value("led_off")?
177            .as_u64()
178            .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
179            == 0)
180            .into())
181    }
182
183    /// Attempts to switch LED to a specified state (i.e. turn it ON or turn it OFF).
184    pub fn set_led_state(&self, led_state: LedState) -> Result<(), TpLinkHs110Error> {
185        match serde_json::from_str::<Value>(
186            &self.request(
187                json!({"system": {"set_led_off": {"off": (led_state == LedState::Off) as u8 }}})
188                    .to_string(),
189            )?,
190        )?
191        .extract_hierarchical(&["system", "set_led_off", "err_code"])?
192        .as_i64()
193        .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
194        {
195            0 => Ok(()),
196            err_code => Err(TpLinkHs110Error::SmartplugErrCode(err_code)),
197        }
198    }
199
200    /// Attempts to obtain a smartplug name (alias). Name is given during smartplug initial setup,
201    /// and it could be changed in companion app (Tapo or Kasa) on a mobile phone.
202    pub fn hostname(&self) -> Result<String, TpLinkHs110Error> {
203        Ok(self
204            .info_field_value("alias")?
205            .as_str()
206            .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
207            .to_string())
208    }
209
210    /// Attempts to obtain hardware version (hardware revision) of a smartplug.
211    pub fn hw_version(&self) -> Result<HwVersion, TpLinkHs110Error> {
212        match self
213            .info_field_value("hw_ver")?
214            .as_str()
215            .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
216        {
217            "1.0" => Ok(HwVersion::Version1),
218            "2.0" => Ok(HwVersion::Version2),
219            other => Ok(HwVersion::Unsupported(other.into())),
220        }
221    }
222
223    /// Attempts to get current power relay state. It is either smartplug powers connected device
224    /// (ON) or not (OFF).
225    pub fn power_state(&self) -> Result<PowerState, TpLinkHs110Error> {
226        Ok((self
227            .info_field_value("relay_state")?
228            .as_u64()
229            .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
230            == 1)
231            .into())
232    }
233
234    /// Attempts to switch power relay on or switch it off.
235    pub fn set_power_state(&self, state: PowerState) -> Result<(), TpLinkHs110Error> {
236        match serde_json::from_str::<Value>(
237            &self.request(
238                json!({"system": {"set_relay_state": {"state": (state == PowerState::On) as u8 }}})
239                    .to_string(),
240            )?,
241        )?
242        .extract_hierarchical(&["system", "set_relay_state", "err_code"])?
243        .as_i64()
244        .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
245        {
246            0 => Ok(()),
247            err_code => Err(TpLinkHs110Error::SmartplugErrCode(err_code)),
248        }
249    }
250
251    /// Attempts to get an information about smartplug connection to TP-Link cloud.
252    ///
253    /// In case of success resulting JSON Value looks similar to this:
254    /// ```text
255    /// Object {
256    ///     "binded": Number(1),
257    ///     "cld_connection": Number(1),
258    ///     "err_code": Number(0),
259    ///     "fwDlPage": String(""),
260    ///     "fwNotifyType": Number(0),
261    ///     "illegalType": Number(0),
262    ///     "server": String("n-devs.tplinkcloud.com"),
263    ///     "stopConnect": Number(0),
264    ///     "tcspInfo": String(""),
265    ///     "tcspStatus": Number(1),
266    ///     "username": String("username@example.com"),
267    /// }
268    /// ```
269    pub fn cloudinfo(&self) -> Result<Value, TpLinkHs110Error> {
270        serde_json::from_str::<Value>(
271            &self.request(json!({"cnCloud": {"get_info": {}}}).to_string())?,
272        )?
273        .extract_hierarchical(&["cnCloud", "get_info"])
274    }
275
276    /// Attempts to get an information about Wi-Fi access points which smartplug observes in a
277    /// radio spectrum.
278    /// The `refresh` boolean specifies whether it is necessary to perform scan of Wi-Fi spectrum
279    /// (i.e. refresh the list of access points), or not.
280    ///
281    /// In case of success resulting JSON looks similar to the following:
282    /// ```text
283    /// Array [
284    ///     Object {
285    ///         "key_type": Number(3),
286    ///         "ssid": String("MERCUSYS_1A04"),
287    ///     },
288    ///     Object {
289    ///         "key_type": Number(3),
290    ///         "ssid": String("RADIO"),
291    ///     },
292    ///     Object {
293    ///         "key_type": Number(3),
294    ///         "ssid": String("TP-Link_C1F3"),
295    ///     },
296    ///     Object {
297    ///         "key_type": Number(2),
298    ///         "ssid": String("ZyXEL_KEENEKTIC_LITE_76FAFB"),
299    ///     },
300    /// ],
301    /// ```
302    pub fn ap_list(&self, refresh: bool) -> Result<Value, TpLinkHs110Error> {
303        serde_json::from_str::<Value>(
304            &self.request(
305                json!({"netif": {"get_scaninfo": {"refresh": refresh as u8}}}).to_string(),
306            )?,
307        )?
308        .extract_hierarchical(&["netif", "get_scaninfo", "ap_list"])
309    }
310
311    /// Attempts to get values from smartplug's energy meter. Energy meter is present in HS110, and
312    /// absent in HS100.
313    ///
314    /// In case of success resulting JSON looks like this:
315    /// ```text
316    /// Object {
317    ///     "current": Number(0.027824),
318    ///     "current_ma": Number(27.824),
319    ///     "err_code": Number(0),
320    ///     "power": Number(0.770242),
321    ///     "power_mw": Number(770.242),
322    ///     "total": Number(625.833),
323    ///     "total_wh": Number(625833.0),
324    ///     "voltage": Number(228.603726),
325    ///     "voltage_mv": Number(228603.726),
326    /// }
327    /// ```
328    pub fn emeter(&self) -> Result<Value, TpLinkHs110Error> {
329        let mut emeter = serde_json::from_str::<Value>(
330            &self.request(json!({"emeter":{"get_realtime":{}}}).to_string())?,
331        )?
332        .extract_hierarchical(&["emeter", "get_realtime"])?;
333
334        // Smart plugs of HW version 1 and HW version 2 provide results via different JSON fields
335        // and use different units.
336        // I.e. one uses "voltage" in Volts and another "voltage_mv" in milliVolts.
337        //
338        // As it not clear which version is "better" or more widely used - calculate and provide
339        // both fields for both hardware versions:
340        #[rustfmt::skip]
341        [
342            ("voltage_mv", "voltage",    0.001f64),
343            ("current_ma", "current",    0.001f64),
344            ("power_mw",   "power",      0.001f64),
345            ("total_wh",   "total",      0.001f64),
346            ("voltage",    "voltage_mv", 1000f64),
347            ("current",    "current_ma", 1000f64),
348            ("power",      "power_mw",   1000f64),
349            ("total",      "total_wh",   1000f64),
350        ]
351        .iter()
352        .for_each(|(from, to, multiplier)| {
353            if let Some(from) = emeter.get(from) {
354                if emeter.get(to).is_none() {
355                    emeter[to] = Value::from(from.as_f64().unwrap_or(0f64) * multiplier);
356                }
357            }
358        });
359
360        Ok(emeter)
361    }
362
363    /// Attempts to reboot a smartplug with an optional delay (in seconds).
364    pub fn reboot(&self, delay: Option<u32>) -> Result<(), TpLinkHs110Error> {
365        match serde_json::from_str::<Value>(
366            &self.request(
367                json!({"system": {"reboot": {"delay": delay.unwrap_or(0) }}}).to_string(),
368            )?,
369        )?
370        .extract_hierarchical(&["system", "reboot", "err_code"])?
371        .as_i64()
372        .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
373        {
374            0 => Ok(()),
375            err_code => Err(TpLinkHs110Error::SmartplugErrCode(err_code)),
376        }
377    }
378
379    /// Attempts to perform a factory reset with an optional delay (in seconds).
380    pub fn factory_reset(&self, delay: Option<u32>) -> Result<(), TpLinkHs110Error> {
381        match serde_json::from_str::<Value>(
382            &self.request(
383                json!({"system": {"reset": {"delay": delay.unwrap_or(0) }}}).to_string(),
384            )?,
385        )?
386        .extract_hierarchical(&["system", "reset", "err_code"])?
387        .as_i64()
388        .ok_or(TpLinkHs110Error::UnexpectedValueRepresentation)?
389        {
390            0 => Ok(()),
391            err_code => Err(TpLinkHs110Error::SmartplugErrCode(err_code)),
392        }
393    }
394}
395
396trait ExtractHierarchical {
397    fn extract_hierarchical(&self, path: &[&'static str]) -> Result<Value, TpLinkHs110Error>;
398}
399
400impl ExtractHierarchical for Value {
401    /// Attempts to traverse hierarchical structure (JSON object) over provided path and returns
402    /// corresponding sub-object/field.
403    fn extract_hierarchical(&self, path: &[&'static str]) -> Result<Value, TpLinkHs110Error> {
404        let mut current_object = self;
405        for key in path {
406            current_object =
407                current_object
408                    .get(key)
409                    .ok_or_else(|| TpLinkHs110Error::KeyIsNotAvailable {
410                        response: self.clone(),
411                        key,
412                    })?;
413        }
414
415        Ok(current_object.clone())
416    }
417}
418
419/// Smartplug hardware version (hardware revision).
420#[derive(Debug)]
421pub enum HwVersion {
422    Version1,
423    Version2,
424    Unsupported(String),
425}
426
427/// Smartplug's power relay state.
428#[derive(Debug, Clone, Copy, PartialEq)]
429pub enum PowerState {
430    /// Power relay is ON, i.e. smartplug is powering its outlet (connected device).
431    On,
432
433    /// Power relay is OFF, i.e. there is no power on smartplug's outlet.
434    Off,
435}
436
437impl Display for PowerState {
438    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439        write!(
440            f,
441            "{}",
442            match self {
443                PowerState::On => "ON",
444                PowerState::Off => "OFF",
445            }
446        )
447    }
448}
449
450impl Not for PowerState {
451    type Output = PowerState;
452
453    fn not(self) -> Self::Output {
454        match self {
455            PowerState::On => PowerState::Off,
456            PowerState::Off => PowerState::On,
457        }
458    }
459}
460
461impl From<PowerState> for bool {
462    fn from(value: PowerState) -> Self {
463        match value {
464            PowerState::On => true,
465            PowerState::Off => false,
466        }
467    }
468}
469
470impl From<bool> for PowerState {
471    fn from(value: bool) -> Self {
472        match value {
473            true => Self::On,
474            false => Self::Off,
475        }
476    }
477}
478
479/// Smartplug LED indicator state.
480#[derive(Debug, Clone, Copy, PartialEq)]
481pub enum LedState {
482    /// LED light is ON.
483    On,
484
485    /// LED light is OFF.
486    Off,
487}
488
489impl Display for LedState {
490    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491        write!(
492            f,
493            "{}",
494            match self {
495                LedState::On => "ON",
496                LedState::Off => "OFF",
497            }
498        )
499    }
500}
501
502impl Not for LedState {
503    type Output = LedState;
504
505    fn not(self) -> Self::Output {
506        match self {
507            LedState::On => LedState::Off,
508            LedState::Off => LedState::On,
509        }
510    }
511}
512
513impl From<LedState> for bool {
514    fn from(value: LedState) -> Self {
515        match value {
516            LedState::On => true,
517            LedState::Off => false,
518        }
519    }
520}
521
522impl From<bool> for LedState {
523    fn from(value: bool) -> Self {
524        match value {
525            true => Self::On,
526            false => Self::Off,
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use crate::*;
534    use once_cell::sync::Lazy;
535    use serial_test::serial;
536
537    static TEST_TARGET_ADDR: Lazy<String> =
538        Lazy::new(|| std::env::var("TEST_TARGET_ADDR").expect("TEST_TARGET_ADDR env variable"));
539
540    #[test]
541    #[serial]
542    fn hostname() {
543        let smartplug = HS110::new(&*TEST_TARGET_ADDR)
544            .unwrap()
545            .with_timeout(Duration::from_secs(3));
546        assert!(smartplug.hostname().is_ok());
547
548        let smartplug = HS110::new(&*TEST_TARGET_ADDR).unwrap();
549        assert!(smartplug.hostname().is_ok());
550
551        assert!(matches!(
552            smartplug.hw_version(),
553            Ok(HwVersion::Version1) | Ok(HwVersion::Version2)
554        ));
555    }
556
557    #[test]
558    fn switch_led_on_off() {
559        let smartplug = HS110::new(&*TEST_TARGET_ADDR).unwrap();
560
561        let original_state = smartplug.led_state().expect("failed to obtain LED state");
562
563        assert!(smartplug.set_led_state(!original_state).is_ok());
564        assert_eq!(
565            smartplug.led_state().expect("failed to obtain LED state"),
566            !original_state
567        );
568
569        assert!(smartplug.set_led_state(original_state).is_ok());
570        assert_eq!(
571            smartplug.led_state().expect("failed to obtain LED state"),
572            original_state
573        );
574    }
575
576    #[test]
577    #[serial]
578    #[ignore = "power-cycles devices connected to the plug"]
579    fn switch_power_on_off() {
580        let smartplug = HS110::new(&*TEST_TARGET_ADDR).unwrap();
581
582        let original_state = smartplug
583            .power_state()
584            .expect("failed to obtain smartplug power state");
585
586        assert!(smartplug.set_power_state(!original_state).is_ok());
587        assert_eq!(
588            smartplug
589                .power_state()
590                .expect("failed to obtain smartplug power state"),
591            !original_state
592        );
593
594        assert!(smartplug.set_power_state(original_state).is_ok());
595        assert_eq!(
596            smartplug
597                .power_state()
598                .expect("failed to obtain smartplug power state"),
599            original_state
600        );
601    }
602
603    #[test]
604    fn get_cloudinfo() {
605        assert!(HS110::new(&*TEST_TARGET_ADDR).unwrap().cloudinfo().is_ok());
606    }
607
608    #[test]
609    #[serial]
610    fn access_points_list_and_scan() {
611        let smartplug = HS110::new(&*TEST_TARGET_ADDR).unwrap();
612
613        smartplug
614            .ap_list(false)
615            .expect("failed to obtain AP list")
616            .as_array()
617            .expect("json array is expected");
618
619        assert!(
620            !smartplug
621                .ap_list(true)
622                .expect("failed to obtain AP list")
623                .as_array()
624                .expect("json array is expected")
625                .is_empty(),
626            "list of access points is not expected to be empty"
627        );
628    }
629
630    #[test]
631    #[serial]
632    #[ignore = "power-cycles devices connected to the plug"]
633    fn reboot() {
634        let hs110 = HS110::new(&*TEST_TARGET_ADDR).unwrap();
635        assert!(hs110.reboot(None).is_ok());
636
637        let hs110 = hs110.with_timeout(Duration::from_secs(1));
638        assert!(
639            hs110.reboot(Some(1)).is_err(),
640            "device is expected to be unreachable right after reboot command"
641        );
642
643        // Wait till the device is back online after reboot.
644        let hs110 = hs110.with_timeout(Duration::from_secs(10));
645        for _ in 0..20 {
646            if hs110.hostname().is_ok() {
647                return;
648            }
649        }
650        panic!("device didn't back online after reboot");
651    }
652}