├── .gitmodules ├── meross_powermon ├── __init__.py ├── version.py ├── utils.py ├── delete.py ├── init.py ├── tests │ ├── test_command_line_autocomplete.py │ ├── test_init_user.py │ ├── definitions.py │ ├── test_init_root.py │ ├── test_config_user.py │ ├── test_delete.py │ ├── test_config_root.py │ ├── test_setup.py │ └── test_monitor.py ├── iwlist.py ├── monitor.py ├── config.py ├── command_line.py ├── modified_device.py └── setup.py ├── tox.ini ├── .coveragerc ├── MANIFEST.in ├── pytest.ini ├── meross ├── requirements.txt ├── setup.py ├── conftest.py ├── .gitignore └── README.md /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /meross_powermon/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /meross_powermon/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | VERSION = "0.1.5" 4 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | 4 | [testenv] 5 | deps = -rrequirements.txt 6 | commands = pytest \ 7 | -v {posargs} 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # Skip iwlist 4 | meross_powermon/iwlist.py 5 | # Omit modified_device 6 | meross_powermon/modified_device.py 7 | -------------------------------------------------------------------------------- /meross_powermon/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import base64 4 | 5 | 6 | def mangle(s): 7 | return str(base64.b64encode(s.encode("utf-8")), "utf-8") 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include tox.ini 2 | include conftest.py 3 | include pytest.ini 4 | include .coveragerc 5 | include requirements.txt 6 | include README.md 7 | include meross 8 | include meross_powermon/tests/* 9 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ignore=venv --ignore=meross_powermon/iwlist.py --pep8 --cov=meross_powermon --cov-report=html --cov-report=xml --cov-fail-under=95 --junit-xml=test_results.xml 3 | pep8maxlinelength = 90 4 | mccabe-complexity = 10 5 | -------------------------------------------------------------------------------- /meross_powermon/delete.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | 5 | from meross_powermon import config 6 | 7 | 8 | def go(opts): 9 | cfg = config.load() 10 | print(cfg["devices"].keys()) 11 | devices = list(cfg["devices"].keys()) 12 | if opts.name in devices: 13 | cfg["devices"].pop(opts.name) 14 | config.save(cfg) 15 | else: 16 | sys.exit('error: Device "{}" not found'.format(opts.name)) 17 | -------------------------------------------------------------------------------- /meross_powermon/init.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from meross_powermon import config 6 | from meross_powermon.utils import mangle 7 | 8 | 9 | def go(opts): 10 | config.exists(fail=False) 11 | cfg = config.load() 12 | if os.getuid() == 0: 13 | # root 14 | opts.ssid = mangle(opts.ssid) 15 | opts.password = mangle(opts.password) 16 | config.root_update(cfg, opts) 17 | config.save(cfg) 18 | else: 19 | # Normal user mode 20 | config.update(cfg, opts) 21 | config.save(cfg) 22 | -------------------------------------------------------------------------------- /meross: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # PYTHON_ARGCOMPLETE_OK 5 | 6 | """ 7 | Locates a configures a Meross IoT device 8 | """ 9 | 10 | import sys 11 | 12 | import meross_powermon.command_line as command_line 13 | 14 | 15 | if __name__ == "__main__": 16 | opts, subcommands = command_line.arguments() 17 | if opts.subcommand is None: 18 | sys.exit("Missing sub-command, expecting one of:" 19 | " {}".format(", ".join(sorted(subcommands.keys())))) 20 | try: 21 | subcommands[opts.subcommand].go(opts) 22 | except KeyboardInterrupt: 23 | pass 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | apipkg==1.5 2 | argcomplete==1.9.4 3 | atomicwrites==1.3.0 4 | attrs==19.1.0 5 | certifi==2019.3.9 6 | chardet==3.0.4 7 | check-manifest==0.37 8 | coverage==4.5.3 9 | devpi-client==4.3.0 10 | devpi-common==3.3.1 11 | entrypoints==0.3 12 | execnet==1.5.0 13 | filelock==3.0.10 14 | flake8==3.7.7 15 | idna==2.8 16 | mccabe==0.6.1 17 | meross-iot==0.1.4.3 18 | more-itertools==6.0.0 19 | paho-mqtt==1.4.0 20 | pep8==1.7.1 21 | pkginfo==1.5.0.1 22 | pluggy==0.9.0 23 | py==1.8.0 24 | pycodestyle==2.5.0 25 | pyflakes==2.1.1 26 | pytest==4.3.1 27 | pytest-cache==1.0 28 | pytest-cov==2.6.1 29 | pytest-mccabe==0.1 30 | pytest-mock==1.10.1 31 | pytest-pep8==1.0.6 32 | requests==2.21.0 33 | six==1.12.0 34 | toml==0.10.0 35 | tox==3.7.0 36 | urllib3==1.24.1 37 | virtualenv==16.4.3 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | from meross_powermon.version import VERSION 4 | 5 | setup(name='meross-powermon', 6 | version=VERSION, 7 | description='Tools for managing local Meross energy monitoring plugs', 8 | url='http://server/', 9 | author='Dave Boulton', 10 | author_email='email addy', 11 | license='', 12 | packages=find_packages(), 13 | install_requires=[ 14 | 'meross-iot', 15 | 'argcomplete' 16 | ], 17 | scripts=["meross"], 18 | classifiers=[ 19 | 'Development Status :: 4 - Beta', 20 | 'Intended Audience :: ?', 21 | 'License :: ???', 22 | 'Programming Language :: Python :: 3', 23 | 'Topic :: Software Development :: Libraries :: Python Modules', 24 | 'Topic :: Utilities' 25 | ], 26 | zip_safe=False) 27 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | 4 | import pytest 5 | import py 6 | import tempfile 7 | 8 | sys.path.insert(0, str(Path(".").resolve())) 9 | 10 | 11 | @pytest.fixture(scope="function") 12 | def as_root(mocker): 13 | """ 14 | Ensure that os.getuid() return 0 15 | """ 16 | getuid = mocker.patch("os.getuid") 17 | getuid.return_value = 0 18 | return 19 | 20 | 21 | @pytest.fixture(scope="function") 22 | def config_path(tmpdir, monkeypatch): 23 | """ 24 | Generate a path to a config file in the current "tmpdir" and patch 25 | pathlib.Path.expanduser so it returns the path to the config file 26 | """ 27 | import meross_powermon.config as config 28 | target = Path(tmpdir) / "config.json" 29 | config.CONFIG = target.as_posix() 30 | monkeypatch.setattr("pathlib.Path.expanduser", lambda x: target) 31 | return target 32 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_command_line_autocomplete.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import os 5 | from pathlib import Path 6 | import json 7 | import argparse 8 | 9 | import meross_powermon.command_line as cmd 10 | 11 | 12 | def test_devices(mocker): 13 | config_exists = mocker.patch("meross_powermon.config.exists") 14 | config_exists.return_value = True 15 | config_load = mocker.patch("meross_powermon.config.load") 16 | config_load.return_value = dict() 17 | config_list_devices = mocker.patch("meross_powermon.config.list_devices") 18 | config_list_devices.return_value = ["a_dev1", "b_dev2"] 19 | 20 | assert cmd.devices("a", None, dummy=0) == ["all", "a_dev1"] 21 | assert cmd.devices("b", None, dummy=0) == ["b_dev2"] 22 | assert cmd.devices("~a_", None, dummy=0) == ["~a_dev1"] 23 | 24 | assert cmd.just_devices("b", None, dummy=0) == ["b_dev2"] 25 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_init_user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Check that init functions correctly when run as normal user 5 | """ 6 | 7 | import pytest 8 | import os 9 | from pathlib import Path 10 | import json 11 | 12 | import meross_powermon.init as init 13 | import meross_powermon.command_line as cmd 14 | import meross_powermon.config as config 15 | 16 | 17 | DUMMY_CONTENTS = dict({"server": "test", 18 | "port": 1234, 19 | "ca_cert": "ca.crt" 20 | }) 21 | 22 | 23 | def test_first_run(tmpdir, config_path): 24 | assert not config_path.exists() 25 | args = "init --server test --port 1234 --ca-cert ca.crt" 26 | with tmpdir.as_cwd(): 27 | opts, subcmds = cmd.arguments(args.split()) 28 | subcmds[opts.subcommand].go(opts) 29 | o = json.loads(config_path.read_text()) 30 | assert o["server"] == "test" 31 | assert o["port"] == 1234 32 | assert o["ca_cert"] == "ca.crt" 33 | 34 | 35 | def test_update_config(tmpdir, config_path): 36 | args = "init --server s2 --port 4321 --ca-cert zz.zzz" 37 | config_path.write_text(json.dumps(DUMMY_CONTENTS) + "\n") 38 | with tmpdir.as_cwd(): 39 | assert config_path.exists() 40 | opts, subcmds = cmd.arguments(args.split()) 41 | subcmds[opts.subcommand].go(opts) 42 | o = json.loads(config_path.read_text()) 43 | assert o["server"] == "s2" 44 | assert o["port"] == 4321 45 | assert o["ca_cert"] == "zz.zzz" 46 | -------------------------------------------------------------------------------- /meross_powermon/tests/definitions.py: -------------------------------------------------------------------------------- 1 | DUMMY_CONTENTS = dict({"user": "test", 2 | "ssid": "TVlTU0lE", 3 | "password": "U0VDUkVU", 4 | "interface": "MYWLAN" 5 | }) 6 | 7 | DUMMY_USER = dict({"server": "test", 8 | "port": 1234, 9 | "ca_cert": "cb.crt" 10 | }) 11 | 12 | DUMMY_DEVICE = dict({"aircon": { 13 | "hardware": { 14 | "type": "mss310", 15 | "subType": "us", 16 | "version": "2.0.0", 17 | "chipType": "mt7682", 18 | "uuid": "12341234123412341234123412341234", 19 | "macAddress": "34:29:8f:13:c9:0a" 20 | }, 21 | "firmware": { 22 | "version": "2.1.7", 23 | "compileTime": "2018/11/08 09:58:45 GMT +08:00", 24 | "wifiMac": "12:34:56:12:34:56", 25 | "innerIp": "", 26 | "server": "", 27 | "port": 0, 28 | "userId": 0 29 | }, 30 | "time": { 31 | "timestamp": 14, 32 | "timezone": "", 33 | "timeRule": [] 34 | }, 35 | "online": { 36 | "status": 2 37 | } 38 | }}) 39 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_init_root.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Check that init functions correctly when run as root 5 | """ 6 | 7 | import pytest 8 | import os 9 | from pathlib import Path 10 | import json 11 | 12 | import meross_powermon.init as init 13 | import meross_powermon.command_line as cmd 14 | from meross_powermon.utils import mangle 15 | 16 | 17 | DUMMY_CONTENTS = dict({"user": "test", 18 | "ssid": "TVlTU0lE", 19 | "password": "U0VDUkVU", 20 | "interface": "MYWLAN" 21 | }) 22 | 23 | 24 | def test_first_run(tmpdir, as_root, config_path): 25 | assert not config_path.exists() 26 | args = "init --user test --ssid MYSSID --password SECRET" 27 | args += " --interface MYWLAN" 28 | with tmpdir.as_cwd(): 29 | opts, subcmds = cmd.arguments(args.split()) 30 | subcmds[opts.subcommand].go(opts) 31 | o = json.loads(config_path.read_text()) 32 | assert o["user"] == "test" 33 | assert o["interface"] == "MYWLAN" 34 | assert o["ssid"] == mangle("MYSSID") 35 | assert o["password"] == mangle("SECRET") 36 | 37 | 38 | def test_update_config(tmpdir, as_root, config_path): 39 | args = "init --user x --ssid SSID2 --password PWD2 --interface wlan1" 40 | config_path.write_text(json.dumps(DUMMY_CONTENTS) + "\n") 41 | with tmpdir.as_cwd(): 42 | assert config_path.exists() 43 | opts, subcmds = cmd.arguments(args.split()) 44 | subcmds[opts.subcommand].go(opts) 45 | o = json.loads(config_path.read_text()) 46 | assert o["user"] == "x" 47 | assert o["interface"] == "wlan1" 48 | assert o["ssid"] == mangle("SSID2") 49 | assert o["password"] == mangle("PWD2") 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 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 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | test_results.xml 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_config_user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import os 5 | from pathlib import Path 6 | import json 7 | 8 | import meross_powermon.init as init 9 | import meross_powermon.command_line as cmd 10 | import meross_powermon.config as config 11 | 12 | 13 | DUMMY_CONTENTS = dict({"server": "test", 14 | "port": 1234, 15 | "ca_cert": "cb.crt" 16 | }) 17 | 18 | 19 | def test_no_cfg(tmpdir, config_path): 20 | args = "config --server test --port 1234 --ca-cert cb.crt" 21 | with tmpdir.as_cwd(): 22 | with pytest.raises(SystemExit) as ex: 23 | opts, subcmds = cmd.arguments(args.split()) 24 | subcmds[opts.subcommand].go(opts) 25 | assert "No configuration file, run 'meross init' first" in str(ex) 26 | 27 | 28 | def test_update_config(tmpdir, config_path): 29 | args = "config --server s2 --port 4321 --ca-cert zz.zzz" 30 | config_path.write_text(json.dumps(DUMMY_CONTENTS) + "\n") 31 | o = json.loads(config_path.read_text()) 32 | assert o["server"] == "test" 33 | assert o["port"] == 1234 34 | assert o["ca_cert"] == "cb.crt" 35 | with tmpdir.as_cwd(): 36 | assert config_path.exists() 37 | opts, subcmds = cmd.arguments(args.split()) 38 | subcmds[opts.subcommand].go(opts) 39 | o = json.loads(config_path.read_text()) 40 | assert o["server"] == "s2" 41 | assert o["port"] == 4321 42 | assert o["ca_cert"] == "zz.zzz" 43 | 44 | 45 | def test_show(tmpdir, config_path, capsys): 46 | args = "config --show" 47 | config_path.write_text(json.dumps(DUMMY_CONTENTS) + "\n") 48 | opts, subcmds = cmd.arguments(args.split()) 49 | subcmds[opts.subcommand].go(opts) 50 | out, err = capsys.readouterr() 51 | assert '"server": "test"' in out 52 | assert '"port": 1234' in out 53 | assert '"ca_cert": "cb.crt"' in out 54 | 55 | 56 | def test_user_config_files(config_path): 57 | expected = "/home/numpty/.config/meross_powermon/config.json" 58 | assert config.user_config_file("numpty") == expected 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # meross-powermon 2 | Simple command line interface for local operation of Meross IoT kit 3 | 4 | Requires MerossIot to be installed. 5 | 6 | Incorporates iwlist.py from https://github.com/iancoleman/python-iwlist as I couldn't figure out how to include it without running into problems when trying to package this stuff up so it was suitable for "pip install". 7 | 8 | You have to be root to run all of the wifi commands to connect to Meross device. This assumes that root will only run the "setup" phase for each device and that a user, will run everything else. 9 | 10 | To initialise local config files: 11 | 12 | For root: 13 | 14 | Specify who the lucky user is, give your normal wifi network details and the interface of the wifi device: 15 | 16 | `./meross init --user USER --ssid SSID --password PASSWORD --interface IF` 17 | 18 | 19 | For the user: 20 | 21 | Give the name of the mqtt server, port number and path to certificate file if required: 22 | 23 | `./meross init --server SERVER --port PORT --ca-cert /path/to/ca.crt` 24 | 25 | In both cases the configuration is stored in .config/meross_powermon/config.json relative to the home directory and has a "chmod 600" performed on it. 26 | 27 | Once that's done you don't need to think about those options, you can change them with a `config` sub-command though. 28 | 29 | To add a device (as root): 30 | 31 | `./meross setup name` 32 | 33 | Will bring up the wifi device and scan for an AP name starting with "Meross_" 34 | 35 | We then associate with the Meross_* network 36 | 37 | Next we configure the wifi network with an appropriate IP address and route and gather some device data (which we'll store later). Then it's a case of giving the device details of the MQTT server to use and the ssid and password for our normal wifi network. 38 | 39 | Finally the device data is stored in the named user's config file using the name given in the setup command. 40 | 41 | Our user can then run a simple monitor to test that it's working: 42 | 43 | `./meross monitor name` 44 | 45 | Root can overwrite an existing device name by using `--force` with the `setup` command or the user can delete a device using a `delete` sub-command. -------------------------------------------------------------------------------- /meross_powermon/iwlist.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | 4 | cellNumberRe = re.compile(r"^Cell\s+(?P.+)\s+-\s+Address:\s(?P.+)$") 5 | regexps = [ 6 | re.compile(r"^ESSID:\"(?P.*)\"$"), 7 | re.compile(r"^Protocol:(?P.+)$"), 8 | re.compile(r"^Mode:(?P.+)$"), 9 | re.compile(r"^Frequency:(?P[\d.]+) (?P.+) \(Channel (?P\d+)\)$"), 10 | re.compile(r"^Encryption key:(?P.+)$"), 11 | re.compile(r"^Quality=(?P\d+)/(?P\d+)\s+Signal level=(?P.+) d.+$"), 12 | re.compile(r"^Signal level=(?P\d+)/(?P\d+).*$"), 13 | ] 14 | 15 | # Detect encryption type 16 | wpaRe = re.compile(r"IE:\ WPA\ Version\ 1$") 17 | wpa2Re = re.compile(r"IE:\ IEEE\ 802\.11i/WPA2\ Version\ 1$") 18 | 19 | # Runs the comnmand to scan the list of networks. 20 | # Must run as super user. 21 | # Does not specify a particular device, so will scan all network devices. 22 | def scan(interface='wlan0'): 23 | cmd = ["iwlist", interface, "scan"] 24 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 25 | points = proc.stdout.read().decode('utf-8') 26 | return points 27 | 28 | # Parses the response from the command "iwlist scan" 29 | def parse(content): 30 | cells = [] 31 | lines = content.split('\n') 32 | for line in lines: 33 | line = line.strip() 34 | cellNumber = cellNumberRe.search(line) 35 | if cellNumber is not None: 36 | cells.append(cellNumber.groupdict()) 37 | continue 38 | wpa = wpaRe.search(line) 39 | if wpa is not None : 40 | cells[-1].update({'encryption':'wpa'}) 41 | wpa2 = wpa2Re.search(line) 42 | if wpa2 is not None : 43 | cells[-1].update({'encryption':'wpa2'}) 44 | for expression in regexps: 45 | result = expression.search(line) 46 | if result is not None: 47 | if 'encryption' in result.groupdict() : 48 | if result.groupdict()['encryption'] == 'on' : 49 | cells[-1].update({'encryption': 'wep'}) 50 | else : 51 | cells[-1].update({'encryption': 'off'}) 52 | else : 53 | cells[-1].update(result.groupdict()) 54 | continue 55 | return cells 56 | -------------------------------------------------------------------------------- /meross_powermon/monitor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import base64 4 | import json 5 | import time 6 | import logging 7 | from math import isclose 8 | 9 | from meross_powermon import config 10 | from meross_powermon.modified_device import Mss310 11 | from meross_powermon.utils import mangle 12 | 13 | 14 | def go(opts): 15 | log = logging.getLogger("meross_powerplug") 16 | log.setLevel(logging.WARNING) 17 | cfg = config.load() 18 | p = dict() 19 | p["domain"] = cfg["server"] 20 | p["port"] = cfg["port"] 21 | p["ca_cert"] = cfg["ca_cert"] 22 | 23 | all_devices = sorted(config.list_devices(cfg)) 24 | devices = [] 25 | for name in opts.name: 26 | if name == "all": 27 | devices.extend(all_devices) 28 | continue 29 | if name.startswith("~"): 30 | rm = name.lstrip("~") 31 | try: 32 | devices.remove(rm) 33 | except ValueError: 34 | print('unknown device: "{}"'.format(rm)) 35 | else: 36 | devices.append(name) 37 | 38 | plugs = [] 39 | for dev in devices: 40 | device = cfg["devices"][dev] 41 | key = mangle(dev) 42 | p["uuid"] = device["hardware"]["uuid"] 43 | p["devName"] = dev 44 | p["devType"] = device["hardware"]["type"] 45 | p["hdwareVersion"] = device["hardware"]["version"] 46 | p["fmwareVersion"] = device["firmware"]["version"] 47 | plugs.append(Mss310("token", key, None, **p)) 48 | 49 | if len(plugs) == 1: 50 | devmon(plugs[0], delay=opts.delay, 51 | abserr=opts.abserr, relerr=opts.relerr) 52 | else: 53 | for p in plugs: 54 | dV, mA, mW = electricity(p) 55 | output(p._name, dV, mA, mW, ts=False) 56 | 57 | 58 | def electricity(device): 59 | res = device.get_electricity() 60 | e = res["electricity"] 61 | dV = e["voltage"] 62 | mA = e["current"] 63 | mW = e["power"] 64 | return dV, mA, mW 65 | 66 | 67 | def output(name, dV, mA, mW, ts=True): 68 | t = time.ctime() if ts else "" 69 | print("{:>16} {:8.2f}V {:8.2f}A " 70 | "{:8.2f}W {}".format(name, dV/10, mA/1000, mW/1000, t)) 71 | 72 | 73 | def devmon(device, delay=1, abserr=1000, relerr=0.05): 74 | dV0, mA0, mW0 = electricity(device) 75 | output(device._name, dV0, mA0, mW0) 76 | time.sleep(delay) 77 | while True: 78 | dV, mA, mW = electricity(device) 79 | if not isclose(mW, mW0, rel_tol=relerr, abs_tol=abserr): 80 | output(device._name, dV, mA, mW) 81 | dV0, mA0, mW0 = dV, mA, mW 82 | time.sleep(delay) 83 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_delete.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import os 5 | from pathlib import Path 6 | import json 7 | import argparse 8 | 9 | import meross_powermon.init as init 10 | import meross_powermon.command_line as cmd 11 | import meross_powermon.config as config 12 | import meross_powermon.delete as delete 13 | from meross_powermon.utils import mangle 14 | 15 | 16 | DUMMY_CONTENTS = dict({"user": "test", 17 | "ssid": "TVlTU0lE", 18 | "password": "U0VDUkVU", 19 | "interface": "MYWLAN" 20 | }) 21 | 22 | DUMMY_USER = dict({"server": "test", 23 | "port": 1234, 24 | "ca_cert": "cb.crt" 25 | }) 26 | 27 | DUMMY_DEVICE = dict({"aircon": { 28 | "hardware": { 29 | "type": "mss310", 30 | "subType": "us", 31 | "version": "2.0.0", 32 | "chipType": "mt7682", 33 | "uuid": "12341234123412341234123412341234", 34 | "macAddress": "34:29:8f:13:c9:0a" 35 | }, 36 | "firmware": { 37 | "version": "2.1.7", 38 | "compileTime": "2018/11/08 09:58:45 GMT +08:00", 39 | "wifiMac": "12:34:56:12:34:56", 40 | "innerIp": "", 41 | "server": "", 42 | "port": 0, 43 | "userId": 0 44 | }, 45 | "time": { 46 | "timestamp": 14, 47 | "timezone": "", 48 | "timeRule": [] 49 | }, 50 | "online": { 51 | "status": 2 52 | } 53 | }}) 54 | 55 | 56 | def test_delete_device(tmpdir, mocker, as_root, config_path): 57 | user_config_file = mocker.patch("meross_powermon.config.user_config_file") 58 | user_config_file.return_value = config_path.as_posix() 59 | opts = argparse.Namespace() 60 | opts.force = False 61 | config_path.write_text(json.dumps(DUMMY_USER) + "\n") 62 | config.add_device(DUMMY_DEVICE, opts, "nobody") 63 | o = json.loads(config_path.read_text()) 64 | opts.name = "aircon" 65 | 66 | # delete the devices 67 | delete.go(opts) 68 | 69 | # try to delete non-existent device 70 | with pytest.raises(SystemExit) as ex: 71 | delete.go(opts) 72 | assert 'Device "aircon" not found' in str(ex) 73 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_config_root.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import os 5 | from pathlib import Path 6 | import json 7 | import argparse 8 | 9 | from meross_powermon import init 10 | from meross_powermon import command_line as cmd 11 | from meross_powermon import config 12 | from meross_powermon.utils import mangle 13 | from meross_powermon.tests.definitions import (DUMMY_CONTENTS, DUMMY_USER, 14 | DUMMY_DEVICE) 15 | 16 | 17 | def test_no_root_cfg(tmpdir, as_root, config_path): 18 | args = "config --user test --ssid MYSSID --password SECRET" 19 | args += " --interface MYWLAN" 20 | with tmpdir.as_cwd(): 21 | with pytest.raises(SystemExit) as ex: 22 | opts, subcmds = cmd.arguments(args.split()) 23 | subcmds[opts.subcommand].go(opts) 24 | assert "No configuration file, run 'meross init' first" in str(ex) 25 | 26 | 27 | def test_load_user_cfg(tmpdir, as_root, config_path): 28 | with tmpdir.as_cwd(): 29 | with pytest.raises(SystemExit) as ex: 30 | config.load('_NO_SUCH_USER_') 31 | assert "No user configuration file" in str(ex) 32 | 33 | 34 | def test_update_root_config(tmpdir, as_root, config_path): 35 | args = "config --user x --ssid SSID2 --password PWD2 --interface wlan1" 36 | config_path.write_text(json.dumps(DUMMY_CONTENTS) + "\n") 37 | o = json.loads(config_path.read_text()) 38 | assert o["user"] == "test" 39 | with tmpdir.as_cwd(): 40 | assert config_path.exists() 41 | opts, subcmds = cmd.arguments(args.split()) 42 | subcmds[opts.subcommand].go(opts) 43 | o = json.loads(config_path.read_text()) 44 | assert o["user"] == "x" 45 | assert o["interface"] == "wlan1" 46 | assert o["ssid"] == mangle("SSID2") 47 | assert o["password"] == mangle("PWD2") 48 | 49 | 50 | def test_add_device(tmpdir, mocker, as_root, config_path): 51 | user_config_file = mocker.patch("meross_powermon.config.user_config_file") 52 | user_config_file.return_value = config_path.as_posix() 53 | opts = argparse.Namespace() 54 | opts.force = False 55 | config_path.write_text(json.dumps(DUMMY_USER) + "\n") 56 | config.add_device(DUMMY_DEVICE, opts, "nobody") 57 | o = json.loads(config_path.read_text()) 58 | assert isinstance(o["devices"], dict) 59 | 60 | # Add it again, without --force 61 | with pytest.raises(SystemExit) as ex: 62 | config.add_device(DUMMY_DEVICE, opts, "nobody") 63 | assert "Unable to overwrite device unless you use --force" in str(ex) 64 | 65 | # Add it again, with --force 66 | opts.force = True 67 | config.add_device(DUMMY_DEVICE, opts, "nobody") 68 | 69 | # Get all devices 70 | all = config.list_devices(o) 71 | assert all == ["aircon"] 72 | 73 | 74 | def test_connect(mocker, config_path, as_root, monkeypatch): 75 | user_config_file = mocker.patch("meross_powermon.config.user_config_file") 76 | user_config_file.return_value = config_path.as_posix() 77 | opts = argparse.Namespace() 78 | opts.force = False 79 | config_path.write_text(json.dumps(DUMMY_USER) + "\n") 80 | config.add_device(DUMMY_DEVICE, opts, "nobody") 81 | o = json.loads(config_path.read_text()) 82 | monkeypatch.setattr("meross_powermon.config.Mss310", 83 | lambda x, y, z, **kwdargs: None) 84 | config.connect("aircon") 85 | -------------------------------------------------------------------------------- /meross_powermon/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import os 5 | import sys 6 | from pathlib import Path 7 | 8 | from meross_powermon.utils import mangle 9 | from meross_powermon.modified_device import Mss310 10 | 11 | CONFIG = "~/.config/meross_powermon/config.json" 12 | 13 | 14 | def go(opts): 15 | exists(fail=True) 16 | cfg = load() 17 | if os.getuid() == 0: 18 | if opts.ssid: 19 | opts.ssid = mangle(opts.ssid) 20 | if opts.password: 21 | opts.password = mangle(opts.password) 22 | 23 | root_update(cfg, opts) 24 | save(cfg) 25 | else: 26 | # Normal user mode 27 | update(cfg, opts) 28 | save(cfg) 29 | if opts.show: 30 | print(json.dumps(cfg, indent=4)) 31 | 32 | 33 | def exists(user=CONFIG, fail=True): 34 | cfgfile = Path.expanduser(Path(CONFIG)) 35 | if fail and not cfgfile.exists(): 36 | sys.exit("No configuration file, run 'meross init' first") 37 | return cfgfile.exists() 38 | 39 | 40 | def load(user=CONFIG): 41 | cfgfile = Path.expanduser(Path(user)) 42 | if not cfgfile.exists(): 43 | # Prevent root triggering this for the user config 44 | if os.getuid() == 0 and "~" not in user: 45 | msg = 'No user configuration file "{}".\n'.format(cfgfile) 46 | msg += 'Ask user to run "meross init" before continuing.' 47 | sys.exit(msg) 48 | cfgfile.parent.mkdir(parents=True, exist_ok=True) 49 | save(dict()) 50 | return dict() 51 | cfg = json.loads(cfgfile.read_text()) 52 | return cfg 53 | 54 | 55 | def update(cfg, opts, attrs=["server", "port", "ca_cert"]): 56 | for attr in attrs: 57 | if getattr(opts, attr): 58 | cfg[attr] = getattr(opts, attr) 59 | 60 | 61 | def root_update(cfg, opts): 62 | update(cfg, opts, attrs=["user", "ssid", "password", "interface"]) 63 | 64 | 65 | def user_config_file(user): 66 | return "/home/" + user + "/.config/meross_powermon/config.json" 67 | 68 | 69 | def load_user(user): 70 | return load(user_config_file(user)) 71 | 72 | 73 | def save(cfg, user=CONFIG): 74 | cfgfile = Path.expanduser(Path(user)) 75 | cfgfile.write_text(json.dumps(cfg, indent=4)) 76 | cfgfile.chmod(0o600) 77 | 78 | 79 | def save_user(cfg, user): 80 | save(cfg, user_config_file(user)) 81 | 82 | 83 | def add_device(dev, opts, user): 84 | cfg = load_user(user) 85 | cfg["devices"] = cfg.get("devices", dict()) 86 | devices = cfg.get("devices") 87 | if list(dev.keys())[0] in devices: 88 | if not opts.force: 89 | sys.exit("Unable to overwrite device unless you use --force") 90 | cfg["devices"].update(dev) 91 | save_user(cfg, user) 92 | 93 | 94 | def list_devices(cfg): 95 | return list(cfg["devices"].keys()) 96 | 97 | 98 | def connect(name): 99 | cfg = load() 100 | devices = list_devices(cfg) 101 | if name in devices: 102 | p = dict() 103 | p["domain"] = cfg["server"] 104 | p["port"] = cfg["port"] 105 | p["ca_cert"] = cfg["ca_cert"] 106 | device = cfg["devices"][name] 107 | key = mangle(name) 108 | p["uuid"] = device["hardware"]["uuid"] 109 | p["devName"] = name 110 | p["devType"] = device["hardware"]["type"] 111 | p["hdwareVersion"] = device["hardware"]["version"] 112 | p["fmwareVersion"] = device["firmware"]["version"] 113 | return Mss310("token", key, None, **p) 114 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import json 5 | import argparse 6 | import sys 7 | 8 | from meross_powermon import (config, setup) 9 | from meross_powermon import command_line as cmd 10 | from meross_powermon.tests.definitions import (DUMMY_USER, DUMMY_DEVICE, 11 | DUMMY_CONTENTS) 12 | 13 | 14 | @pytest.fixture(scope="function") 15 | def prepare_setup(tmpdir, mocker, as_root, config_path): 16 | user_config_file = mocker.patch("meross_powermon.config.user_config_file") 17 | user_config_file.return_value = config_path.as_posix() 18 | config_path.write_text(json.dumps(DUMMY_USER) + "\n") 19 | opts = argparse.Namespace() 20 | opts.force = False 21 | config.add_device(DUMMY_DEVICE, opts, "nobody") 22 | cfg = json.loads(config_path.read_text()) 23 | cfg.update(DUMMY_CONTENTS) 24 | config.save(cfg) 25 | _ = mocker.patch("meross_powermon.config.save") 26 | scan = mocker.patch("meross_powermon.setup.iwlist.scan") 27 | parse = mocker.patch("meross_powermon.setup.iwlist.parse") 28 | parse.return_value = [{"essid": "Meross_Dummy_Device", 29 | "mac": "12:34:56:78:9a:bc:de"}] 30 | post = mocker.patch("meross_powermon.setup.requests.post", 31 | side_effect=mocked_post) 32 | run = mocker.patch("meross_powermon.setup.subprocess.run", 33 | side_effect=MockedSubprocessRun) 34 | return opts, cfg, scan, parse, post, run 35 | 36 | 37 | class MockedPost(): 38 | def __init__(self, stat, payload): 39 | self.status_code = stat 40 | self.payload = payload 41 | 42 | def json(self): 43 | return self.payload 44 | 45 | 46 | def mocked_post(*args, **kwargs): 47 | ns = kwargs["json"]["header"]["namespace"] 48 | if ns == "Appliance.System.All": 49 | res = dict({"payload": { 50 | "all": { 51 | "system": DUMMY_DEVICE["aircon"] 52 | } 53 | }}) 54 | return MockedPost(200, res) 55 | elif ns == "Appliance.Config.Key": 56 | return MockedPost(200, {}) 57 | elif ns == "Appliance.Config.Wifi": 58 | return MockedPost(200, {}) 59 | return MockedPost(404, None) 60 | 61 | 62 | class MockedSubprocessRun(): 63 | def __init__(self, *args, **kwargs): 64 | pass 65 | 66 | def check_returncode(self): 67 | pass 68 | 69 | 70 | def test_setup_device(tmpdir, as_root, prepare_setup): 71 | opts, cfg, scan, parse, post, run = prepare_setup 72 | with tmpdir.as_cwd(): 73 | opts.name = "test-device" 74 | setup.go(opts) 75 | assert post.call_count == 3 76 | 77 | 78 | def test_setup_duplicate_device(tmpdir, as_root, prepare_setup): 79 | opts, cfg, scan, parse, post, run = prepare_setup 80 | with tmpdir.as_cwd(): 81 | opts.name = "aircon" 82 | with pytest.raises(SystemExit) as ex: 83 | setup.go(opts) 84 | assert "Unable to overwrite device unless you use --force" in str(ex) 85 | 86 | 87 | def test_setup_force_duplicate_device(tmpdir, as_root, prepare_setup, mocker): 88 | opts, cfg, scan, parse, post, run = prepare_setup 89 | opts.force = True 90 | with tmpdir.as_cwd(): 91 | opts.name = "aircon" 92 | setup.go(opts) 93 | assert post.call_count == 3 94 | c = mocker.call 95 | run.assert_has_calls([c("ip link set dev MYWLAN up".split(), timeout=3), 96 | c("iwconfig MYWLAN ap 12:34:56:78:9a:bc:de " 97 | "essid Meross_Dummy_Device key off".split(), 98 | timeout=3), 99 | c("ip addr add 10.10.10.100/24 dev MYWLAN".split(), 100 | timeout=3), 101 | c("ip addr del 10.10.10.100/24 dev MYWLAN".split(), 102 | timeout=3), 103 | c("ip link set dev MYWLAN down".split(), timeout=3)]) 104 | -------------------------------------------------------------------------------- /meross_powermon/tests/test_monitor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import json 5 | import argparse 6 | import sys 7 | 8 | from meross_powermon import config 9 | from meross_powermon import monitor 10 | from meross_powermon import command_line as cmd 11 | 12 | DUMMY_USER = dict({"server": "test", 13 | "port": 1234, 14 | "ca_cert": "cb.crt" 15 | }) 16 | 17 | DUMMY_DEVICE = dict({"dev1": { 18 | "hardware": { 19 | "type": "mss310", 20 | "subType": "us", 21 | "version": "2.0.0", 22 | "chipType": "mt7682", 23 | "uuid": "12341234123412341234123412341234", 24 | "macAddress": "34:29:8f:13:c9:0a" 25 | }, 26 | "firmware": { 27 | "version": "2.1.7", 28 | "compileTime": "2018/11/08 09:58:45 GMT +08:00", 29 | "wifiMac": "12:34:56:12:34:56", 30 | "innerIp": "", 31 | "server": "", 32 | "port": 0, 33 | "userId": 0 34 | }, 35 | "time": { 36 | "timestamp": 14, 37 | "timezone": "", 38 | "timeRule": [] 39 | }, 40 | "online": { 41 | "status": 2 42 | } 43 | }}) 44 | 45 | 46 | class Dummy(object): 47 | def __init__(self, x, y, z, **kwdargs): 48 | self._name = "" 49 | self.n = 0 50 | pass 51 | 52 | def get_electricity(self): 53 | self.n += 1 54 | if self.n == 3: 55 | sys.exit("OK: End of test") 56 | return {"electricity": { 57 | "voltage": 100*self.n, 58 | "current": 200*self.n, 59 | "power": 3000*self.n} 60 | } 61 | 62 | 63 | @pytest.fixture(scope="function") 64 | def devices(config_path, mocker): 65 | user_config_file = mocker.patch("meross_powermon.config.user_config_file") 66 | user_config_file.return_value = config_path.as_posix() 67 | config_path.write_text(json.dumps(DUMMY_USER) + "\n") 68 | opts = argparse.Namespace() 69 | config.add_device(DUMMY_DEVICE, opts, "nobody") 70 | o = json.loads(config_path.read_text()) 71 | # Copy dev1 device to dev2 and dev3 72 | o["devices"]["dev2"] = o["devices"]["dev1"] 73 | o["devices"]["dev3"] = o["devices"]["dev1"] 74 | config_path.write_text(json.dumps(o) + "\n") 75 | return ["dev1", "dev2", "dev3"] 76 | 77 | 78 | def test_monitor_selection(tmpdir, devices, capsys): 79 | monitor.Mss310 = Dummy 80 | monitor.hook_for_testing = sys.exit 81 | args = "monitor dev2 dev3" 82 | with tmpdir.as_cwd(): 83 | opts, subcmds = cmd.arguments(args.split()) 84 | opts.delay = 0.0 85 | subcmds[opts.subcommand].go(opts) 86 | out, err = capsys.readouterr() 87 | assert " 10.00V 0.20A 3.00W" in out 88 | 89 | 90 | def test_monitor_bad_device(tmpdir, devices, capsys): 91 | monitor.Mss310 = Dummy 92 | args = "monitor all ~dev4" 93 | with tmpdir.as_cwd(): 94 | opts, subcmds = cmd.arguments(args.split()) 95 | opts.delay = 0.0 96 | subcmds[opts.subcommand].go(opts) 97 | out, err = capsys.readouterr() 98 | assert 'unknown device: "dev4"' in out 99 | 100 | 101 | def test_monitor_all(tmpdir, devices, capsys): 102 | monitor.Mss310 = Dummy 103 | args = "monitor all" 104 | with tmpdir.as_cwd(): 105 | opts, subcmds = cmd.arguments(args.split()) 106 | opts.delay = 0.0 107 | subcmds[opts.subcommand].go(opts) 108 | out, err = capsys.readouterr() 109 | assert " 10.00V 0.20A 3.00W" in out 110 | 111 | 112 | def test_monitor_one(tmpdir, devices, capsys): 113 | monitor.Mss310 = Dummy 114 | args = "monitor all ~dev2 ~dev3" 115 | with tmpdir.as_cwd(): 116 | opts, subcmds = cmd.arguments(args.split()) 117 | opts.delay = 0.0 118 | with pytest.raises(SystemExit) as ex: 119 | subcmds[opts.subcommand].go(opts) 120 | assert "OK: End of test" in str(ex) 121 | out, err = capsys.readouterr() 122 | assert " 10.00V 0.20A 3.00W" in out 123 | -------------------------------------------------------------------------------- /meross_powermon/command_line.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argcomplete 4 | import argparse 5 | import base64 6 | import sys 7 | import os 8 | 9 | from meross_powermon.version import VERSION 10 | from meross_powermon import (config, delete, init, monitor, setup) 11 | 12 | 13 | def devices(prefix, parsed_args, **kwargs): 14 | config.exists(fail=True) 15 | cfg = config.load() 16 | result = ["all"] 17 | result.extend(config.list_devices(cfg)) 18 | if not prefix.startswith("~"): 19 | pre = "" 20 | pfx = prefix 21 | else: 22 | pre = "~" 23 | pfx = prefix.lstrip("~") 24 | 25 | return [pre + name for name in result if name.startswith(pfx)] 26 | 27 | 28 | def just_devices(prefix, parsed_args, **kwargs): 29 | config.exists(fail=True) 30 | cfg = config.load() 31 | result = [] 32 | result.extend(config.list_devices(cfg)) 33 | return [name for name in result if name.startswith(prefix)] 34 | 35 | 36 | def arguments(*args): 37 | # Parser to store arguments 38 | parser = argparse.ArgumentParser(description="meross ({}) - A tool for " 39 | "local use of Meross IoT " 40 | "kit".format(VERSION)) 41 | 42 | # Subcommands 43 | subparser = parser.add_subparsers(dest="subcommand") 44 | subcommands = dict() 45 | 46 | # Config command 47 | cfg_p = subparser.add_parser("config", 48 | description="Update configuration info") 49 | subcommands["config"] = config 50 | config_or_init_options(cfg_p, required=False) 51 | 52 | # Init command 53 | ini_p = subparser.add_parser("init", 54 | description="Create and store initial" 55 | "configuration") 56 | subcommands["init"] = init 57 | config_or_init_options(ini_p, required=True) 58 | 59 | if os.getuid() == 0: 60 | # Setup (only if root) 61 | subcommands["setup"] = setup 62 | set_p = subparser.add_parser("setup", 63 | description="Locate and configure a " 64 | "Meross device") 65 | set_p.add_argument('--force', action="store_true", 66 | help="Allow overwriting of existing device names") 67 | set_p.add_argument('name', help="Local name for Meross device") 68 | 69 | else: 70 | # Delete (normal user) 71 | subcommands["delete"] = delete 72 | mon_d = subparser.add_parser("delete", 73 | description="Delete named device") 74 | mon_d.add_argument('name', help="Remove named device " 75 | ).completer = just_devices 76 | # Monitor (normal user) 77 | subcommands["monitor"] = monitor 78 | mon_p = subparser.add_parser("monitor", 79 | description="Monitor device(s)") 80 | mon_p.add_argument("--delay", type=int, default=5, 81 | help="Delay between polling device (default=5s)") 82 | mon_p.add_argument("--abserr", type=float, default=1000, 83 | help="Absolute error tolerance in mW " 84 | "(default: 1000)") 85 | mon_p.add_argument("--relerr", type=float, default=0.05, 86 | help="Relative error tolerance " 87 | "(default: 0.05)") 88 | mon_p.add_argument('name', nargs="*", default=["all"], 89 | help="Example monitoring of named devices. " 90 | "(Defalt: all devices)").completer = devices 91 | 92 | argcomplete.autocomplete(parser, always_complete_options=False) 93 | return (parser.parse_args(*args), subcommands) 94 | 95 | 96 | def config_or_init_options(parser, required): 97 | if os.getuid() == 0: 98 | parser.add_argument('--user', required=required, 99 | help="Which user's config to update") 100 | parser.add_argument('--ssid', required=required, 101 | help='Name of your wifi access point.') 102 | parser.add_argument('--password', required=required, 103 | help='Your wifi password. ') 104 | parser.add_argument('--interface', default="wlan0", metavar="IF", 105 | help="Wifi device (default: wlan0)") 106 | else: 107 | parser.add_argument('--server', required=required, 108 | help="MQTT server") 109 | parser.add_argument('--port', type=int, 110 | help="MQTT port (default: 8883)") 111 | parser.add_argument('--ca-cert', default=None, 112 | help="Certificate for connecting to MQTT server") 113 | parser.add_argument('--show', action="store_true", 114 | help="Show configuration data") 115 | -------------------------------------------------------------------------------- /meross_powermon/modified_device.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from threading import RLock, Condition 4 | from hashlib import md5 5 | import ssl 6 | import random 7 | import sys 8 | import time 9 | import string 10 | import json 11 | 12 | import paho.mqtt.client as mqtt 13 | from paho.mqtt import MQTTException 14 | 15 | from meross_iot.supported_devices.power_plugs import (Device, ClientStatus) 16 | from meross_iot.utilities.synchronization import AtomicCounter 17 | 18 | 19 | class ModifiedDevice(Device): 20 | 21 | def __init__(self, 22 | token, 23 | key, 24 | user_id, 25 | **kwords): 26 | 27 | self._status_lock = RLock() 28 | 29 | self._waiting_message_ack_queue = Condition() 30 | self._waiting_subscribers_queue = Condition() 31 | self._subscription_count = AtomicCounter(0) 32 | 33 | self._set_status(ClientStatus.INITIALIZED) 34 | 35 | self._token = token, 36 | self._key = key 37 | self._user_id = user_id 38 | self._uuid = kwords['uuid'] 39 | if "domain" in kwords: 40 | self._domain = kwords['domain'] 41 | else: 42 | self._domain = "eu-iot.meross.com" 43 | 44 | # Lookup port and certificate MQTT server 45 | self._port = kwords.get('port', 2001) 46 | self._ca_cert = kwords.get('ca_cert', None) 47 | 48 | if "channels" in kwords: 49 | self._channels = kwords['channels'] 50 | 51 | # Informations about device 52 | if "devName" in kwords: 53 | self._name = kwords['devName'] 54 | if "deviceType" in kwords: 55 | self._type = kwords['deviceType'] 56 | if "fmwareVersion" in kwords: 57 | self._fwversion = kwords['fmwareVersion'] 58 | if "hdwareVersion" in kwords: 59 | self._hwversion = kwords['hdwareVersion'] 60 | 61 | self._generate_client_and_app_id() 62 | 63 | # Password is calculated as the MD5 of USERID concatenated with KEY 64 | md5_hash = md5() 65 | clearpwd = "%s%s" % (self._user_id, self._key) 66 | md5_hash.update(clearpwd.encode("utf8")) 67 | hashed_password = md5_hash.hexdigest() 68 | 69 | # Start the mqtt client 70 | # ex. app-id -> app:08d4c9f99da40203ebc798a76512ec14 71 | self._mqtt_client = mqtt.Client(client_id=self._client_id, 72 | protocol=mqtt.MQTTv311) 73 | self._mqtt_client.on_connect = self._on_connect 74 | self._mqtt_client.on_message = self._on_message 75 | self._mqtt_client.on_disconnect = self._on_disconnect 76 | self._mqtt_client.on_subscribe = self._on_subscribe 77 | self._mqtt_client.on_log = self._on_log 78 | # Set user_id to None to avoid login 79 | if self._user_id is not None: 80 | self._mqtt_client.username_pw_set(username=self._user_id, 81 | password=hashed_password) 82 | self._mqtt_client.tls_set(ca_certs=self._ca_cert, certfile=None, 83 | keyfile=None, cert_reqs=ssl.CERT_REQUIRED, 84 | tls_version=ssl.PROTOCOL_TLS, 85 | ciphers=None) 86 | 87 | self._mqtt_client.connect(self._domain, self._port, keepalive=30) 88 | self._set_status(ClientStatus.CONNECTING) 89 | 90 | # Starts a new thread that handles mqtt protocol and calls us back 91 | # via callbacks 92 | self._mqtt_client.loop_start() 93 | 94 | with self._waiting_subscribers_queue: 95 | self._waiting_subscribers_queue.wait() 96 | if self._client_status != ClientStatus.SUBSCRIBED: 97 | # An error has occurred 98 | raise Exception(self._error) 99 | 100 | 101 | class Mss310(ModifiedDevice): 102 | def get_power_consumptionX(self): 103 | return self._execute_cmd("GET", "Appliance.Control.ConsumptionX", {}) 104 | 105 | def get_electricity(self): 106 | return self._execute_cmd("GET", "Appliance.Control.Electricity", {}) 107 | 108 | def turn_on(self): 109 | if self._hwversion.split(".")[0] == "2": 110 | payload = {'togglex': {"onoff": 1}} 111 | return self._execute_cmd("SET", "Appliance.Control.ToggleX", 112 | payload) 113 | else: 114 | payload = {"channel": 0, "toggle": {"onoff": 1}} 115 | return self._execute_cmd("SET", "Appliance.Control.Toggle", 116 | payload) 117 | 118 | def turn_off(self): 119 | if self._hwversion.split(".")[0] == "2": 120 | payload = {'togglex': {"onoff": 0}} 121 | return self._execute_cmd("SET", "Appliance.Control.ToggleX", 122 | payload) 123 | else: 124 | payload = {"channel": 0, "toggle": {"onoff": 0}} 125 | return self._execute_cmd("SET", "Appliance.Control.Toggle", 126 | payload) 127 | -------------------------------------------------------------------------------- /meross_powermon/setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import subprocess 4 | import requests 5 | import base64 6 | import subprocess 7 | import time 8 | import os 9 | import sys 10 | 11 | from meross_powermon import (iwlist, config) 12 | from meross_powermon.utils import mangle 13 | 14 | UPDOWN = "ip link set dev {} {}" 15 | ASSOCIATE = "iwconfig {} ap {} essid {} key off" 16 | URL = "http://10.10.10.1" 17 | 18 | HEADERS = dict({"from": "", 19 | "messageId": "", 20 | "method": "GET", 21 | "namespace": "Appliance.System.All", 22 | "payloadVersion": "", 23 | "sign": "", 24 | "timestamp": ""}) 25 | 26 | 27 | def go(opts): 28 | config.exists(fail=True) 29 | rootcfg = config.load() 30 | cfg = config.load_user(rootcfg["user"]) 31 | cfg.update(rootcfg) 32 | interface = cfg["interface"] 33 | 34 | wifi_up(interface) 35 | print("Looking for Meross device...", end="") 36 | 37 | try: 38 | attempts = 0 39 | while attempts < 300: 40 | print(".", end="") 41 | sys.stdout.flush() 42 | content = iwlist.scan() 43 | cells = iwlist.parse(content) 44 | for cell in cells: 45 | if cell["essid"].startswith("Meross_"): 46 | print("\nFound {}".format(cell["essid"])) 47 | 48 | associate(cell, interface) 49 | add_ip(interface) 50 | 51 | # Get initial device data 52 | dev0 = get_device_data() 53 | device = dict() 54 | device[opts.name] = dev0["payload"]["all"]["system"] 55 | 56 | # Setup server details 57 | set_server_details(cfg, opts) 58 | 59 | # Setup wifi details 60 | set_wifi_details(cfg) 61 | 62 | # Save device config 63 | config.add_device(device, opts, cfg["user"]) 64 | typ = device[opts.name]["hardware"]["type"] 65 | print("Added {} ({})".format(opts.name, typ)) 66 | 67 | # We're done 68 | attempts = 999 69 | break 70 | 71 | attempts += 1 72 | time.sleep(1) 73 | 74 | except subprocess.CalledProcessError: 75 | pass 76 | 77 | finally: 78 | # Remove the IP address from the interface 79 | del_ip(interface) 80 | wifi_down(interface) 81 | 82 | 83 | def wifi_up(interface): 84 | cmd = UPDOWN.format(interface, "up") 85 | run(cmd) 86 | 87 | 88 | def wifi_down(interface): 89 | cmd = UPDOWN.format(interface, "down") 90 | run(cmd) 91 | 92 | 93 | def associate(cell, interface): 94 | cmd = ASSOCIATE.format(interface, cell["mac"], cell["essid"]) 95 | run(cmd) 96 | 97 | 98 | def add_ip(interface): 99 | cmd = "ip addr add 10.10.10.100/24 dev {}".format(interface) 100 | run(cmd) 101 | 102 | 103 | def del_ip(interface): 104 | cmd = "ip addr del 10.10.10.100/24 dev {}".format(interface) 105 | run(cmd) 106 | 107 | 108 | def send(url, data): 109 | r = requests.post(url, json=data) 110 | if r.status_code != 200: 111 | print("status: ", r.status_code) 112 | sys.exit("error sending request to device: {}".format(url)) 113 | return r.json() 114 | 115 | 116 | def get_device_data(): 117 | headers = HEADERS.copy() 118 | payload = dict() 119 | data = dict({"header": headers, 120 | "payload": payload}) 121 | return send(URL + "/config/", data) 122 | 123 | 124 | def set_server_details(cfg, opts): 125 | header = HEADERS.copy() 126 | header["method"] = "SET" 127 | header["namespace"] = "Appliance.Config.Key" 128 | header["payloadVersion"] = 1 129 | header["timestamp"] = 0 130 | key = mangle(opts.name) 131 | payload = dict({"key": { 132 | "gateway": { 133 | "host": cfg["server"], 134 | "port": cfg["port"], 135 | "secondHost": cfg["server"], 136 | "secondPort": cfg["port"] 137 | }, 138 | "key": key, 139 | "userId": key 140 | }}) 141 | 142 | data = dict({"header": header, 143 | "payload": payload}) 144 | return send(URL + "/config/", data) 145 | 146 | 147 | def set_wifi_details(cfg): 148 | header = HEADERS.copy() 149 | header["method"] = "SET" 150 | header["namespace"] = "Appliance.Config.Wifi" 151 | header["payloadVersion"] = 0 152 | header["timestamp"] = 0 153 | payload = dict({"wifi": {"bssid": "", 154 | "channel": 1, 155 | "cipher": 3, 156 | "encryption": 6, 157 | "password": cfg["password"], 158 | "ssid": cfg["ssid"]}}) 159 | data = dict({"header": header, 160 | "payload": payload}) 161 | return send(URL + "/config/", data) 162 | 163 | 164 | def run(cmd): 165 | print(cmd, end=" ") 166 | result = subprocess.run(cmd.split(), timeout=3) 167 | result.check_returncode() 168 | print("OK") 169 | --------------------------------------------------------------------------------