├── requirements.txt ├── README.md ├── test.py ├── .gitignore ├── LICENSE ├── test_config.py └── mfi.py /requirements.txt: -------------------------------------------------------------------------------- 1 | # This requirements file lists all third-party dependencies for this project. 2 | # 3 | # Run 'pip install -r requirements.txt -t lib/' to install these dependencies 4 | # in `lib/` subdirectory. 5 | # 6 | # Note: The `lib` directory is added to `sys.path` by `appengine_config.py`. 7 | requests==2.3.0 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ubnt-mfi-py 2 | =========== 3 | 4 | Python API for Ubiquiti mFi gear. 5 | 6 | To use the test suite, you will need to set the TESTMPORT, TESTMPOWER, TESTUSER, TESTPASS environment variables. 7 | 8 | Currently, you can retrieve the system.cfg file, via the http interface, into a UbntConfig object and make some changes to it. 9 | DO NOT TRUST UbntConfg.get_config_dump() it needs much more testing before use, to ensure it does not brick any devices. I want to be very careful to avoid loading invalid configs on the device. 10 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import mfi 2 | import os 3 | 4 | test_mpower = mfi.MPower(os.environ['TESTMPOWER'], os.environ['TESTUSER'], 5 | os.environ['TESTPASS']) 6 | test_mport = mfi.MPort(os.environ['TESTMPORT'], os.environ['TESTUSER'], 7 | os.environ['TESTPASS']) 8 | print(test_mpower.get_data()) 9 | print(test_mpower.get_power(1)) 10 | print(test_mpower.get_power(2)) 11 | print(test_mpower.get_param(1, 'output')) 12 | print(test_mport.get_data()) 13 | print(test_mport.get_temperature(1)) 14 | print(test_mport.get_temperature(1, 'f')) 15 | print(test_mport.get_temperature(2)) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | ### vim ### 6 | [._]*.s[a-w][a-z] 7 | [._]s[a-w][a-z] 8 | *.un~ 9 | Session.vim 10 | .netrwhist 11 | *~ 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | env/ 19 | bin/ 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | include/ 29 | var/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | 46 | # Translations 47 | *.mo 48 | 49 | # Mr Developer 50 | .mr.developer.cfg 51 | .project 52 | .pydevproject 53 | 54 | # Rope 55 | .ropeproject 56 | 57 | # Django stuff: 58 | *.log 59 | *.pot 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Andrew Rodgers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /test_config.py: -------------------------------------------------------------------------------- 1 | import mfi 2 | import os 3 | import json 4 | from collections import defaultdict 5 | 6 | 7 | def save_config(): 8 | test_mpower = mfi.MPower(os.environ['TESTMPOWER'], os.environ['TESTUSER'], 9 | os.environ['TESTPASS']) 10 | test_config = test_mpower.get_cfg() 11 | with open('mpower.cfg', 'r+') as f: 12 | f.write(test_config) 13 | 14 | 15 | def load_config(): 16 | with open('mpower.cfg', 'r+') as f: 17 | test_config = f.read() 18 | return test_config 19 | 20 | config_text = load_config() 21 | 22 | 23 | def parse_config(conf): 24 | def Tree(): 25 | return defaultdict(Tree) 26 | 27 | data = Tree() 28 | 29 | for line in conf.splitlines(): 30 | if not line: 31 | continue 32 | path, val = line.split('=') 33 | fields = path.split('.') 34 | prop = fields.pop() 35 | obj = data 36 | for f in fields: 37 | if f.isdigit(): 38 | items = obj.setdefault('items', []) 39 | idx = int(f) - 1 40 | while len(items) < idx + 1: 41 | items.append(Tree()) 42 | obj = items[idx] 43 | else: 44 | obj = obj[f] 45 | 46 | obj[prop] = val 47 | 48 | return data 49 | 50 | 51 | def output_conf(item, items=None, lines=None): 52 | if items is None: 53 | items = [] 54 | if lines is None: 55 | lines = [] 56 | if isinstance(item, defaultdict): 57 | for key, value in item.items(): 58 | items.append(key) 59 | return output_conf(value, items, lines) 60 | elif isinstance(item, list): 61 | for value in item: 62 | items.append(1) 63 | return output_conf(value, items, lines) 64 | else: 65 | items.append('=') 66 | items.append(item) 67 | lines.append(items) 68 | print(lines) 69 | return output_conf(item, items, lines) 70 | 71 | 72 | config = mfi.UbntConfig(config_text) 73 | print(config.get_config_dump()) 74 | -------------------------------------------------------------------------------- /mfi.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | from collections import defaultdict 4 | from collections import Mapping, Set, Sequence 5 | 6 | 7 | class UbntConfig: 8 | 9 | def __init__(self, config): 10 | self.config = self.parse_config(config) 11 | self.string_types = (str, unicode) if str is bytes else (str, bytes) 12 | self.iteritems = lambda mapping: getattr( 13 | mapping, 'iteritems', mapping.items)() 14 | 15 | def parse_line(self, line_string, data): 16 | Tree = lambda: defaultdict(Tree) 17 | path, val = line_string.split('=') 18 | fields = path.split('.') 19 | prop = fields.pop() 20 | obj = data 21 | for f in fields: 22 | if f.isdigit(): 23 | items = obj.setdefault('items', []) 24 | idx = int(f) - 1 25 | while len(items) < idx + 1: 26 | items.append(Tree()) 27 | obj = items[idx] 28 | else: 29 | obj = obj[f] 30 | 31 | obj[prop] = val 32 | return data 33 | 34 | def parse_config(self, conf): 35 | Tree = lambda: defaultdict(Tree) 36 | 37 | data = Tree() 38 | 39 | for line in conf.splitlines(): 40 | if not line: 41 | continue 42 | data = self.parse_line(line, data) 43 | 44 | return data 45 | 46 | def get_ntp(self): 47 | if self.config['ntpclient']['status'] == 'enabled': 48 | return self.config['ntpclient']['items'][0] 49 | else: 50 | return "ntpclient not enabled" 51 | 52 | def set_ntp(self, ntp_server): 53 | self.config['ntpclient']['status'] = 'enabled' 54 | self.config['ntpclient']['items'][0]['status'] = 'enabled' 55 | self.config['ntpclient']['items'][0]['server'] = ntp_server 56 | 57 | def get_crontab(self): 58 | if self.config['cron']['status'] == 'enabled': 59 | return self.config['cron']['items'][0]['job']['items'] 60 | 61 | def add_cronjob(self, schedule, status, cmd, label): 62 | self.config['cron']['items'][0]['job']['items'].append( 63 | {'schedule': schedule, 'status': status, 'cmd': cmd, 64 | 'label': label}) 65 | return self.config['cron']['items'][0]['job']['items'] 66 | 67 | def flatten_config(self, obj, path=(), memo=None): 68 | if memo is None: 69 | memo = set() 70 | iterator = None 71 | if isinstance(obj, Mapping): 72 | iterator = self.iteritems 73 | elif isinstance(obj, (Sequence, Set)) and not isinstance( 74 | obj, self.string_types): 75 | iterator = enumerate 76 | if iterator: 77 | if id(obj) not in memo: 78 | memo.add(id(obj)) 79 | for path_component, value in iterator(obj): 80 | if path_component == 'items': 81 | for result in self.flatten_config(value, path, memo): 82 | yield result 83 | else: 84 | try: 85 | i = int(path_component) 86 | path_component = str(i + 1) 87 | except: 88 | pass 89 | for result in self.flatten_config( 90 | value, path + (path_component,), memo): 91 | yield result 92 | memo.remove(id(obj)) 93 | else: 94 | yield path, obj 95 | 96 | def get_config_dump(self): 97 | flat = self.flatten_config(self.config) 98 | print(flat) 99 | lines = ['.'.join(path) + '=' + str(value) for path, value in flat] 100 | return '\n'.join(lines) 101 | 102 | def get_config(self): 103 | return self.config 104 | 105 | 106 | class MfiDevice: 107 | """Base class for all mFi devices""" 108 | def __init__(self, url, user, passwd, cache_timeout=2): 109 | 110 | """Provide a url to the mpower device, a username, and a password""" 111 | self.url = url 112 | self.user = user 113 | self.passwd = passwd 114 | self.cache_timeout = cache_timeout 115 | self.data_retrieved = 0 116 | self.session = requests.Session() 117 | #This get is necessary to set a cookie in the session prior to trying 118 | #to login might be better to stick it in the login method itself 119 | self.session.get(url) 120 | self.login() 121 | 122 | def login(self): 123 | post_data = {"uri": "/", "username": self.user, 124 | "password": self.passwd} 125 | headers = {"Expect": ""} 126 | self.session.post((self.url + "/login.cgi"), 127 | headers=headers, data=post_data, 128 | allow_redirects=True) 129 | 130 | def get_data(self): 131 | if (time.time() - self.data_retrieved) > self.cache_timeout: 132 | r = self.session.get((self.url + "/mfi/sensors.cgi")) 133 | self.data_retrieved = time.time() 134 | self.data = r.json() 135 | return self.data 136 | 137 | def get_sensor(self, port_no): 138 | self.get_data() 139 | try: 140 | return self.data["sensors"][port_no - 1] 141 | except(KeyError, IndexError): 142 | print("Port #" + str(port_no) + " does not exist on this device") 143 | raise 144 | 145 | def get_param(self, port_no, param): 146 | try: 147 | sensor = self.get_sensor(port_no) 148 | return sensor[param] 149 | except(KeyError, IndexError): 150 | print("Port #" + str(port_no) + " does not have parameter: " 151 | + param) 152 | 153 | def get_cfg(self): 154 | r = self.session.get(self.url + "/cfg.cgi") 155 | self.config = UbntConfig(r.text) 156 | return self.config 157 | 158 | def set_cfg(self, config_string): 159 | files = {'file': ('config.cfg', config_string)} 160 | p = self.session.post((self.url + "/system.cgi"), files=files) 161 | return p.text 162 | 163 | 164 | class MPower(MfiDevice): 165 | """Provides an interface to a single mPower Device""" 166 | 167 | def get_power(self, port_no): 168 | return self.get_param(port_no, 'power') 169 | 170 | def switch(self, port_no, state="toggle"): 171 | if state == "toggle": 172 | current_state = self.get_param(port_no, 'output') 173 | next_state = not current_state 174 | else: 175 | if int(state) == 0 or int(state) == 1: 176 | next_state = int(state) 177 | data = {"output": str(next_state)} 178 | self.session.put((self.url + "/sensors/" + str(port_no) + "/"), 179 | data=data) 180 | 181 | 182 | class MPort(MfiDevice): 183 | """Provides an API to a single mPort Device""" 184 | 185 | def get_temperature(self, port_no, temp_format='c'): 186 | try: 187 | sensor = self.get_sensor(port_no) 188 | if "model" in sensor and sensor['model'] == 'Ubiquiti mFi-THS': 189 | if temp_format == "c": 190 | return sensor['analog'] * 30 - 10 191 | elif temp_format == "f": 192 | return (sensor['analog'] * 30 - 10) * 1.8 + 32 193 | else: 194 | raise IndexError 195 | except(IndexError): 196 | print("Sorry port #" + str(port_no) + 197 | " either does not exist or is not a Temperature Sensor") 198 | --------------------------------------------------------------------------------