rust_py_miio/
lib.rs

1//! This module provides an interface to interact with Miio devices via Python.
2//!
3//! It offers functions to retrieve available device types, create devices, and call device methods.
4//! Devices are represented by the Device struct which supports serialization and deserialization.
5
6use std::collections::HashMap;
7use std::ffi::CString;
8
9use pyo3::prelude::*;
10use pyo3::types::{PyBytes, PyDict, PyModule};
11
12use serde::{Deserialize, Serialize};
13use serde_json;
14
15mod constants;
16
17const MIIO_INTERFACE_CODE: &str = include_str!("../python-src/miio_interface.py");
18
19/// Retrieves a list of available device types from the Python interface.
20///
21/// # Returns
22///
23/// * `Ok(Vec<String>)` - A vector of device type names if successful.
24/// * `Err(PyErr)` - An error if the Python call fails.
25
26pub fn get_device_types() -> Result<Vec<String>, PyErr> {
27    Python::with_gil(|py| {
28        // Import the Python module
29        let miio_module = PyModule::from_code(
30            py,
31            CString::new(MIIO_INTERFACE_CODE)?.as_c_str(),
32            &CString::new("miio_interface.py")?,
33            &CString::new("miio_interface")?,
34        )?;
35
36        // Retrieve the Python function 'get_device_types'
37        let get_device_types = miio_module.getattr("get_device_types")?;
38        // Call the function without arguments
39        let device_types_py = get_device_types.call0()?;
40        // Convert Python list to Rust Vec<String>
41        let v: Vec<String> = device_types_py.extract()?;
42        Ok(v)
43    })
44}
45
46/// Represents a Miio device with its associated properties and Python object.
47///
48/// The Device struct includes data necessary for device communication and method invocation,
49/// along with functionalities to serialize/deserialize the device configuration.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Device {
52    /// A serialized representation of the underlying Python object as bytes.
53    pub serialized_py_object: Vec<u8>,
54    /// A map of callable method names to their corresponding Python signatures.
55    pub callable_methods: HashMap<String, String>,
56}
57
58impl Device {
59    /// Deserializes a JSON string into a `Device` instance.
60    ///
61    /// This function leverages Serde's JSON deserializer to parse the provided JSON
62    /// string and convert it into a corresponding `Device` instance. The JSON must
63    /// correctly represent the `Device` struct's fields.
64    ///
65    /// # Arguments
66    ///
67    /// * `json_str` - A string slice that holds the JSON representation of a `Device`.
68    ///
69    /// # Returns
70    ///
71    /// * `Ok(Device)` containing the `Device` instance if deserialization succeeds.
72    /// * `Err(serde_json::Error)` if parsing fails due to invalid JSON or type mismatch.
73    pub fn deserialize_json(json_str: &str) -> Result<Device, serde_json::Error> {
74        serde_json::from_str(json_str)
75    }
76
77    /// Serializes the Device instance into a JSON string using pretty formatting.
78    ///
79    /// This function leverages Serde's JSON serializer with pretty print enabled
80    /// to convert the `Device` struct into a human-readable JSON string. The output
81    /// includes appropriate whitespace and line breaks, making it easier to read and
82    /// debug.
83    ///
84    /// # Returns
85    ///
86    /// * `Ok(String)` containing the formatted JSON representation of the Device if serialization succeeds.
87    /// * `Err(serde_json::Error)` if any serialization error occurs.
88
89    pub fn serialize_json(&self) -> Result<String, serde_json::Error> {
90        serde_json::to_string_pretty(self)
91    }
92
93    /// Serializes the Device instance to a JSON file.
94    ///
95    /// # Arguments
96    ///
97    /// * `folder` - The directory where the file will be saved.
98    /// * `file_name` - The name of the file to create.
99    ///
100    /// # Returns
101    ///
102    /// * `Ok(())` on success, or an std::io::Error on failure.
103    pub fn serialize_to_file(&self, folder: &str, file_name: &str) -> std::io::Result<()> {
104        let path = format!("{}/{}", folder, file_name);
105        let json_str = self.serialize_json()?;
106        std::fs::write(path, json_str)
107    }
108
109    /// Deserializes a Device instance from a JSON file.
110    ///
111    /// # Arguments
112    ///
113    /// * `folder` - The directory containing the file.
114    /// * `file_name` - The name of the file to read.
115    ///
116    /// # Returns
117    ///
118    /// * `Ok(Device)` if deserialization is successful.
119    /// * `Err(std::io::Error)` if an error occurs during file read or parsing.
120    pub fn deserialize_from_file(folder: &str, file_name: &str) -> std::io::Result<Device> {
121        let path = format!("{}/{}", folder, file_name);
122        let json_str = std::fs::read_to_string(path)?;
123        Ok(Device::deserialize_json(&json_str)?)
124    }
125
126    /// Creates a new Device instance by invoking the Python function.
127    ///
128    /// This function calls the Python module to create a device and retrieve its properties,
129    /// including serialized state and callable methods.
130    ///
131    /// # Arguments
132    ///
133    /// * `ip` - The IP address of the device.
134    /// * `token` - The token used for authentication.
135    /// * `device_type` - The type of the device.
136    ///
137    /// # Returns
138    ///
139    /// * `Ok(Device)` on success.
140    /// * `Err(PyErr)` if any Python call fails.
141    pub fn create_device(ip: &str, token: &str, device_type: &str) -> Result<Device, PyErr> {
142        Python::with_gil(|py| {
143            // Import the Python module
144            let miio_module = PyModule::from_code(
145                py,
146                CString::new(MIIO_INTERFACE_CODE)?.as_c_str(),
147                &CString::new("miio_interface.py")?,
148                &CString::new("miio_interface")?,
149            )?;
150
151            // Retrieve the Python function 'create_device'
152            let create_device = miio_module.getattr("get_device")?;
153            // Call the function with arguments
154            let device: Bound<'_, PyBytes> = create_device
155                .call1((ip, token, device_type))?
156                .downcast::<PyBytes>()?
157                .clone();
158
159            // Retrieve the Python function 'get_device_methods'
160            let get_device_methods = miio_module.getattr("get_device_methods")?;
161            // Call the function with arguments
162            let methods = get_device_methods.call1((device.clone(),))?; // Dict returned
163            let methods = methods.downcast::<PyDict>()?;
164            let mut callable_methods = HashMap::new();
165            for (key, value) in methods.iter() {
166                let key = key.extract::<String>()?;
167                let value = value.extract::<String>()?;
168                callable_methods.insert(key, value);
169            }
170
171            let device_bytes = device.as_bytes().to_vec();
172            Ok(Device {
173                serialized_py_object: device_bytes,
174                callable_methods,
175            })
176        })
177    }
178
179    /// Calls a method on the device by invoking the corresponding Python function.
180    ///
181    /// This function sends a command to the device through Python and returns the result.
182    ///
183    /// # Arguments
184    ///
185    /// * `method_name` - The name of the method to be called.
186    /// * `args` - A vector of string arguments for the method.
187    ///
188    /// # Returns
189    ///
190    /// * `Ok(String)` containing the result if successful.
191    /// * `Err(PyErr)` if the Python call fails.
192
193    pub fn call_method(&self, method_name: &str, args: Vec<&str>) -> Result<String, PyErr> {
194        Python::with_gil(|py| {
195            // Import the Python module
196            let miio_module = PyModule::from_code(
197                py,
198                CString::new(MIIO_INTERFACE_CODE)?.as_c_str(),
199                &CString::new("miio_interface.py")?,
200                &CString::new("miio_interface")?,
201            )?;
202
203            // Retrieve the Python function 'call_method'
204            let call_method = miio_module.getattr("call_method")?;
205            // Call the function with arguments
206            let result: String = call_method
207                .call1((self.serialized_py_object.clone(), method_name, args))?
208                .extract()?;
209            Ok(result)
210        })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::constants::*;
217    use super::*;
218
219    #[test]
220    fn test_python_path() {
221        // eprintln!("Python interpreter path loading...");
222        let res: Result<(), PyErr> = Python::with_gil(|py| {
223            let sys = PyModule::import(py, "sys")?;
224            let path = sys.getattr("path")?;
225            let path: Vec<String> = path.extract()?;
226            if path.is_empty() {
227                return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
228                    "Python path is empty",
229                ));
230            }
231            Ok(())
232        });
233        assert!(res.is_ok());
234    }
235
236    #[test]
237    fn test_get_device_types_success() {
238        assert!(!get_device_types().unwrap().is_empty())
239    }
240
241    #[test]
242    fn test_get_device_types_cherry_picked() {
243        let device_types = get_device_types().unwrap();
244        assert!(device_types.contains(&String::from("Yeelight")));
245        assert!(device_types.contains(&String::from("FanMiot")));
246        assert!(device_types.contains(&String::from("AirHumidifierMiot")));
247    }
248
249    #[test]
250    fn test_get_device_types_cherry_picked_ne() {
251        let device_types = get_device_types().unwrap();
252        assert!(!device_types.contains(&String::from("Yeeli")));
253        assert!(!device_types.contains(&String::from("DummyStupidWifiRepeater")));
254        assert!(!device_types.contains(&String::from("DummySleepingpad")));
255        assert!(!device_types.contains(&String::from("Fanatico")));
256        assert!(!device_types.contains(&String::from("Yourmama")));
257    }
258
259    #[test]
260    fn test_create_device_success() {
261        let device = Device::create_device(IP, TOKEN, DEVICE_TYPE).unwrap();
262        assert!(!device.serialized_py_object.is_empty());
263    }
264
265    #[test]
266    fn test_get_device_types_error() {
267        assert!(!Device::create_device("0.0.0.0.0.0.0.0.0", TOKEN, DEVICE_TYPE,).is_ok());
268        assert!(!Device::create_device(IP, "tokennnnnnnnnnnnnn", DEVICE_TYPE,).is_ok());
269        assert!(!Device::create_device(IP, TOKEN, "NotADeviceYkkkkkkkkkkkkk",).is_ok());
270    }
271
272    #[test]
273    fn test_get_device_methods() {
274        let device = Device::create_device(IP, TOKEN, DEVICE_TYPE).unwrap();
275        assert!(!device.callable_methods.is_empty());
276        assert!(device.callable_methods.contains_key("toggle"));
277    }
278
279    #[test]
280    fn test_call_method() {
281        let device = Device::create_device(IP, TOKEN, DEVICE_TYPE).unwrap();
282        let result = device.call_method(METHOD_NAME, vec![]).unwrap();
283        assert_eq!(result, "['ok']");
284        let device = Device::create_device(IP, TOKEN, DEVICE_TYPE).unwrap();
285        let result = device.call_method("set_rgb", vec!["(32, 32, 32)"]).unwrap();
286        assert_eq!(result, "['ok']");
287    }
288
289    #[test]
290    fn test_call_method_err() {
291        let device = Device::create_device(IP, TOKEN, DEVICE_TYPE).unwrap();
292        let result = device.call_method("set_rgb", vec!["[32, 32, 32]"]).unwrap();
293        eprintln!("{}", result);
294    }
295
296    #[test]
297    fn test_serialize_to_file() {
298        let device = Device::create_device(IP, TOKEN, DEVICE_TYPE).unwrap();
299        let folder = std::env::temp_dir();
300        let folder = folder.to_str().unwrap();
301        let file_name = "device.json";
302        device.serialize_to_file(folder, file_name).unwrap();
303        let path = format!("{}/{}", folder, file_name);
304        assert!(std::fs::metadata(path).is_ok());
305    }
306
307    #[test]
308    fn test_serialize_deserialize_to_file() {
309        let device = Device::create_device(IP, TOKEN, DEVICE_TYPE).unwrap();
310        let folder = std::env::temp_dir();
311        let folder = folder.to_str().unwrap();
312        let file_name = "device.json";
313        device.serialize_to_file(folder, file_name).unwrap();
314        let deserialized_device = Device::deserialize_from_file(folder, file_name).unwrap();
315        assert_eq!(
316            device.serialized_py_object,
317            deserialized_device.serialized_py_object
318        );
319        assert_eq!(
320            device.callable_methods,
321            deserialized_device.callable_methods
322        );
323    }
324}