├── mp ├── __init__.py ├── version.py ├── conbase.py ├── retry.py ├── tokenizer.py ├── conserial.py ├── contelnet.py ├── conwebsock.py ├── pyboard.py ├── term.py ├── mpfexp.py └── mpfshell.py ├── doc ├── screenshot.png └── uml │ └── mpfshell.puml ├── pyproject.toml ├── requirements.txt ├── MANIFEST.in ├── .editorconfig ├── tests ├── modules │ ├── README.md │ └── test_tokenizer.py └── ontarget │ ├── README.md │ ├── conftest.py │ └── test_mpfexp.py ├── setup.cfg ├── .pre-commit-config.yaml ├── setup.py ├── .gitignore ├── LICENSE ├── README.md └── CHANGELOG.md /mp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wendlers/mpfshell/HEAD/doc/screenshot.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | ignore = ["F403", "E501", "E722", "N802", "N803", "N806", "C901", "D100", "D102", "D102", "D10"] 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial ~= 3.4 2 | colorama ~= 0.3.6 3 | websocket_client ~= 0.35.0 4 | telnetlib-313-and-up; python_version >= '3.13' -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-include tests * 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | -------------------------------------------------------------------------------- /doc/uml/mpfshell.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | class Pyboard 4 | class MpFileExplorer 5 | class MpFileShell 6 | 7 | Pyboard <|- MpFileExplorer 8 | MpFileExplorer <- MpFileShell 9 | 10 | @enduml -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.py] 11 | indent_style = space 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /tests/modules/README.md: -------------------------------------------------------------------------------- 1 | # mpfshell - unit tests 2 | 2016-06-29, sw@kaltpost.de 3 | 4 | This directory contains the unit test-suite (WIP) for the mpfshell. 5 | It uses [pytest](https://pytest.org/). 6 | 7 | ## Running the tests 8 | 9 | Running the tests: 10 | 11 | export PYTHONPATH=$PWD/../.. 12 | py.test -v 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=setup.cfg 3 | ignore=F403,E128,E126,E123,E121,E265,E203,E501,N802,N803,N806,C901,W503,E722 4 | 5 | [isort] 6 | include_trailing_comma = true 7 | line_length = 120 8 | force_grid_wrap = 0 9 | multi_line_output = 3 10 | skip=migrations,node_modules 11 | 12 | [semantic_release] 13 | version_variable = mp/version.py:FULL 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 22.12.0 4 | hooks: 5 | - id: black 6 | name: Reformat files 7 | - repo: https://github.com/asottile/reorder_python_imports 8 | rev: v3.9.0 9 | hooks: 10 | - id: reorder-python-imports 11 | name: Reorder imports 12 | - repo: https://github.com/charliermarsh/ruff-pre-commit 13 | rev: 'v0.0.235' 14 | hooks: 15 | - id: ruff 16 | - repo: local 17 | hooks: 18 | - id: gitchangelog 19 | language: system 20 | always_run: true 21 | pass_filenames: false 22 | name: Generate changelog 23 | entry: bash -c "gitchangelog > CHANGELOG.md" 24 | stages: [commit] 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | from mp import version 5 | 6 | setup( 7 | name="mpfshell", 8 | version=version.FULL, 9 | description="A simple shell based file explorer for ESP8266 and WiPy " 10 | "Micropython devices.", 11 | author="Stefan Wendler", 12 | author_email="sw@kaltpost.de", 13 | url="https://github.com/wendlers/mpfshell", 14 | long_description=open("README.md").read(), 15 | long_description_content_type="text/markdown", 16 | install_requires=["pyserial", "colorama", "websocket_client"], 17 | packages=["mp"], 18 | include_package_data=True, 19 | keywords=["micropython", "shell", "file transfer", "development"], 20 | classifiers=[], 21 | entry_points={"console_scripts": ["mpfshell=mp.mpfshell:main"]}, 22 | ) 23 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | .idea/* 64 | venv 65 | *.swp 66 | MANIFEST 67 | .vscode 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Stefan Wendler 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. 22 | -------------------------------------------------------------------------------- /tests/ontarget/README.md: -------------------------------------------------------------------------------- 1 | # mpfshell - hardware tests 2 | 2016-06-29, sw@kaltpost.de 3 | 4 | This directory contains the test-suite (WIP) for the mpfshell which runs against 5 | real hardware. It uses [pytest](https://pytest.org/). 6 | 7 | ## Running the tests 8 | 9 | The tests are executed against a real MP board. Thus, the machine 10 | running the tests needs access to the MP board via serial line or 11 | websocket. 12 | 13 | The tests are currently only usable on the ESP8266 (not the WiPy). 14 | 15 | Running the tests on ttyUSB0: 16 | 17 | export PYTHONPATH=$PWD/../.. 18 | py.test -v --testcon "ser:/dev/ttyUSB0" 19 | 20 | Or over websockets: 21 | 22 | export PYTHONPATH=$PWD/../.. 23 | py.test -v --testcon "ws:192.168.1.1,passwd" 24 | 25 | To test the caching variant of the shell commands, add the `--caching` 26 | flag: 27 | 28 | export PYTHONPATH=$PWD/.. 29 | py.test -v --caching --testcon "ws:192.168.1.1,passwd" 30 | 31 | __Note:__ The test initially wipes everything from flash, except 32 | `boot.py` and `port_config.py`. To omit this, the `--nosetup` flag 33 | could be used (but the tests will fail if certain files and directories 34 | already exist). 35 | 36 | -------------------------------------------------------------------------------- /mp/version.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | 25 | 26 | FULL = "0.9.3" 27 | 28 | MAJOR, MINOR, PATCH = FULL.split(".") 29 | -------------------------------------------------------------------------------- /mp/conbase.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | 25 | 26 | class ConError(Exception): 27 | pass 28 | 29 | 30 | class ConBase: 31 | def __init__(self): 32 | pass 33 | 34 | def close(self): 35 | raise NotImplementedError 36 | 37 | def read(self, size): 38 | raise NotImplementedError 39 | 40 | def write(self, data): 41 | raise NotImplementedError 42 | 43 | def inWaiting(self): 44 | raise NotImplementedError 45 | 46 | @property 47 | def in_waiting(self): 48 | return self.inWaiting() 49 | 50 | def survives_soft_reset(self): 51 | return False 52 | -------------------------------------------------------------------------------- /mp/retry.py: -------------------------------------------------------------------------------- 1 | import time 2 | from functools import wraps 3 | 4 | 5 | def retry(ExceptionToCheck, tries=4, delay=3, backoff=2, logger=None): 6 | """ 7 | Retry calling the decorated function using an exponential backoff. 8 | 9 | http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/ 10 | original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry 11 | 12 | :param ExceptionToCheck: the exception to check. may be a tuple of 13 | exceptions to check 14 | :type ExceptionToCheck: Exception or tuple 15 | :param tries: number of times to try (not retry) before giving up 16 | :type tries: int 17 | :param delay: initial delay between retries in seconds 18 | :type delay: int 19 | :param backoff: backoff multiplier e.g. value of 2 will double the delay 20 | each retry 21 | :type backoff: int 22 | :param logger: logger to use. If None, print 23 | :type logger: logging.Logger instance 24 | """ 25 | 26 | def deco_retry(f): 27 | @wraps(f) 28 | def f_retry(*args, **kwargs): 29 | mtries, mdelay = tries, delay 30 | while mtries > 1: 31 | try: 32 | return f(*args, **kwargs) 33 | except ExceptionToCheck as e: 34 | msg = "%s, Retrying in %d seconds..." % (str(e), mdelay) 35 | if logger: 36 | logger.warning(msg) 37 | else: 38 | print(msg) 39 | time.sleep(mdelay) 40 | mtries -= 1 41 | mdelay *= backoff 42 | return f(*args, **kwargs) 43 | 44 | return f_retry # true decorator 45 | 46 | return deco_retry 47 | -------------------------------------------------------------------------------- /tests/modules/test_tokenizer.py: -------------------------------------------------------------------------------- 1 | from mp.tokenizer import Token 2 | from mp.tokenizer import Tokenizer 3 | 4 | 5 | class TestTokenizer: 6 | def __cmp_tokens(self, a, b): 7 | 8 | if len(a) != len(b): 9 | return False 10 | 11 | for i in range(len(a)): 12 | if a[i].kind != b[i].kind or a[i].value != b[i].value: 13 | return False 14 | 15 | return True 16 | 17 | def test_valid_strings(self): 18 | 19 | tests = [ 20 | ("simple1", [Token(Token.STR, "simple1")]), 21 | ( 22 | "simple1 simple2.txt", 23 | [Token(Token.STR, "simple1"), Token(Token.STR, "simple2.txt")], 24 | ), 25 | ('"Quoted"', [Token(Token.QSTR, "Quoted")]), 26 | ( 27 | '"Quoted with whitespace" non-quoted', 28 | [ 29 | Token(Token.QSTR, "Quoted with whitespace"), 30 | Token(Token.STR, "non-quoted"), 31 | ], 32 | ), 33 | ( 34 | '"$1+2 _3%2*1+2-2/#.py~" $1+2_3%2*1+2-2/#.py~', 35 | [ 36 | Token(Token.QSTR, "$1+2 _3%2*1+2-2/#.py~"), 37 | Token(Token.STR, "$1+2_3%2*1+2-2/#.py~"), 38 | ], 39 | ), 40 | ] 41 | 42 | t = Tokenizer() 43 | 44 | for string, exp_tokens in tests: 45 | tokens, rest = t.tokenize(string) 46 | assert rest == "" 47 | assert self.__cmp_tokens(exp_tokens, tokens) 48 | 49 | def test_invalid_strings(self): 50 | 51 | tests = [ 52 | ("char ? is invalid", "? is invalid"), 53 | ('"char ? is invalid"', '"char ? is invalid"'), 54 | ('"unbalanced quotes', '"unbalanced quotes'), 55 | ('"valid quotes" valid "unbalanced quotes', '"unbalanced quotes'), 56 | ('unbalanced quotes"', '"'), 57 | ] 58 | 59 | t = Tokenizer() 60 | 61 | for string, exp_rest in tests: 62 | tokens, rest = t.tokenize(string) 63 | assert rest == exp_rest 64 | -------------------------------------------------------------------------------- /mp/tokenizer.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import re 25 | 26 | 27 | class Token(object): 28 | 29 | STR = "STR" 30 | QSTR = "QSTR" 31 | 32 | def __init__(self, kind, value=None): 33 | 34 | self._kind = kind 35 | self._value = value 36 | 37 | @property 38 | def kind(self): 39 | return self._kind 40 | 41 | @property 42 | def value(self): 43 | return self._value 44 | 45 | def __repr__(self): 46 | 47 | if isinstance(self.value, str): 48 | v = "'%s'" % self.value 49 | else: 50 | v = str(self.value) 51 | 52 | return "Token('%s', %s)" % (self.kind, v) 53 | 54 | 55 | class Tokenizer(object): 56 | def __init__(self): 57 | 58 | valid_fnchars = r"A-Za-z0-9_%#~@/\$!\*\.\+\-\:" 59 | 60 | tokens = [ 61 | (r"[%s]+" % valid_fnchars, lambda scanner, token: Token(Token.STR, token)), 62 | ( 63 | r'"[%s ]+"' % valid_fnchars, 64 | lambda scanner, token: Token(Token.QSTR, token[1:-1]), 65 | ), 66 | (r"[ ]", lambda scanner, token: None), 67 | ] 68 | 69 | self.scanner = re.Scanner(tokens) 70 | 71 | def tokenize(self, string): 72 | 73 | return self.scanner.scan(string) 74 | -------------------------------------------------------------------------------- /mp/conserial.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import logging 25 | import time 26 | 27 | from serial import Serial 28 | 29 | from mp.conbase import ConBase 30 | from mp.conbase import ConError 31 | 32 | 33 | class ConSerial(ConBase): 34 | def __init__(self, port, baudrate=115200, reset=False): 35 | ConBase.__init__(self) 36 | 37 | try: 38 | self.serial = Serial(port, baudrate=baudrate, interCharTimeout=1) 39 | 40 | if reset: 41 | logging.info("Hard resetting device at port: %s" % port) 42 | 43 | self.serial.setDTR(True) 44 | time.sleep(0.25) 45 | self.serial.setDTR(False) 46 | 47 | self.serial.close() 48 | self.serial = Serial(port, baudrate=baudrate, interCharTimeout=1) 49 | 50 | while True: 51 | time.sleep(2.0) 52 | if not self.inWaiting(): 53 | break 54 | self.serial.read(self.inWaiting()) 55 | 56 | except Exception as e: 57 | logging.error(e) 58 | raise ConError(e) 59 | 60 | def close(self): 61 | return self.serial.close() 62 | 63 | def read(self, size): 64 | data = self.serial.read(size) 65 | logging.debug("serial read < %s" % str(data)) 66 | return data 67 | 68 | def write(self, data): 69 | logging.debug("serial write > %s" % str(data)) 70 | return self.serial.write(data) 71 | 72 | def inWaiting(self): 73 | return self.serial.inWaiting() 74 | 75 | def survives_soft_reset(self): 76 | return False 77 | -------------------------------------------------------------------------------- /tests/ontarget/conftest.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | # enable logging of modules under test 25 | import logging 26 | 27 | import pytest 28 | 29 | from mp.mpfexp import MpFileExplorer 30 | from mp.mpfexp import MpFileExplorerCaching 31 | 32 | _mpfexp_inst = None 33 | 34 | 35 | def pytest_addoption(parser): 36 | """ 37 | Add some custom parameters. 38 | 39 | :param parser: Parser object 40 | """ 41 | 42 | parser.addoption( 43 | "--testcon", 44 | action="store", 45 | default="ser:/dev/ttyUSB0", 46 | help="Connection string to use for tests", 47 | ) 48 | 49 | parser.addoption( 50 | "--caching", 51 | action="store_true", 52 | default=False, 53 | help="Enable caching of MpFileExplorer", 54 | ) 55 | 56 | parser.addoption( 57 | "--nosetup", action="store_true", default=False, help="Skip initial board setup" 58 | ) 59 | 60 | 61 | @pytest.fixture(scope="module") 62 | def mpsetup(request): 63 | """ 64 | Initial setup. Mainly clear out everything from FS except "boot.py" and "port_config.py" 65 | 66 | :param request: Request object 67 | """ 68 | 69 | if not request.config.getoption("--nosetup"): 70 | 71 | fe = MpFileExplorer(request.config.getoption("--testcon")) 72 | fe.puts( 73 | "pytest.py", 74 | """ 75 | def rm(path): 76 | import uos as os 77 | files = os.listdir(path) 78 | for f in files: 79 | if f not in ['boot.py', 'port_config.py']: 80 | try: 81 | os.remove(path + '/' + f) 82 | except: 83 | try: 84 | os.rmdir(path + '/' + f) 85 | except: 86 | rm(path + '/' + f) 87 | """, 88 | ) 89 | 90 | fe.exec_("import pytest") 91 | fe.exec_("pytest.rm('.')") 92 | 93 | 94 | @pytest.fixture(scope="function") 95 | def mpfexp(request): 96 | """ 97 | Fixture providing connected mMpFileExplorer instance 98 | 99 | :param request: Request object 100 | :return: MpFileExplorer instance 101 | """ 102 | 103 | global _mpfexp_inst 104 | 105 | def teardown(): 106 | _mpfexp_inst.close() 107 | 108 | if request.config.getoption("--caching"): 109 | _mpfexp_inst = MpFileExplorerCaching(request.config.getoption("--testcon")) 110 | else: 111 | _mpfexp_inst = MpFileExplorer(request.config.getoption("--testcon")) 112 | 113 | request.addfinalizer(teardown) 114 | 115 | return _mpfexp_inst 116 | 117 | 118 | logging.basicConfig( 119 | format="%(asctime)s\t%(levelname)s\t%(message)s", 120 | filename="test.log", 121 | level=logging.DEBUG, 122 | ) 123 | -------------------------------------------------------------------------------- /mp/contelnet.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import sys 25 | import telnetlib 26 | import time 27 | from collections import deque 28 | 29 | from mp.conbase import ConBase 30 | from mp.conbase import ConError 31 | 32 | 33 | class ConTelnet(ConBase): 34 | def __init__(self, ip, user, password): 35 | ConBase.__init__(self) 36 | 37 | if sys.version_info < (3, 0): 38 | self.read = self.__read2 39 | else: 40 | self.read = self.__read3 41 | 42 | self.tn = telnetlib.Telnet(ip) 43 | 44 | if user == "": 45 | self.fifo = deque() 46 | return 47 | 48 | if b"Login as:" in self.tn.read_until(b"Login as:", timeout=5.0): 49 | self.tn.write(bytes(user.encode("ascii")) + b"\r\n") 50 | 51 | if b"Password:" in self.tn.read_until(b"Password:", timeout=5.0): 52 | 53 | # needed because of internal implementation details of the telnet server 54 | time.sleep(0.2) 55 | self.tn.write(bytes(password.encode("ascii")) + b"\r\n") 56 | 57 | if b"for more information." in self.tn.read_until( 58 | b'Type "help()" for more information.', timeout=5.0 59 | ): 60 | self.fifo = deque() 61 | return 62 | 63 | raise ConError() 64 | 65 | def __del__(self): 66 | self.close() 67 | 68 | def close(self): 69 | try: 70 | self.tn.close() 71 | except Exception: 72 | # the telnet object might not exist yet, so ignore this one 73 | pass 74 | 75 | def __fill_fifo(self, size): 76 | 77 | while len(self.fifo) < size: 78 | 79 | timeout_count = 0 80 | data = self.tn.read_eager() 81 | 82 | if len(data): 83 | self.fifo.extend(data) 84 | else: 85 | time.sleep(0.25) 86 | timeout_count += 1 87 | 88 | def __read2(self, size=1): 89 | 90 | self.__fill_fifo(size) 91 | 92 | data = b"" 93 | while len(data) < size and len(self.fifo) > 0: 94 | data += self.fifo.popleft() 95 | 96 | return data 97 | 98 | def __read3(self, size=1): 99 | 100 | self.__fill_fifo(size) 101 | 102 | data = b"" 103 | while len(data) < size and len(self.fifo) > 0: 104 | data += bytes([self.fifo.popleft()]) 105 | 106 | return data 107 | 108 | def write(self, data): 109 | 110 | # print("write:", data) 111 | self.tn.write(data) 112 | return len(data) 113 | 114 | def inWaiting(self): 115 | 116 | n_waiting = len(self.fifo) 117 | 118 | if not n_waiting: 119 | data = self.tn.read_eager() 120 | self.fifo.extend(data) 121 | return len(data) 122 | else: 123 | return n_waiting 124 | 125 | def survives_soft_reset(self): 126 | return False 127 | -------------------------------------------------------------------------------- /mp/conwebsock.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import logging 25 | import threading 26 | import time 27 | from collections import deque 28 | 29 | import websocket 30 | 31 | from mp.conbase import ConBase 32 | from mp.conbase import ConError 33 | 34 | 35 | class ConWebsock(ConBase, threading.Thread): 36 | def __init__(self, ip, password): 37 | 38 | ConBase.__init__(self) 39 | threading.Thread.__init__(self) 40 | 41 | self.daemon = True 42 | 43 | self.fifo = deque() 44 | self.fifo_lock = threading.Lock() 45 | 46 | # websocket.enableTrace(logging.root.getEffectiveLevel() < logging.INFO) 47 | self.ws = websocket.WebSocketApp( 48 | "ws://%s:8266" % ip, 49 | on_message=self.on_message, 50 | on_error=self.on_error, 51 | on_close=self.on_close, 52 | ) 53 | 54 | self.start() 55 | 56 | self.timeout = 5.0 57 | 58 | if b"Password:" in self.read(10, blocking=False): 59 | self.ws.send(password + "\r") 60 | if b"WebREPL connected" not in self.read(25, blocking=False): 61 | raise ConError() 62 | else: 63 | raise ConError() 64 | 65 | self.timeout = 1.0 66 | 67 | logging.info("websocket connected to ws://%s:8266" % ip) 68 | 69 | def run(self): 70 | self.ws.run_forever() 71 | 72 | def __del__(self): 73 | self.close() 74 | 75 | def on_message(self, ws, message): 76 | self.fifo.extend(message) 77 | 78 | try: 79 | self.fifo_lock.release() 80 | except: 81 | pass 82 | 83 | def on_error(self, ws, error): 84 | logging.error("websocket error: %s" % error) 85 | 86 | try: 87 | self.fifo_lock.release() 88 | except: 89 | pass 90 | 91 | def on_close(self, ws): 92 | logging.info("websocket closed") 93 | 94 | try: 95 | self.fifo_lock.release() 96 | except: 97 | pass 98 | 99 | def close(self): 100 | try: 101 | self.ws.close() 102 | 103 | try: 104 | self.fifo_lock.release() 105 | except: 106 | pass 107 | 108 | self.join() 109 | except Exception: 110 | try: 111 | self.fifo_lock.release() 112 | except: 113 | pass 114 | 115 | def read(self, size=1, blocking=True): 116 | 117 | data = "" 118 | 119 | tstart = time.time() 120 | 121 | while (len(data) < size) and (time.time() - tstart < self.timeout): 122 | 123 | if len(self.fifo) > 0: 124 | data += self.fifo.popleft() 125 | elif blocking: 126 | self.fifo_lock.acquire() 127 | 128 | return data.encode("utf-8") 129 | 130 | def write(self, data): 131 | 132 | self.ws.send(data) 133 | return len(data) 134 | 135 | def inWaiting(self): 136 | return len(self.fifo) 137 | 138 | def survives_soft_reset(self): 139 | return False 140 | -------------------------------------------------------------------------------- /mp/pyboard.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Pyboard REPL interface 4 | """ 5 | import sys 6 | import time 7 | 8 | try: 9 | stdout = sys.stdout.buffer 10 | except AttributeError: 11 | # Python2 doesn't have buffer attr 12 | stdout = sys.stdout 13 | 14 | 15 | def stdout_write_bytes(b): 16 | b = b.replace(b"\x04", b"") 17 | stdout.write(b) 18 | stdout.flush() 19 | 20 | 21 | class PyboardError(BaseException): 22 | pass 23 | 24 | 25 | class Pyboard: 26 | def __init__(self, conbase): 27 | 28 | self.con = conbase 29 | 30 | def close(self): 31 | 32 | if self.con is not None: 33 | self.con.close() 34 | 35 | def read_until(self, min_num_bytes, ending, timeout=10, data_consumer=None): 36 | 37 | data = self.con.read(min_num_bytes) 38 | if data_consumer: 39 | data_consumer(data) 40 | timeout_count = 0 41 | while True: 42 | if data.endswith(ending): 43 | break 44 | elif self.con.inWaiting() > 0: 45 | new_data = self.con.read(1) 46 | data = data + new_data 47 | if data_consumer: 48 | data_consumer(new_data) 49 | timeout_count = 0 50 | else: 51 | timeout_count += 1 52 | if timeout is not None and timeout_count >= 100 * timeout: 53 | break 54 | time.sleep(0.01) 55 | return data 56 | 57 | def enter_raw_repl(self): 58 | 59 | time.sleep(0.5) # allow some time for board to reset 60 | self.con.write(b"\r\x03\x03") # ctrl-C twice: interrupt any running program 61 | 62 | # flush input (without relying on serial.flushInput()) 63 | n = self.con.inWaiting() 64 | while n > 0: 65 | self.con.read(n) 66 | n = self.con.inWaiting() 67 | 68 | if self.con.survives_soft_reset(): 69 | 70 | self.con.write(b"\r\x01") # ctrl-A: enter raw REPL 71 | data = self.read_until(1, b"raw REPL; CTRL-B to exit\r\n>") 72 | 73 | if not data.endswith(b"raw REPL; CTRL-B to exit\r\n>"): 74 | print(data) 75 | raise PyboardError("could not enter raw repl") 76 | 77 | self.con.write(b"\x04") # ctrl-D: soft reset 78 | data = self.read_until(1, b"soft reboot\r\n") 79 | if not data.endswith(b"soft reboot\r\n"): 80 | print(data) 81 | raise PyboardError("could not enter raw repl") 82 | 83 | # By splitting this into 2 reads, it allows boot.py to print stuff, 84 | # which will show up after the soft reboot and before the raw REPL. 85 | data = self.read_until(1, b"raw REPL; CTRL-B to exit\r\n") 86 | if not data.endswith(b"raw REPL; CTRL-B to exit\r\n"): 87 | print(data) 88 | raise PyboardError("could not enter raw repl") 89 | 90 | else: 91 | 92 | self.con.write(b"\r\x01") # ctrl-A: enter raw REPL 93 | data = self.read_until(1, b"raw REPL; CTRL-B to exit\r\n") 94 | 95 | if not data.endswith(b"raw REPL; CTRL-B to exit\r\n"): 96 | print(data) 97 | raise PyboardError("could not enter raw repl") 98 | 99 | def exit_raw_repl(self): 100 | self.con.write(b"\r\x02") # ctrl-B: enter friendly REPL 101 | 102 | def follow(self, timeout, data_consumer=None): 103 | 104 | # wait for normal output 105 | data = self.read_until(1, b"\x04", timeout=timeout, data_consumer=data_consumer) 106 | if not data.endswith(b"\x04"): 107 | raise PyboardError("timeout waiting for first EOF reception") 108 | data = data[:-1] 109 | 110 | # wait for error output 111 | data_err = self.read_until(1, b"\x04", timeout=timeout) 112 | if not data_err.endswith(b"\x04"): 113 | raise PyboardError("timeout waiting for second EOF reception") 114 | data_err = data_err[:-1] 115 | 116 | # return normal and error output 117 | return data, data_err 118 | 119 | def exec_raw_no_follow(self, command): 120 | 121 | if isinstance(command, bytes): 122 | command_bytes = command 123 | else: 124 | command_bytes = bytes(command.encode("utf-8")) 125 | 126 | # check we have a prompt 127 | data = self.read_until(1, b">") 128 | if not data.endswith(b">"): 129 | raise PyboardError("could not enter raw repl") 130 | 131 | # write command 132 | self.con.write(command_bytes) 133 | self.con.write(b"\x04") 134 | 135 | # check if we could exec command 136 | data = self.read_until(2, b"OK", timeout=0.5) 137 | if data != b"OK": 138 | raise PyboardError("could not exec command") 139 | 140 | def exec_raw(self, command, timeout=10, data_consumer=None): 141 | self.exec_raw_no_follow(command) 142 | return self.follow(timeout, data_consumer) 143 | 144 | def eval(self, expression): 145 | ret = self.exec_("print({})".format(expression)) 146 | ret = ret.strip() 147 | return ret 148 | 149 | def exec_(self, command): 150 | ret, ret_err = self.exec_raw(command) 151 | if ret_err: 152 | raise PyboardError("exception", ret, ret_err) 153 | return ret 154 | 155 | def execfile(self, filename): 156 | with open(filename, "rb") as f: 157 | pyfile = f.read() 158 | return self.exec_(pyfile) 159 | 160 | def get_time(self): 161 | t = str(self.eval("pyb.RTC().datetime()").encode("utf-8"))[1:-1].split(", ") 162 | return int(t[4]) * 3600 + int(t[5]) * 60 + int(t[6]) 163 | 164 | 165 | # in Python2 exec is a keyword so one must use "exec_" 166 | # but for Python3 we want to provide the nicer version "exec" 167 | setattr(Pyboard, "exec", Pyboard.exec_) 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpfshell 2 | 3 | [![](https://www.codeshelter.co/static/badges/badge-plastic.9800c3f706ba.svg)](https://www.codeshelter.co/) 4 | 5 | [![PyPI version](https://badge.fury.io/py/mpfshell.svg)](https://badge.fury.io/py/mpfshell) 6 | 7 | 2016-06-21, sw@kaltpost.de 8 | 9 | A simple shell based file explorer for ESP8266 and WiPy 10 | [Micropython](https://github.com/micropython/micropython) based devices. 11 | 12 | The shell is a helper for up/downloading files to the ESP8266 (over serial line and Websockets) 13 | and WiPy (serial line and telnet). It basically offers commands to list and upload/download 14 | files on the flash FS of the device. 15 | 16 | ![mpfshell](./doc/screenshot.png) 17 | 18 | Main features: 19 | 20 | * Support for serial connections (ESP8266 and WiPy) 21 | * Support for websockets (via WebREPL) connections (ESP8266 only) 22 | * Support for telnet connections (WiPy only) 23 | * Full directory handling (enter, create, remove) 24 | * Transfer (upload/download) of multiple files matching a reg.-exp. 25 | * All files are transferred in binary mode, and thus it should be 26 | possible to also upload pre-compiled code (.mpy) too. 27 | * You can compile and upload files with one command. 28 | * Integrated REPL (supporting a workflow like: upload changed files, enter REPL, test, exit REPL, upload ...) 29 | * Fully scriptable 30 | * Tab-completion 31 | * Command history 32 | * Best of all: it comes with color 33 | 34 | 35 | __Note__: The software is mainly tested on Ubuntu 16.04 LTS. However, there is basic Windows support 36 | (tested with Python 3.5 and PySerial 3.1), but some of the keys (e.g. Tab) are note working as 37 | expected yet. 38 | 39 | ## Requirements 40 | 41 | General: 42 | 43 | * ESP8266 or WiPy board running latest [Micropython](https://github.com/micropython/micropython) 44 | * For the ESP8266 firmware build from the repository, please note, that WebREPL is not started 45 | by default. For more information see the [quickstart](http://micropython.org/resources/docs/en/latest/esp8266/esp8266/quickref.html#webrepl-web-browser-interactive-prompt). 46 | * For the WiPy, please note, that you need to enable REPL on UART if you intend to connect 47 | via serial line to the WiPy (see [here](http://micropython.org/resources/docs/en/latest/wipy/wipy/tutorial/repl.html)) 48 | 49 | For the shell: 50 | 51 | * Python >= 2.7 or Python >= 3.4 52 | * The PySerial, colorama, and websocket-client packages (`pip install -r requirements.txt`) 53 | 54 | __IMPORTANT__: It is highly recommended to use PySerial version 3.x on Python 2 and 3. 55 | 56 | __Note__: The tools only work if the REPL is accessible on the device! 57 | 58 | ## Installing 59 | 60 | ### From PyPi 61 | 62 | To install the latest release from PyPi: 63 | 64 | sudo pip install mpfshell 65 | 66 | ### From Source 67 | 68 | Clone this repository: 69 | 70 | git clone https://github.com/wendlers/mpfshell 71 | 72 | To install for __Python 2__, execute the following: 73 | 74 | sudo pip install -r requirements.txt 75 | sudo python setup.py install 76 | 77 | To install for __Python 3__, execute the following: 78 | 79 | sudo pip3 install -r requirements.txt 80 | sudo python3 setup.py install 81 | 82 | ## Known Issues 83 | 84 | * For PySerial 2.6 the REPL is deactivated since Miniterm (which comes with 2.6) 85 | seems broken. 86 | 87 | ## General 88 | 89 | ### TAB Completion 90 | 91 | The shell supports TAB completion for commands and file names. 92 | So it's totally worth pressing TAB-TAB every now and then :-) 93 | 94 | ### File/Directory Names 95 | 96 | File names including whitespaces are supported, but such names need to be enclosed 97 | in quotes. E.g. accessing a file named "with white space.txt" needs to quoted: 98 | 99 | get "with white space.txt" 100 | put "with white space.txt" without-white-space.txt 101 | put without-white-space.txt "with white space.txt" 102 | 103 | The following characters are accepted for file and directory names: 104 | 105 | A-Za-z0-9 _%#~@/\$!\*\.\+\- 106 | 107 | ## Shell Usage 108 | 109 | __Note:__ Since version 0.7.1, the shell offers caching for file and 110 | directory names. It is now enabled by default. To disable caching, 111 | add the `--nocache` flag on the command line. 112 | 113 | Start the shell with: 114 | 115 | mpfshell 116 | 117 | At the shell prompt, first connect to the device. E.g. to connect 118 | via serial line: 119 | 120 | mpfs> open ttyUSB0 121 | 122 | Or connect via websocket (ESP8266 only) with the password "python": 123 | 124 | mpfs> open ws:192.168.1.1,python 125 | 126 | Or connect vial telnet (WiPy only) with username "micro" and password "python": 127 | 128 | mpfs> open tn:192.168.1.1,micro,python 129 | 130 | __Note__: Login and password are optional. If left out, they will be asked for. 131 | 132 | Now you can list the files on the device with: 133 | 134 | mpfs> ls 135 | 136 | To upload e.g. the local file "boot.py" to the device use: 137 | 138 | mpfs> put boot.py 139 | 140 | If you like to use a different filename on the device, you could use this: 141 | 142 | mpfs> put boot.py main.py 143 | 144 | To compile before uploading and upload the compiled file (you need mpy-cross in your path): 145 | 146 | mpfs > putc boot.py 147 | 148 | Or to upload all files that match a regular expression from the 149 | current local directory to the current remote directory: 150 | 151 | mpfs> mput .*\.py 152 | 153 | And to download e.g. the file "boot.py" from the device use: 154 | 155 | mpfs> get boot.py 156 | 157 | Using a different local file name: 158 | 159 | mpfs> get boot.py my_boot.py 160 | 161 | Or to download all files that match a regular expression from the 162 | current remote directory to the current local directory: 163 | 164 | mpfs> mget .*\.py 165 | 166 | To remove a file (or directory) on the device use: 167 | 168 | mpfs> rm boot.py 169 | 170 | Or remove all remote files that match a regular expression: 171 | 172 | mpfs> mrm test.*\.py 173 | 174 | To create a new remote directory: 175 | 176 | mpfs> md test 177 | 178 | To navigate remote directories: 179 | 180 | mpfs> cd test 181 | mpfs> cd .. 182 | mpfs> cd /some/full/path 183 | 184 | See which is the current remote directory: 185 | 186 | mpfs> pwd 187 | 188 | Remove a remote directory: 189 | 190 | mpfs> rm test 191 | 192 | __Note__: The directory to delete needs to be empty! 193 | 194 | To navigate on the local filesystem, use: 195 | 196 | lls, lcd lpwd 197 | 198 | Enter REPL: 199 | 200 | repl 201 | 202 | To exit REPL and return to the file shell use Ctrl+] 203 | 204 | For a full list of commands use: 205 | 206 | mpfs> help 207 | mpfs> help 208 | 209 | The shell is also scriptable. 210 | 211 | E.g. to execute a command, and then enter the shell: 212 | 213 | mpfshell -c "open ttyUSB0" 214 | 215 | Or to copy the file "boot.py" to the device, and don't enter the shell at all: 216 | 217 | mpfshell -n -c "open ttyUSB0; put boot.py" 218 | 219 | It is also possible to put a bunch of shell commands in a file, and then execute 220 | them from that file. 221 | 222 | E.g. creating a file called "myscript.mpf": 223 | 224 | open ttyUSB0 225 | put boot.py 226 | put main.py 227 | ls 228 | 229 | And execute it with: 230 | 231 | mpfshell -s myscript.mpf 232 | 233 | 234 | ## Running the Shell in a Virtual Environment 235 | 236 | Somtimes the easiest way to satisfy the requirements is to setup a virtual environment. 237 | E.g. on Debian Jessie (which still has PySerial 2.6), this could be 238 | done like so (assuming you are within the `mpfshell` base directory): 239 | 240 | Install support for virtual environments: 241 | 242 | sudo apt-get install python3-venv 243 | 244 | Create a new virtual environment: 245 | 246 | pyvenv venv 247 | 248 | Or you could use `python3 -m virtualenv venv` instead of `pyvenv`. 249 | 250 | Activate it (so every following `pip install` goes to the new virtual environment): 251 | 252 | source venv/bin/activate 253 | 254 | Now install the dependencies to the virtual environment: 255 | 256 | pip install -r requirements.txt 257 | python setup.py install 258 | 259 | Now run the shell with the following command: 260 | 261 | python -m mp.mpfshell 262 | 263 | __Note:__ The environment always has to be activated with the above command before using it. 264 | -------------------------------------------------------------------------------- /mp/term.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import serial 25 | 26 | if serial.VERSION.startswith("2."): 27 | 28 | # see if we could use the legacy Miniterm implementation 29 | from serial.tools.miniterm import ( 30 | Miniterm, 31 | console, 32 | CONVERT_CRLF, 33 | CONVERT_CR, 34 | CONVERT_LF, 35 | NEWLINE_CONVERISON_MAP, 36 | ) 37 | 38 | class Term(Miniterm): 39 | def __init__(self, serial_instance, echo=False, eol="crlf"): 40 | 41 | self.serial = serial_instance 42 | 43 | convert = {"cr": CONVERT_CR, "lf": CONVERT_LF, "crlf": CONVERT_CRLF} 44 | 45 | self.console = console 46 | self.echo = echo 47 | self.repr_mode = 0 48 | self.convert_outgoing = convert[eol] 49 | self.newline = NEWLINE_CONVERISON_MAP[self.convert_outgoing] 50 | self.dtr_state = True 51 | self.rts_state = True 52 | self.break_state = False 53 | 54 | self.exit_character = None 55 | self.menu_character = None 56 | self.raw = None 57 | 58 | self.console.setup() 59 | 60 | def set_rx_encoding(self, enc): 61 | pass 62 | 63 | def set_tx_encoding(self, enc): 64 | pass 65 | 66 | else: 67 | 68 | # see if we could use the new Miniterm implementation 69 | from serial.tools.miniterm import Miniterm, ConsoleBase, unichr 70 | import os 71 | 72 | if os.name == "nt": # noqa 73 | import codecs 74 | import sys 75 | import msvcrt 76 | import ctypes 77 | 78 | class Out(object): 79 | """file-like wrapper that uses os.write""" 80 | 81 | def __init__(self, fd): 82 | self.fd = fd 83 | 84 | def flush(self): 85 | pass 86 | 87 | def write(self, s): 88 | os.write(self.fd, s) 89 | 90 | class Console(ConsoleBase): 91 | fncodes = { 92 | ";": "\1bOP", # F1 93 | "<": "\1bOQ", # F2 94 | "=": "\1bOR", # F3 95 | ">": "\1bOS", # F4 96 | "?": "\1b[15~", # F5 97 | "@": "\1b[17~", # F6 98 | "A": "\1b[18~", # F7 99 | "B": "\1b[19~", # F8 100 | "C": "\1b[20~", # F9 101 | "D": "\1b[21~", # F10 102 | } 103 | navcodes = { 104 | "H": "\x1b[A", # UP 105 | "P": "\x1b[B", # DOWN 106 | "K": "\x1b[D", # LEFT 107 | "M": "\x1b[C", # RIGHT 108 | "G": "\x1b[H", # HOME 109 | "O": "\x1b[F", # END 110 | "R": "\x1b[2~", # INSERT 111 | "S": "\x1b[3~", # DELETE 112 | "I": "\x1b[5~", # PGUP 113 | "Q": "\x1b[6~", # PGDN 114 | } 115 | 116 | def __init__(self): 117 | super(Console, self).__init__() 118 | self._saved_ocp = ctypes.windll.kernel32.GetConsoleOutputCP() 119 | self._saved_icp = ctypes.windll.kernel32.GetConsoleCP() 120 | ctypes.windll.kernel32.SetConsoleOutputCP(65001) 121 | ctypes.windll.kernel32.SetConsoleCP(65001) 122 | # ANSI handling available through SetConsoleMode since Windows 10 v1511 123 | # https://en.wikipedia.org/wiki/ANSI_escape_code#cite_note-win10th2-1 124 | # if platform.release() == '10' and int(platform.version().split('.')[2]) > 10586: 125 | ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 126 | import ctypes.wintypes as wintypes 127 | 128 | if not hasattr(wintypes, "LPDWORD"): # PY2 129 | wintypes.LPDWORD = ctypes.POINTER(wintypes.DWORD) 130 | SetConsoleMode = ctypes.windll.kernel32.SetConsoleMode 131 | GetConsoleMode = ctypes.windll.kernel32.GetConsoleMode 132 | GetStdHandle = ctypes.windll.kernel32.GetStdHandle 133 | mode = wintypes.DWORD() 134 | GetConsoleMode(GetStdHandle(-11), ctypes.byref(mode)) 135 | if (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0: 136 | SetConsoleMode( 137 | GetStdHandle(-11), 138 | mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING, 139 | ) 140 | self._saved_cm = mode 141 | self.output = codecs.getwriter("UTF-8")( 142 | Out(sys.stdout.fileno()), "replace" 143 | ) 144 | # the change of the code page is not propagated to Python, manually fix it 145 | sys.stderr = codecs.getwriter("UTF-8")( 146 | Out(sys.stderr.fileno()), "replace" 147 | ) 148 | sys.stdout = self.output 149 | self.output.encoding = "UTF-8" # needed for input 150 | 151 | def __del__(self): 152 | ctypes.windll.kernel32.SetConsoleOutputCP(self._saved_ocp) 153 | ctypes.windll.kernel32.SetConsoleCP(self._saved_icp) 154 | try: 155 | ctypes.windll.kernel32.SetConsoleMode( 156 | ctypes.windll.kernel32.GetStdHandle(-11), self._saved_cm 157 | ) 158 | except AttributeError: # in case no _saved_cm 159 | pass 160 | 161 | def getkey(self): 162 | while True: 163 | z = msvcrt.getwch() 164 | if z == unichr(13): 165 | return unichr(10) 166 | elif z is unichr(0) or z is unichr(0xE0): 167 | try: 168 | code = msvcrt.getwch() 169 | if z is unichr(0): 170 | return self.fncodes[code] 171 | else: 172 | return self.navcodes[code] 173 | except KeyError: 174 | pass 175 | else: 176 | return z 177 | 178 | def cancel(self): 179 | # CancelIo, CancelSynchronousIo do not seem to work when using 180 | # getwch, so instead, send a key to the window with the console 181 | hwnd = ctypes.windll.kernel32.GetConsoleWindow() 182 | ctypes.windll.user32.PostMessageA(hwnd, 0x100, 0x0D, 0) 183 | 184 | class Term(Miniterm): 185 | def __init__(self, serial_instance, echo=False, eol="crlf", filters=()): 186 | self.console = Console() 187 | self.serial = serial_instance 188 | self.echo = echo 189 | self.raw = False 190 | self.input_encoding = "UTF-8" 191 | self.output_encoding = "UTF-8" 192 | self.eol = eol 193 | self.filters = filters 194 | self.update_transformations() 195 | self.exit_character = unichr(0x1D) # GS/CTRL+] 196 | self.menu_character = unichr(0x14) # Menu: CTRL+T 197 | self.alive = None 198 | self._reader_alive = None 199 | self.receiver_thread = None 200 | self.rx_decoder = None 201 | self.tx_decoder = None 202 | 203 | else: 204 | Term = Miniterm 205 | -------------------------------------------------------------------------------- /tests/ontarget/test_mpfexp.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import os 25 | 26 | import pytest 27 | 28 | from mp.mpfshell import RemoteIOError 29 | 30 | 31 | @pytest.mark.usefixtures("mpsetup") 32 | class TestMpfexp: 33 | """ 34 | Tests for the MpFileExplorer class. 35 | """ 36 | 37 | def __create_local_file(self, file, data=b""): 38 | 39 | with open(file, "wb") as f: 40 | f.write(data) 41 | 42 | def test_directory_handling(self, mpfexp): 43 | 44 | assert "/" == mpfexp.pwd() 45 | 46 | mpfexp.md("dir1") 47 | mpfexp.md("dir 1") 48 | mpfexp.md("dir1/subdir1") 49 | 50 | # no duplicate directory names 51 | with pytest.raises(RemoteIOError): 52 | mpfexp.md("dir1") 53 | 54 | with pytest.raises(RemoteIOError): 55 | mpfexp.md("dir1/subdir1") 56 | 57 | # no subdir in non existing dir 58 | with pytest.raises(RemoteIOError): 59 | mpfexp.md("dir2/subdir1") 60 | 61 | # relative directory creating 62 | mpfexp.cd("dir1") 63 | assert "/dir1" == mpfexp.pwd() 64 | mpfexp.md("subdir2") 65 | 66 | # created dirs visible for ls and marked as directory 67 | mpfexp.cd("/") 68 | assert "/" == mpfexp.pwd() 69 | assert ("dir1", "D") in mpfexp.ls(True, True, True) 70 | assert ("dir 1", "D") in mpfexp.ls(True, True, True) 71 | 72 | # no dir with same name as existing file 73 | with pytest.raises(RemoteIOError): 74 | mpfexp.md("boot.py") 75 | 76 | # subdirs are visible for ls 77 | mpfexp.cd("dir1") 78 | assert "/dir1" == mpfexp.pwd() 79 | assert [("subdir1", "D"), ("subdir2", "D")] == mpfexp.ls(True, True, True) 80 | 81 | mpfexp.cd("subdir1") 82 | assert "/dir1/subdir1" == mpfexp.pwd() 83 | assert [] == mpfexp.ls(True, True, True) 84 | 85 | mpfexp.cd("..") 86 | mpfexp.cd("subdir2") 87 | assert "/dir1/subdir2" == mpfexp.pwd() 88 | assert [] == mpfexp.ls(True, True, True) 89 | 90 | # no duplicate directory names 91 | with pytest.raises(RemoteIOError): 92 | mpfexp.cd("subdir1") 93 | 94 | # FIXME: not working as expected yet 95 | # mpfexp.cd('../subdir1') 96 | # assert "/dir1/subdir1" == mpfexp.pwd() 97 | 98 | # allow whitespaces in dir names 99 | mpfexp.cd("/dir 1") 100 | assert "/dir 1" == mpfexp.pwd() 101 | assert [] == mpfexp.ls(True, True, True) 102 | 103 | def test_file_handling(self, mpfexp, tmpdir): 104 | 105 | os.chdir(str(tmpdir)) 106 | data = b"\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99" 107 | self.__create_local_file("file1", data) 108 | 109 | # upload with same name 110 | mpfexp.put("file1") 111 | 112 | # upload with different name 113 | mpfexp.put("file1", "file2") 114 | assert ("file1", "F") in mpfexp.ls(True, True, True) 115 | assert ("file2", "F") in mpfexp.ls(True, True, True) 116 | 117 | os.remove("file1") 118 | assert not os.path.isfile("file1") 119 | 120 | # download and compare 121 | mpfexp.get("file1") 122 | mpfexp.get("file2") 123 | mpfexp.get("file1", "file3") 124 | 125 | for name in ["file1", "file2", "file3"]: 126 | with open(name, "rb") as f: 127 | assert data == f.read() 128 | 129 | # overwrite existing file 130 | data = b"\xaa\xbb\xcc\xdd\xee\xff" 131 | self.__create_local_file("file1", data) 132 | 133 | mpfexp.put("file1") 134 | 135 | with open("file1", "rb") as f: 136 | assert data == f.read() 137 | 138 | # file with name of existing directory not allowed 139 | self.__create_local_file("dir2") 140 | 141 | mpfexp.md("dir2") 142 | 143 | with pytest.raises(RemoteIOError): 144 | mpfexp.put("file1", "dir2") 145 | 146 | with pytest.raises(RemoteIOError): 147 | mpfexp.put("dir2") 148 | 149 | # put files to subdir 150 | mpfexp.put("file1", "dir2/file1") 151 | mpfexp.cd("dir2") 152 | mpfexp.put("file2", "file2") 153 | assert [("file1", "F"), ("file2", "F")] == mpfexp.ls(True, True, True) 154 | mpfexp.cd("/") 155 | 156 | # fail to put to non-existing directory 157 | with pytest.raises(RemoteIOError): 158 | mpfexp.put("file1", "dir3/file1") 159 | 160 | # fail to get non-existing file 161 | with pytest.raises(RemoteIOError): 162 | mpfexp.get("file99") 163 | 164 | with pytest.raises(RemoteIOError): 165 | mpfexp.get("dir2") 166 | 167 | with pytest.raises(RemoteIOError): 168 | mpfexp.get("dir2/file99") 169 | 170 | # fail to get to non-existing dir 171 | with pytest.raises(IOError): 172 | mpfexp.get("file1", "dir/file") 173 | 174 | # fail to put non existing file 175 | with pytest.raises(IOError): 176 | mpfexp.put("file99") 177 | 178 | # allow whitespaces in file-names 179 | mpfexp.put("file1", "file 1") 180 | mpfexp.get("file 1") 181 | assert ("file 1", "F") in mpfexp.ls(True, True, True) 182 | 183 | def test_removal(self, mpfexp, tmpdir): 184 | 185 | os.chdir(str(tmpdir)) 186 | 187 | mpfexp.md("dir3") 188 | mpfexp.md("dir 3") 189 | self.__create_local_file("file10") 190 | 191 | mpfexp.put("file10") 192 | mpfexp.put("file10", "dir3/file1") 193 | mpfexp.put("file10", "dir3/file2") 194 | 195 | # don't allow deletion of non empty dirs 196 | with pytest.raises(RemoteIOError): 197 | mpfexp.rm("dir3") 198 | 199 | # delete files and empty dirs 200 | mpfexp.rm("file10") 201 | mpfexp.rm("dir3/file1") 202 | 203 | mpfexp.cd("dir3") 204 | mpfexp.rm("file2") 205 | assert [] == mpfexp.ls(True, True, True) 206 | 207 | mpfexp.cd("/") 208 | mpfexp.rm("dir3") 209 | mpfexp.rm("dir 3") 210 | 211 | assert ("file10", "F") not in mpfexp.ls(True, True, True) 212 | assert ("dir3", "D") not in mpfexp.ls(True, True, True) 213 | assert ("dir 3", "D") not in mpfexp.ls(True, True, True) 214 | 215 | # fail to remove non-existing file or dir 216 | with pytest.raises(RemoteIOError): 217 | mpfexp.rm("file10") 218 | 219 | with pytest.raises(RemoteIOError): 220 | mpfexp.rm("dir3") 221 | 222 | def test_mputget(self, mpfexp, tmpdir): 223 | 224 | os.chdir(str(tmpdir)) 225 | 226 | self.__create_local_file("file20") 227 | self.__create_local_file("file21") 228 | self.__create_local_file("file22") 229 | 230 | mpfexp.md("dir4") 231 | mpfexp.cd("dir4") 232 | mpfexp.mput(".", r"file\.*") 233 | 234 | assert [("file20", "F"), ("file21", "F"), ("file22", "F")] == sorted( 235 | mpfexp.ls(True, True, True) 236 | ) 237 | 238 | os.mkdir("mget") 239 | os.chdir(os.path.join(str(tmpdir), "mget")) 240 | mpfexp.mget(".", r"file\.*") 241 | assert ["file20", "file21", "file22"] == sorted(os.listdir(".")) 242 | 243 | mpfexp.mget(".", "notmatching") 244 | 245 | with pytest.raises(RemoteIOError): 246 | mpfexp.mput(".", "*") 247 | 248 | with pytest.raises(RemoteIOError): 249 | mpfexp.mget(".", "*") 250 | 251 | def test_putsgets(self, mpfexp): 252 | 253 | mpfexp.md("dir5") 254 | mpfexp.cd("dir5") 255 | 256 | data = "Some random data" 257 | 258 | mpfexp.puts("file1", data) 259 | assert mpfexp.gets("file1").startswith(data) 260 | 261 | mpfexp.cd("/") 262 | 263 | with pytest.raises(RemoteIOError): 264 | mpfexp.puts("invalid/file1", "don't care") 265 | 266 | with pytest.raises(RemoteIOError): 267 | mpfexp.puts("dir5", "don't care") 268 | 269 | mpfexp.puts("dir5/file1", data) 270 | 271 | with pytest.raises(RemoteIOError): 272 | mpfexp.gets("dir5") 273 | 274 | with pytest.raises(RemoteIOError): 275 | mpfexp.gets("dir5/file99") 276 | 277 | def test_bigfile(self, mpfexp, tmpdir): 278 | 279 | os.chdir(str(tmpdir)) 280 | 281 | data = b"\xab" * (1024 * 40) 282 | self.__create_local_file("file30", data) 283 | 284 | mpfexp.md("dir6") 285 | mpfexp.cd("dir6") 286 | mpfexp.put("file30", "file1") 287 | 288 | mpfexp.get("file1") 289 | 290 | with open("file1", "rb") as f: 291 | assert data == f.read() 292 | 293 | def test_stress(self, mpfexp, tmpdir): 294 | 295 | os.chdir(str(tmpdir)) 296 | mpfexp.md("dir7") 297 | mpfexp.cd("dir7") 298 | 299 | for i in range(20): 300 | 301 | data = b"\xab" * (1024 * 1) 302 | self.__create_local_file("file40", data) 303 | 304 | mpfexp.put("file40", "file1") 305 | assert [("file1", "F")] == mpfexp.ls(True, True, True) 306 | 307 | mpfexp.put("file40", "file2") 308 | assert [("file1", "F"), ("file2", "F")] == mpfexp.ls(True, True, True) 309 | 310 | mpfexp.md("subdir1") 311 | assert [("subdir1", "D"), ("file1", "F"), ("file2", "F")] == mpfexp.ls( 312 | True, True, True 313 | ) 314 | 315 | mpfexp.rm("file1") 316 | mpfexp.rm("file2") 317 | mpfexp.cd("subdir1") 318 | mpfexp.cd("..") 319 | mpfexp.rm("subdir1") 320 | 321 | assert [] == mpfexp.ls(True, True, True) 322 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | (unreleased) 6 | ------------ 7 | 8 | Fix 9 | ~~~ 10 | - Prevent cmd exiting after a successful connection (#107) [hyx0329] 11 | 12 | This commit fix #103 which causes the command line to exit after successfully 13 | opening a connection. 14 | - Running scripts in Windows (#94) [Nicola N] 15 | 16 | Trying to run a script when inside an Anaconda/Miniconda virtual environment on windows10, the script would refuse to run. 17 | This tweak made it behave. 18 | I'm not sure if there are other consequences to this inclusion though: it might break under some other virtual environment, or non-virtual environment. 19 | - Fix window drive path match (#77) [Juwan] 20 | 21 | allow open in C: to execfile .py in D: . 22 | - Update prompt path after return from REPL (#88) [Thomas Friebel] 23 | 24 | * fix: Update prompt path after return from REPL 25 | 26 | When returning from REPL the file explorer is reinitialized since 27 | filesystem contents on the board may have changed. This also means that 28 | the current directory has been reset to /. Update prompt path to display 29 | the new path. 30 | 31 | * Restore current path after REPL 32 | 33 | Without this change, the device will be in the root directory after 34 | REPL. Store the current path before going into REPL and try to cd back 35 | into that path after. 36 | - Remove second argument from on_error and on_message in conwebsock.py 37 | to make ws connections work again (#87) [xnorbt] 38 | - Clean up readme (#84) [Matt Nicholas] 39 | - Fix posix-style paths from Windows (#72) [zouden] 40 | - Fix win10 console out garbled code when used 41 | up/down/left/right/tab.... (#67) [Juwan] 42 | - Add small sleep to allow for board reset time (#64) [Anthony Elder] 43 | - Provide mpfshell entrypoint instead of mpfshell script. (#63) [Stefan 44 | Lehmann] 45 | 46 | * Provide mpfshell entrypoint instead of mpfshell script. 47 | 48 | * Fix conflict. 49 | 50 | * Remove script 51 | 52 | Other 53 | ~~~~~ 54 | - Feat: Autodetect port (#109) [karl] 55 | 56 | resolves https://github.com/wendlers/mpfshell/issues/82 57 | - Release v0.9.3. [Stavros Korokithakis] 58 | - Fix #97 (#101) [2e0byo] 59 | 60 | * typo 61 | 62 | * Revert "fix: Remove second argument from on_error and on_message in conwebsock.py to make ws connections work again (#87)" 63 | 64 | This reverts commit 378d24d2b85d0dca856aec3600d1dc176a6738b0. 65 | 66 | * use uos 67 | 68 | * os is not imported yet! 69 | - Upgrade and run pre-commit. [Stavros Korokithakis] 70 | - Bump version. [Stavros Korokithakis] 71 | - Reformat the code. [Stavros Korokithakis] 72 | - Run pre-commit. [Stavros Korokithakis] 73 | - Feat: Exit with return code 1 if open fails (#93) [Thomas Friebel] 74 | - Fixed crash of "exec code_with_mistakes" (#92) [spinab] 75 | - Performance improvement: Will not call sleep() at the last cycle. 76 | (#89) [Hans Maerki, Hans Märki] 77 | 78 | * Performance improvement: Will not call sleep() at the last cycle. 79 | 80 | * Update pyboard.py 81 | - 0.9.1. [semantic-release] 82 | 83 | Automatically generated by python-semantic-release 84 | - Upgrade pyserial. [Stavros Korokithakis] 85 | - Fall back to os if uos does not exist. [Stavros Korokithakis] 86 | - 0.9.0. [semantic-release] 87 | 88 | Automatically generated by python-semantic-release 89 | - Revert version in preparation for release. [Stavros Korokithakis] 90 | - Fix semantic-release config. [Stavros Korokithakis] 91 | - Add semantic-release config. [Stavros Korokithakis] 92 | - Add PyPI badge. [Stavros Korokithakis] 93 | - Update Code Shelter badge to the right URL. [Stavros Korokithakis] 94 | - Update Code Shelter badge. [Stavros Korokithakis] 95 | - Massively improve caching for all commands. [Stavros Korokithakis] 96 | - Add feature to README. [Stavros Korokithakis] 97 | - Bump the version in preparation for a release. [Stavros Korokithakis] 98 | - Pass arguments the right way when running mpy_cross with a 99 | destination. [Stavros Korokithakis] 100 | - Update README.md. [Stavros Korokithakis] 101 | - Clear the file cache when returning from the REPL (fixes #36) [Stavros 102 | Korokithakis] 103 | - Clarify authentication details. [Stavros Korokithakis] 104 | - Always close open file descriptors (fixes #49) [Stavros Korokithakis] 105 | - Don't wait so long to connect (fixes #47) [Stavros Korokithakis] 106 | - Add the "putc" command. [Stavros Korokithakis] 107 | - Remove trailing whitespace. [Stavros Korokithakis] 108 | - Update README.md (#52) [Yi Liu] 109 | - Add pre-commit checks and fix their errors. [Stavros Korokithakis] 110 | - Use "uos" instead of "os" for the module name. [Stavros Korokithakis] 111 | - Merge pull request #56 from torntrousers/fix-for-circuitpython. 112 | [Stavros Korokithakis] 113 | 114 | Import binascii for CircuitPython 115 | - Import binascii for CircuitPython. [Anthony Elder] 116 | - Merge pull request #41 from tori3852/patch-1. [Stefan Wendler] 117 | 118 | Fix spelling note recursive->not recursive 119 | - Fix spelling note recursive->not recursive. [Tomas R] 120 | 121 | 122 | 0.8.1 (2017-08-07) 123 | ------------------ 124 | - Preparing for PyPi. [Stefan Wendler] 125 | - Added meta information. [Stefan Wendler] 126 | - Updated venv section. [Stefan Wendler] 127 | - Set version to 0.8.1. [Stefan Wendler] 128 | - Merge pull request #39 from bartfeenstra/requirements. [Stefan 129 | Wendler] 130 | 131 | Add a Pip requirements file. 132 | - Simplify the installation instructions. [Bart Feenstra] 133 | - Update the Pip requirements to reflect those in the documentation. 134 | [Bart Feenstra] 135 | - Add a Pip requirements file. [Bart Feenstra] 136 | - Merge pull request #35 from fstengel/master. [Stefan Wendler] 137 | 138 | Add command line arguments 139 | - Add command line arguments. [Frank STENGEL] 140 | 141 | Add two new arguments: 142 | * One positionnal: board 143 | * -o --open BOARD 144 | 145 | with the same effect: directly opening board. 146 | - Merge pull request #38 from bartfeenstra/readme-typo. [Stefan Wendler] 147 | 148 | Fixed a typo in the README. 149 | - Fixed a typo in the README. [Bart Feenstra] 150 | - Fix for newer WiPy fw versions to make ls work again. [Stefan Wendler] 151 | - Fixed bug for directory removal with newer MP versions fixed bug in 152 | caching for newly created directories. [Stefan Wendler] 153 | - Corrected setup.py. [Stefan Wendler] 154 | - Corrected setup.py. [Stefan Wendler] 155 | - Merge branch 'master' into master. [Stefan Wendler] 156 | - Small changes on merge. [Stefan Wendler] 157 | - Merge branch 'master' of https://github.com/mhoffma/mpfshell. [Stefan 158 | Wendler] 159 | - Ask platform where we are in FS don't assume / [marc hoffman] 160 | - New version of esp2866 use vfat mounted at /flash not / [marc hoffman] 161 | - Alows simple telnet no user,password. [marc hoffman] 162 | - Merge branch 'couven92-colorama-fix-wendlers' [Stefan Wendler] 163 | - Raw input deisabled for Windows. [Stefan Wendler] 164 | - Colorama initialised first. [Fredrik Høisæther Rasch] 165 | 166 | Command-Prompt REPL initialised with color-supported stdout wrapper 167 | 168 | Prevent Command-Prompt REPL from using the raw_input as it does not use the specified stdout to display prompt text. 169 | - Ask platform where we are in FS don't assume / [marc hoffman] 170 | - Start out in directory /flash rather than in "/" to be more user- 171 | friendly on >=micropython-1.8.7. [Anton Kindestam] 172 | 173 | Warning: probably breaks compatibility, or at least makes the user 174 | experience uncomfortable, when used with older versions of micropython! 175 | - Also handle ENODEV and EINVAL in the same way we handle ENOENT. [Anton 176 | Kindestam] 177 | 178 | In micropython-1.8.7 files on flash lie in /flash/ and / contains 179 | different mounts. If you try to create files, and other operations here 180 | you will get ENODEV. Also you might bump into EINVAL if you try to read 181 | a directory like a text file. 182 | - Fix bug which could result in double '//' being sent to mpy side 183 | os.listdir call. [Anton Kindestam] 184 | - Replace unsafe eval call with ast.literal_eval. [Anton Kindestam] 185 | 186 | An unsafe eval call was used to read a list-structure returned from the 187 | MicroPython client. This means that the client is able to execute 188 | arbitrary code on the host (connecting/PC side). Using ast.literal_eval 189 | instead should be safe. 190 | - Merge pull request #16 from GHPS/master. [Stefan Wendler] 191 | 192 | Fixed crash of exec command in Python 3.5 193 | - Fixed crash of exec command in Python 3.5. [GHPS] 194 | 195 | In Python 3.5 the exec function (e.g. exec print(1+2)) fails with "a bytes-like object is required, not 'str'" and crashes Mpfshell. The crash is caused by data_consumer - the type of "data". 196 | - Posibility to hard-reset device when connected through serial-line 197 | (using comandline switch --reset). [Stefan Wendler] 198 | - Posibility to hard-reset device when connected through serial-line 199 | (using comandline switch --reset). [Stefan Wendler] 200 | - Posibility to hard-reset device when connected through serial-line 201 | (using comandline switch --reset). [Stefan Wendler] 202 | - Added logging of serial messages for level DEBUG. [Stefan Wendler] 203 | - Added chapter about running in a virtual environment. [Stefan Wendler] 204 | - Removed known issue for REPL (since it seams to be gone). [Stefan 205 | Wendler] 206 | - Don't reset (Ctrl-D) board when connecting through serial line. When 207 | using "cat" with a binary file, print simple hexdump. Command "mpyc" 208 | to bytecompile (local) python files. [Stefan Wendler] 209 | - Allow quoted strings to support file/directory names with whitespaces 210 | (Fixes #13). Fixed behavior of "cat" to not hang on binary files 211 | (Fixes #12). [Stefan Wendler] 212 | - Enabled caching by default. [Stefan Wendler] 213 | - Fixed caching issues. Caching variant now passes test-suit. [Stefan 214 | Wendler] 215 | - Minor changes. [Stefan Wendler] 216 | - Added basic logging (enable with command-line-args '--logfile ', 217 | set level with '--loglevel '. Fixes #10. [Stefan Wendler] 218 | - Minor cleanups. [Stefan Wendler] 219 | - Made websocket based read blocking to avoid high CPU usage on REPL. 220 | Fixes #9. [Stefan Wendler] 221 | - If mput/mget receives a wron regular expression, re throws an 222 | exception. This exception is now caught and a error message is given 223 | (instead of terminating the whole shell). Fixes #8. [Stefan Wendler] 224 | - Added note about old PySerial versions. [Stefan Wendler] 225 | - Merge branch 'master' of github.com:wendlers/mpfshell. [Stefan 226 | Wendler] 227 | - Reworked error reporting and error handling (added retry). Added more 228 | tests. [Stefan Wendler] 229 | - Refuse to start REPL on PySerial < 2.7. [Stefan Wendler] 230 | - Cleaned up header comments, started adding of retry function. [Stefan 231 | Wendler] 232 | - Added more usage notes. [Stefan Wendler] 233 | - Basic testing (only runs against EPS8266 yet). [Stefan Wendler] 234 | - Started pytest based testcases. [Stefan Wendler] 235 | - Fixed bug in caching (duplicate entries after put). [Stefan Wendler] 236 | - On Windows to exit REPL CTR+Q is used. [Stefan Wendler] 237 | - Fix to make script support work on python 2 and 3. [Stefan Wendler] 238 | - Support Windows serial port namings (COMx) which fixes #6. Also 239 | implemented a work-around which made Miniterm on Windows bomb-out when 240 | recreating an instance, this fixes #5. [Stefan Wendler] 241 | - Added basic caching for file and directory names. This minimizes 242 | communications (especially for tab-completion), and speeds up things a 243 | lot. Caching could always beeing disabled by the flag "--nocache" on 244 | the commandline. [Stefan Wendler] 245 | - Now, PySerial starting at version >= 2.7 is OK. [Stefan Wendler] 246 | - Wrapper to handle PySerial 2.7 and PySerial 3.0 differences for 247 | Miniterm. Fixes #2. [Stefan Wendler] 248 | - Added Python 3 support (tool now supports Python >= 2.7 AND Python >= 249 | 3.4). [Stefan Wendler] 250 | - PWD now gives proper error message when not connected. Fixes #4. 251 | [Stefan Wendler] 252 | - The shell script now explicitely uses python2 as a command. [Stefan 253 | Wendler] 254 | - Check version of PySerial, only allow REPL for version 3.x. [Stefan 255 | Wendler] 256 | - Merge branch 'master' of github.com:wendlers/mpfshell. [Stefan 257 | Wendler] 258 | - Merge pull request #1 from sebastienkrut/patch-1. [Stefan Wendler] 259 | 260 | Update README.md 261 | - Update README.md. [Sebastien KRUT] 262 | - Color is now optional (use -o flag). [Stefan Wendler] 263 | - Added note about command history. [Stefan Wendler] 264 | - Added note about tab-completion. [Stefan Wendler] 265 | - Updated screenshot. [Stefan Wendler] 266 | - Added basic support for connecting to ESP via websockets. [Stefan 267 | Wendler] 268 | - Added note about integrated REPL. [Stefan Wendler] 269 | - Added not about scriptability. [Stefan Wendler] 270 | - Added screenshot. [Stefan Wendler] 271 | - Added list of main featues. [Stefan Wendler] 272 | - Refactored the why connections are handled to allow different kind of 273 | connections. Thus, it is now possible to connect to the WiPi also via 274 | telnet. [Stefan Wendler] 275 | - Fixed typo. [Stefan Wendler] 276 | - Removed shortcut to "mpfmount". [Stefan Wendler] 277 | - All files are now up-/downloaded in binary. Thus, binary file transfer 278 | is now possible, and thus, one could upload pre-compiled MP files. 279 | [Stefan Wendler] 280 | - Removed the FUSE part, since I did not use it, and thus did not 281 | maintain it. [Stefan Wendler] 282 | - Removed the STA config stuff again since it is hard to handle the 283 | (debug) responses right now. [Stefan Wendler] 284 | - Added first version of WiFi management command to configure STA. 285 | [Stefan Wendler] 286 | - Updated setup instructions. [Stefan Wendler] 287 | - Updated README. [Stefan Wendler] 288 | - Updated README. [Stefan Wendler] 289 | - Now using PySerial 3.0. [Stefan Wendler] 290 | - Fixed typo. [Stefan Wendler] 291 | - Added more TAB-completion, added commands "mget", "mput", "mrm" to 292 | operate on multiple files. [Stefan Wendler] 293 | - Added basic tab-completion for cd, get, put, rm, cat and lcd. Added 294 | different coloring for directories and files on "ls". [Stefan Wendler] 295 | - Added basic support for directory operations on remote. [Stefan 296 | Wendler] 297 | - Added setup.py, updated documentation. [Stefan Wendler] 298 | - Added ability to script from commandline or file. Added color with 299 | colorama. [Stefan Wendler] 300 | - Added terminal to REPL. [Stefan Wendler] 301 | - Added cat and exec command. [Stefan Wendler] 302 | - Layout. [Stefan Wendler] 303 | - Changed title. [Stefan Wendler] 304 | - Fixed typos. [Stefan Wendler] 305 | - Merge remote-tracking branch 'origin/master' [Stefan Wendler] 306 | 307 | # Conflicts: 308 | # README.md 309 | - Update README.md. [Stefan Wendler] 310 | - Reworked the FUSE fs for MP. [Stefan Wendler] 311 | - Added docs for FUSE mount usage. [Stefan Wendler] 312 | - Added too to mount micropython with FUSE. [Stefan Wendler] 313 | - Factored the file-operations into separate class for better 314 | reusability. [Stefan Wendler] 315 | - Added note on REPL. [Stefan Wendler] 316 | - Added remark about version. [Stefan Wendler] 317 | - Fixed typo. [Stefan Wendler] 318 | - Initial commit. [Stefan Wendler] 319 | 320 | 321 | -------------------------------------------------------------------------------- /mp/mpfexp.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import ast 25 | import binascii 26 | import getpass 27 | import logging 28 | import os 29 | import posixpath # force posix-style slashes 30 | import re 31 | import sre_constants 32 | import subprocess 33 | 34 | from mp.conbase import ConError 35 | from mp.conserial import ConSerial 36 | from mp.contelnet import ConTelnet 37 | from mp.conwebsock import ConWebsock 38 | from mp.pyboard import Pyboard 39 | from mp.pyboard import PyboardError 40 | from mp.retry import retry 41 | 42 | 43 | def _was_file_not_existing(exception): 44 | """ 45 | Helper function used to check for ENOENT (file doesn't exist), 46 | ENODEV (device doesn't exist, but handled in the same way) or 47 | EINVAL errors in an exception. Treat them all the same for the 48 | time being. TODO: improve and nuance. 49 | 50 | :param exception: exception to examine 51 | :return: True if non-existing 52 | """ 53 | 54 | stre = str(exception) 55 | return any(err in stre for err in ("ENOENT", "ENODEV", "EINVAL", "OSError:")) 56 | 57 | 58 | class RemoteIOError(IOError): 59 | pass 60 | 61 | 62 | class MpFileExplorer(Pyboard): 63 | 64 | BIN_CHUNK_SIZE = 64 65 | MAX_TRIES = 3 66 | 67 | def __init__(self, constr, reset=False): 68 | """ 69 | Supports the following connection strings. 70 | 71 | ser:/dev/ttyUSB1, 72 | tn:192.168.1.101,, 73 | ws:192.168.1.102, 74 | 75 | :param constr: Connection string as defined above. 76 | """ 77 | 78 | self.reset = reset 79 | 80 | try: 81 | Pyboard.__init__(self, self.__con_from_str(constr)) 82 | except Exception as e: 83 | raise ConError(e) 84 | 85 | self.dir = None 86 | self.sysname = None 87 | self.setup() 88 | 89 | def __del__(self): 90 | 91 | try: 92 | self.exit_raw_repl() 93 | except: 94 | pass 95 | 96 | try: 97 | self.close() 98 | except: 99 | pass 100 | 101 | def __con_from_str(self, constr): 102 | 103 | con = None 104 | 105 | proto, target = constr.split(":") 106 | params = target.split(",") 107 | 108 | if proto.strip(" ") == "ser": 109 | port = params[0].strip(" ") 110 | 111 | if len(params) > 1: 112 | baudrate = int(params[1].strip(" ")) 113 | else: 114 | baudrate = 115200 115 | 116 | con = ConSerial(port=port, baudrate=baudrate, reset=self.reset) 117 | 118 | elif proto.strip(" ") == "tn": 119 | 120 | host = params[0].strip(" ") 121 | 122 | if len(params) > 1: 123 | login = params[1].strip(" ") 124 | else: 125 | print("") 126 | login = input("telnet login : ") 127 | 128 | if len(params) > 2: 129 | passwd = params[2].strip(" ") 130 | else: 131 | passwd = getpass.getpass("telnet passwd: ") 132 | 133 | # print("telnet connection to: %s, %s, %s" % (host, login, passwd)) 134 | con = ConTelnet(ip=host, user=login, password=passwd) 135 | 136 | elif proto.strip(" ") == "ws": 137 | 138 | host = params[0].strip(" ") 139 | 140 | if len(params) > 1: 141 | passwd = params[1].strip(" ") 142 | else: 143 | passwd = getpass.getpass("webrepl passwd: ") 144 | 145 | con = ConWebsock(host, passwd) 146 | 147 | return con 148 | 149 | def _fqn(self, name): 150 | return posixpath.join(self.dir, name) 151 | 152 | def __set_sysname(self): 153 | self.sysname = self.eval("uos.uname()[0]").decode("utf-8") 154 | 155 | def close(self): 156 | 157 | Pyboard.close(self) 158 | self.dir = None 159 | 160 | def teardown(self): 161 | 162 | self.exit_raw_repl() 163 | self.sysname = None 164 | 165 | def setup(self): 166 | 167 | self.enter_raw_repl() 168 | self.exec_( 169 | "try:\n import uos\nexcept ImportError:\n import os as uos\nimport sys" 170 | ) 171 | self.exec_( 172 | "try:\n import ubinascii\nexcept ImportError:\n import binascii as ubinascii" 173 | ) 174 | 175 | # New version mounts files on /flash so lets set dir based on where we are in 176 | # filesystem. 177 | # Using the "path.join" to make sure we get "/" if "os.getcwd" returns "". 178 | self.dir = posixpath.join("/", self.eval("uos.getcwd()").decode("utf8")) 179 | 180 | self.__set_sysname() 181 | 182 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 183 | def ls(self, add_files=True, add_dirs=True, add_details=False): 184 | 185 | files = [] 186 | 187 | try: 188 | res = self.eval("list(uos.ilistdir('%s'))" % self.dir) 189 | except Exception as e: 190 | if _was_file_not_existing(e): 191 | raise RemoteIOError("No such directory: %s" % self.dir) 192 | else: 193 | raise PyboardError(e) 194 | 195 | entries = ast.literal_eval(res.decode("utf-8")) 196 | 197 | if self.sysname == "WiPy" and self.dir == "/": 198 | # Assume everything in the root is a mountpoint and return them as dirs. 199 | if add_details: 200 | return [(entry[0], "D") for entry in entries] 201 | else: 202 | return [entry[0] for entry in entries] 203 | 204 | for entry in entries: 205 | fname, ftype, inode = entry[:3] 206 | fchar = "D" if ftype == 0x4000 else "F" 207 | if not ((fchar == "D" and add_dirs) or (fchar == "F" and add_files)): 208 | continue 209 | 210 | files.append((fname, fchar) if add_details else fname) 211 | 212 | if add_details: 213 | # Sort directories first, then filenames. 214 | return sorted(files, key=lambda x: (x[1], x[0])) 215 | else: 216 | return sorted(files) 217 | 218 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 219 | def rm(self, target): 220 | 221 | try: 222 | # 1st try to delete it as a file 223 | self.eval("uos.remove('%s')" % (self._fqn(target))) 224 | except PyboardError: 225 | try: 226 | # 2nd see if it is empty dir 227 | self.eval("uos.rmdir('%s')" % (self._fqn(target))) 228 | except PyboardError as e: 229 | # 3rd report error if nor successful 230 | if _was_file_not_existing(e): 231 | if self.sysname == "WiPy": 232 | raise RemoteIOError( 233 | "No such file or directory or directory not empty: %s" 234 | % target 235 | ) 236 | else: 237 | raise RemoteIOError("No such file or directory: %s" % target) 238 | elif "EACCES" in str(e): 239 | raise RemoteIOError("Directory not empty: %s" % target) 240 | else: 241 | raise e 242 | 243 | def mrm(self, pat, verbose=False): 244 | 245 | files = self.ls(add_dirs=False) 246 | find = re.compile(pat) 247 | 248 | for f in files: 249 | if find.match(f): 250 | if verbose: 251 | print(" * rm %s" % f) 252 | 253 | self.rm(f) 254 | 255 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 256 | def put(self, src, dst=None): 257 | 258 | f = open(src, "rb") 259 | data = f.read() 260 | f.close() 261 | 262 | if dst is None: 263 | dst = src 264 | 265 | try: 266 | 267 | self.exec_("f = open('%s', 'wb')" % self._fqn(dst)) 268 | 269 | while True: 270 | c = binascii.hexlify(data[: self.BIN_CHUNK_SIZE]) 271 | if not len(c): 272 | break 273 | 274 | self.exec_("f.write(ubinascii.unhexlify('%s'))" % c.decode("utf-8")) 275 | data = data[self.BIN_CHUNK_SIZE :] 276 | 277 | self.exec_("f.close()") 278 | 279 | except PyboardError as e: 280 | if _was_file_not_existing(e): 281 | raise RemoteIOError("Failed to create file: %s" % dst) 282 | elif "EACCES" in str(e): 283 | raise RemoteIOError("Existing directory: %s" % dst) 284 | else: 285 | raise e 286 | 287 | def mput(self, src_dir, pat, verbose=False): 288 | 289 | try: 290 | 291 | find = re.compile(pat) 292 | files = os.listdir(src_dir) 293 | 294 | for f in files: 295 | if os.path.isfile(f) and find.match(f): 296 | if verbose: 297 | print(" * put %s" % f) 298 | 299 | self.put(posixpath.join(src_dir, f), f) 300 | 301 | except sre_constants.error as e: 302 | raise RemoteIOError("Error in regular expression: %s" % e) 303 | 304 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 305 | def get(self, src, dst=None): 306 | 307 | if src not in self.ls(): 308 | raise RemoteIOError("No such file or directory: '%s'" % self._fqn(src)) 309 | 310 | if dst is None: 311 | dst = src 312 | 313 | f = open(dst, "wb") 314 | 315 | try: 316 | 317 | self.exec_("f = open('%s', 'rb')" % self._fqn(src)) 318 | ret = self.exec_( 319 | "while True:\r\n" 320 | " c = ubinascii.hexlify(f.read(%s))\r\n" 321 | " if not len(c):\r\n" 322 | " break\r\n" 323 | " sys.stdout.write(c)\r\n" % self.BIN_CHUNK_SIZE 324 | ) 325 | 326 | self.exec_("f.close()") 327 | 328 | except PyboardError as e: 329 | if _was_file_not_existing(e): 330 | raise RemoteIOError("Failed to read file: %s" % src) 331 | else: 332 | raise e 333 | 334 | f.write(binascii.unhexlify(ret)) 335 | f.close() 336 | 337 | def mget(self, dst_dir, pat, verbose=False): 338 | 339 | try: 340 | 341 | files = self.ls(add_dirs=False) 342 | find = re.compile(pat) 343 | 344 | for f in files: 345 | if find.match(f): 346 | if verbose: 347 | print(" * get %s" % f) 348 | 349 | self.get(f, dst=posixpath.join(dst_dir, f)) 350 | 351 | except sre_constants.error as e: 352 | raise RemoteIOError("Error in regular expression: %s" % e) 353 | 354 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 355 | def gets(self, src): 356 | 357 | try: 358 | 359 | self.exec_("f = open('%s', 'rb')" % self._fqn(src)) 360 | ret = self.exec_( 361 | "while True:\r\n" 362 | " c = ubinascii.hexlify(f.read(%s))\r\n" 363 | " if not len(c):\r\n" 364 | " break\r\n" 365 | " sys.stdout.write(c)\r\n" % self.BIN_CHUNK_SIZE 366 | ) 367 | 368 | self.exec_("f.close()") 369 | 370 | except PyboardError as e: 371 | if _was_file_not_existing(e): 372 | raise RemoteIOError("Failed to read file: %s" % src) 373 | else: 374 | raise e 375 | 376 | try: 377 | 378 | return binascii.unhexlify(ret).decode("utf-8") 379 | 380 | except UnicodeDecodeError: 381 | 382 | s = ret.decode("utf-8") 383 | fs = "\nBinary file:\n\n" 384 | 385 | while len(s): 386 | fs += s[:64] + "\n" 387 | s = s[64:] 388 | 389 | return fs 390 | 391 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 392 | def puts(self, dst, lines): 393 | 394 | try: 395 | 396 | data = lines.encode("utf-8") 397 | 398 | self.exec_("f = open('%s', 'wb')" % self._fqn(dst)) 399 | 400 | while True: 401 | c = binascii.hexlify(data[: self.BIN_CHUNK_SIZE]) 402 | if not len(c): 403 | break 404 | 405 | self.exec_("f.write(ubinascii.unhexlify('%s'))" % c.decode("utf-8")) 406 | data = data[self.BIN_CHUNK_SIZE :] 407 | 408 | self.exec_("f.close()") 409 | 410 | except PyboardError as e: 411 | if _was_file_not_existing(e): 412 | raise RemoteIOError("Failed to create file: %s" % dst) 413 | elif "EACCES" in str(e): 414 | raise RemoteIOError("Existing directory: %s" % dst) 415 | else: 416 | raise e 417 | 418 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 419 | def cd(self, target): 420 | 421 | if target.startswith("/"): 422 | tmp_dir = target 423 | elif target == "..": 424 | tmp_dir, _ = os.path.split(self.dir) 425 | else: 426 | tmp_dir = self._fqn(target) 427 | 428 | # see if the new dir exists 429 | try: 430 | 431 | self.eval("uos.listdir('%s')" % tmp_dir) 432 | self.dir = tmp_dir 433 | 434 | except PyboardError as e: 435 | if _was_file_not_existing(e): 436 | raise RemoteIOError("No such directory: %s" % target) 437 | else: 438 | raise e 439 | 440 | def pwd(self): 441 | return self.dir 442 | 443 | @retry(PyboardError, tries=MAX_TRIES, delay=1, backoff=2, logger=logging.root) 444 | def md(self, target): 445 | 446 | try: 447 | 448 | self.eval("uos.mkdir('%s')" % self._fqn(target)) 449 | 450 | except PyboardError as e: 451 | if _was_file_not_existing(e): 452 | raise RemoteIOError("Invalid directory name: %s" % target) 453 | elif "EEXIST" in str(e): 454 | raise RemoteIOError("File or directory exists: %s" % target) 455 | else: 456 | raise e 457 | 458 | def mpy_cross(self, src, dst=None): 459 | 460 | if dst is None: 461 | return_code = subprocess.call("mpy-cross %s" % (src), shell=True) 462 | else: 463 | return_code = subprocess.call("mpy-cross -o %s %s" % (dst, src), shell=True) 464 | 465 | if return_code != 0: 466 | raise IOError("Failed to compile: %s" % src) 467 | 468 | 469 | class MpFileExplorerCaching(MpFileExplorer): 470 | def __init__(self, constr, reset=False): 471 | MpFileExplorer.__init__(self, constr, reset) 472 | 473 | self.cache = {} 474 | 475 | def __cache(self, path, data): 476 | 477 | logging.debug("caching '%s': %s" % (path, data)) 478 | self.cache[path] = data 479 | 480 | def __cache_hit(self, path): 481 | 482 | if path in self.cache: 483 | logging.debug("cache hit for '%s': %s" % (path, self.cache[path])) 484 | return self.cache[path] 485 | 486 | return None 487 | 488 | def ls(self, add_files=True, add_dirs=True, add_details=False): 489 | 490 | hit = self.__cache_hit(self.dir) 491 | 492 | if hit is None: 493 | hit = MpFileExplorer.ls(self, True, True, True) 494 | self.__cache(self.dir, hit) 495 | 496 | files = [ 497 | f 498 | for f in hit 499 | if ((add_files and f[1] == "F") or (add_dirs and f[1] == "D")) 500 | ] 501 | files.sort(key=lambda x: (x[1], x[0])) 502 | if not add_details: 503 | files = [f[0] for f in files] 504 | return files 505 | 506 | def put(self, src, dst=None): 507 | 508 | MpFileExplorer.put(self, src, dst) 509 | 510 | if dst is None: 511 | dst = src 512 | 513 | path = os.path.split(self._fqn(dst)) 514 | newitm = path[-1] 515 | parent = path[:-1][0] 516 | 517 | hit = self.__cache_hit(parent) 518 | 519 | if hit is not None: 520 | if (dst, "F") not in hit: 521 | self.__cache(parent, hit + [(newitm, "F")]) 522 | 523 | def puts(self, dst, lines): 524 | 525 | MpFileExplorer.puts(self, dst, lines) 526 | 527 | path = os.path.split(self._fqn(dst)) 528 | newitm = path[-1] 529 | parent = path[:-1][0] 530 | 531 | hit = self.__cache_hit(parent) 532 | 533 | if hit is not None: 534 | if (dst, "F") not in hit: 535 | self.__cache(parent, hit + [(newitm, "F")]) 536 | 537 | def md(self, dir): 538 | 539 | MpFileExplorer.md(self, dir) 540 | 541 | path = os.path.split(self._fqn(dir)) 542 | newitm = path[-1] 543 | parent = path[:-1][0] 544 | 545 | hit = self.__cache_hit(parent) 546 | 547 | if hit is not None: 548 | if (dir, "D") not in hit: 549 | self.__cache(parent, hit + [(newitm, "D")]) 550 | 551 | def rm(self, target): 552 | 553 | MpFileExplorer.rm(self, target) 554 | 555 | path = os.path.split(self._fqn(target)) 556 | rmitm = path[-1] 557 | parent = path[:-1][0] 558 | 559 | hit = self.__cache_hit(parent) 560 | 561 | if hit is not None: 562 | files = [] 563 | 564 | for f in hit: 565 | if f[0] != rmitm: 566 | files.append(f) 567 | 568 | self.__cache(parent, files) 569 | -------------------------------------------------------------------------------- /mp/mpfshell.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2016 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | import argparse 25 | import binascii 26 | import cmd 27 | import glob 28 | import io 29 | import json 30 | import logging 31 | import os 32 | import platform 33 | import sys 34 | import tempfile 35 | 36 | import colorama 37 | import serial 38 | from serial.tools.list_ports import comports 39 | 40 | from mp import version 41 | from mp.conbase import ConError 42 | from mp.mpfexp import MpFileExplorer 43 | from mp.mpfexp import MpFileExplorerCaching 44 | from mp.mpfexp import RemoteIOError 45 | from mp.pyboard import PyboardError 46 | from mp.tokenizer import Tokenizer 47 | 48 | 49 | class MpFileShell(cmd.Cmd): 50 | def __init__(self, color=False, caching=False, reset=False): 51 | if color: 52 | colorama.init() 53 | cmd.Cmd.__init__(self, stdout=colorama.initialise.wrapped_stdout) 54 | else: 55 | cmd.Cmd.__init__(self) 56 | 57 | if platform.system() == "Windows": 58 | self.use_rawinput = False 59 | 60 | self.color = color 61 | self.caching = caching 62 | self.reset = reset 63 | 64 | self.fe = None 65 | self.repl = None 66 | self.tokenizer = Tokenizer() 67 | 68 | self.__intro() 69 | self.__set_prompt_path() 70 | 71 | def __del__(self): 72 | self.__disconnect() 73 | 74 | def __intro(self): 75 | if self.color: 76 | self.intro = ( 77 | "\n" 78 | + colorama.Fore.GREEN 79 | + "** Micropython File Shell v%s, sw@kaltpost.de ** " % version.FULL 80 | + colorama.Fore.RESET 81 | + "\n" 82 | ) 83 | else: 84 | self.intro = ( 85 | "\n** Micropython File Shell v%s, sw@kaltpost.de **\n" % version.FULL 86 | ) 87 | 88 | self.intro += "-- Running on Python %d.%d using PySerial %s --\n" % ( 89 | sys.version_info[0], 90 | sys.version_info[1], 91 | serial.VERSION, 92 | ) 93 | 94 | def __set_prompt_path(self): 95 | if self.fe is not None: 96 | pwd = self.fe.pwd() 97 | else: 98 | pwd = "/" 99 | 100 | if self.color: 101 | self.prompt = ( 102 | colorama.Fore.BLUE 103 | + "mpfs [" 104 | + colorama.Fore.YELLOW 105 | + pwd 106 | + colorama.Fore.BLUE 107 | + "]> " 108 | + colorama.Fore.RESET 109 | ) 110 | else: 111 | self.prompt = "mpfs [" + pwd + "]> " 112 | 113 | def __error(self, msg): 114 | if self.color: 115 | print("\n" + colorama.Fore.RED + msg + colorama.Fore.RESET + "\n") 116 | else: 117 | print("\n" + msg + "\n") 118 | 119 | def __connect(self, port): 120 | try: 121 | self.__disconnect() 122 | 123 | if self.reset: 124 | print("Hard resetting device ...") 125 | if self.caching: 126 | self.fe = MpFileExplorerCaching(port, self.reset) 127 | else: 128 | self.fe = MpFileExplorer(port, self.reset) 129 | print("Connected to %s" % self.fe.sysname) 130 | self.__set_prompt_path() 131 | return True 132 | except PyboardError as e: 133 | logging.error(e) 134 | self.__error(str(e)) 135 | except ConError as e: 136 | logging.error(e) 137 | self.__error("Failed to open: %s" % port) 138 | except AttributeError as e: 139 | logging.error(e) 140 | self.__error("Failed to open: %s" % port) 141 | return False 142 | 143 | def __disconnect(self): 144 | if self.fe is not None: 145 | try: 146 | self.fe.close() 147 | self.fe = None 148 | self.__set_prompt_path() 149 | except RemoteIOError as e: 150 | self.__error(str(e)) 151 | 152 | def __is_open(self): 153 | if self.fe is None: 154 | self.__error("Not connected to device. Use 'open' first.") 155 | return False 156 | 157 | return True 158 | 159 | def __parse_file_names(self, args): 160 | tokens, rest = self.tokenizer.tokenize(args) 161 | 162 | if rest != "": 163 | self.__error("Invalid filename given: %s" % rest) 164 | else: 165 | return [token.value for token in tokens] 166 | 167 | return None 168 | 169 | def postcmd(self, stop, line): 170 | # keep the shell open until manually exited 171 | if line.startswith("exit") or line.startswith("EOF"): 172 | return True 173 | return False 174 | 175 | def do_exit(self, args): 176 | """exit 177 | Exit this shell. 178 | """ 179 | self.__disconnect() 180 | 181 | return True 182 | 183 | do_EOF = do_exit 184 | 185 | def do_open(self, args): 186 | """open 187 | Open connection to device with given target. TARGET might be: 188 | 189 | - a serial port, e.g. ttyUSB0, ser:/dev/ttyUSB0 190 | - a telnet host, e.g tn:192.168.1.1 or tn:192.168.1.1,login,passwd 191 | - a websocket host, e.g. ws:192.168.1.1 or ws:192.168.1.1,passwd 192 | """ 193 | 194 | if not len(args): 195 | self.__error("Missing argument: ") 196 | return False 197 | 198 | if ( 199 | not args.startswith("ser:/dev/") 200 | and not args.startswith("ser:COM") 201 | and not args.startswith("tn:") 202 | and not args.startswith("ws:") 203 | ): 204 | if platform.system() == "Windows": 205 | args = "ser:" + args 206 | else: 207 | args = "ser:/dev/" + args 208 | 209 | return self.__connect(args) 210 | 211 | def complete_open(self, *args): 212 | ports = glob.glob("/dev/ttyUSB*") + glob.glob("/dev/ttyACM*") 213 | return [i[5:] for i in ports if i[5:].startswith(args[0])] 214 | 215 | def do_close(self, args): 216 | """close 217 | Close connection to device. 218 | """ 219 | 220 | self.__disconnect() 221 | 222 | def do_ls(self, args): 223 | """ls 224 | List remote files. 225 | """ 226 | 227 | if self.__is_open(): 228 | try: 229 | files = self.fe.ls(add_details=True) 230 | 231 | if self.fe.pwd() != "/": 232 | files = [("..", "D")] + files 233 | 234 | print("\nRemote files in '%s':\n" % self.fe.pwd()) 235 | 236 | for elem, type in files: 237 | if type == "F": 238 | if self.color: 239 | print( 240 | colorama.Fore.CYAN 241 | + (" %s" % elem) 242 | + colorama.Fore.RESET 243 | ) 244 | else: 245 | print(" %s" % elem) 246 | else: 247 | if self.color: 248 | print( 249 | colorama.Fore.MAGENTA 250 | + (" %s" % elem) 251 | + colorama.Fore.RESET 252 | ) 253 | else: 254 | print(" %s" % elem) 255 | 256 | print("") 257 | 258 | except IOError as e: 259 | self.__error(str(e)) 260 | 261 | def do_pwd(self, args): 262 | """pwd 263 | Print current remote directory. 264 | """ 265 | if self.__is_open(): 266 | print(self.fe.pwd()) 267 | 268 | def do_cd(self, args): 269 | """cd 270 | Change current remote directory to given target. 271 | """ 272 | if not len(args): 273 | self.__error("Missing argument: ") 274 | elif self.__is_open(): 275 | try: 276 | s_args = self.__parse_file_names(args) 277 | if not s_args: 278 | return 279 | elif len(s_args) > 1: 280 | self.__error("Only one argument allowed: ") 281 | return 282 | 283 | self.fe.cd(s_args[0]) 284 | self.__set_prompt_path() 285 | except IOError as e: 286 | self.__error(str(e)) 287 | 288 | def complete_cd(self, *args): 289 | try: 290 | files = self.fe.ls(add_files=False) 291 | except Exception: 292 | files = [] 293 | 294 | return [i for i in files if i.startswith(args[0])] 295 | 296 | def do_md(self, args): 297 | """md 298 | Create new remote directory. 299 | """ 300 | if not len(args): 301 | self.__error("Missing argument: ") 302 | elif self.__is_open(): 303 | try: 304 | s_args = self.__parse_file_names(args) 305 | if not s_args: 306 | return 307 | elif len(s_args) > 1: 308 | self.__error("Only one argument allowed: ") 309 | return 310 | 311 | self.fe.md(s_args[0]) 312 | except IOError as e: 313 | self.__error(str(e)) 314 | 315 | def do_lls(self, args): 316 | """lls 317 | List files in current local directory. 318 | """ 319 | 320 | files = os.listdir(".") 321 | 322 | print("\nLocal files:\n") 323 | 324 | for f in files: 325 | if os.path.isdir(f): 326 | if self.color: 327 | print( 328 | colorama.Fore.MAGENTA + (" %s" % f) + colorama.Fore.RESET 329 | ) 330 | else: 331 | print(" %s" % f) 332 | for f in files: 333 | if os.path.isfile(f): 334 | if self.color: 335 | print(colorama.Fore.CYAN + (" %s" % f) + colorama.Fore.RESET) 336 | else: 337 | print(" %s" % f) 338 | print("") 339 | 340 | def do_lcd(self, args): 341 | """lcd 342 | Change current local directory to given target. 343 | """ 344 | 345 | if not len(args): 346 | self.__error("Missing argument: ") 347 | else: 348 | try: 349 | s_args = self.__parse_file_names(args) 350 | if not s_args: 351 | return 352 | elif len(s_args) > 1: 353 | self.__error("Only one argument allowed: ") 354 | return 355 | 356 | os.chdir(s_args[0]) 357 | except OSError as e: 358 | self.__error(str(e).split("] ")[-1]) 359 | 360 | def complete_lcd(self, *args): 361 | dirs = [o for o in os.listdir(".") if os.path.isdir(os.path.join(".", o))] 362 | return [i for i in dirs if i.startswith(args[0])] 363 | 364 | def do_lpwd(self, args): 365 | """lpwd 366 | Print current local directory. 367 | """ 368 | 369 | print(os.getcwd()) 370 | 371 | def do_put(self, args): 372 | """put [] 373 | Upload local file. If the second parameter is given, 374 | its value is used for the remote file name. Otherwise the 375 | remote file will be named the same as the local file. 376 | """ 377 | 378 | if not len(args): 379 | self.__error("Missing arguments: []") 380 | 381 | elif self.__is_open(): 382 | s_args = self.__parse_file_names(args) 383 | if not s_args: 384 | return 385 | elif len(s_args) > 2: 386 | self.__error( 387 | "Only one ore two arguments allowed: []" 388 | ) 389 | return 390 | 391 | lfile_name = s_args[0] 392 | 393 | if len(s_args) > 1: 394 | rfile_name = s_args[1] 395 | else: 396 | rfile_name = lfile_name 397 | 398 | try: 399 | self.fe.put(lfile_name, rfile_name) 400 | except IOError as e: 401 | self.__error(str(e)) 402 | 403 | def complete_put(self, *args): 404 | files = [o for o in os.listdir(".") if os.path.isfile(os.path.join(".", o))] 405 | return [i for i in files if i.startswith(args[0])] 406 | 407 | def do_mput(self, args): 408 | """mput 409 | Upload all local files that match the given regular expression. 410 | The remote files will be named the same as the local files. 411 | 412 | "mput" does not get directories, and it is not recursive. 413 | """ 414 | 415 | if not len(args): 416 | self.__error("Missing argument: ") 417 | 418 | elif self.__is_open(): 419 | try: 420 | self.fe.mput(os.getcwd(), args, True) 421 | except IOError as e: 422 | self.__error(str(e)) 423 | 424 | def do_get(self, args): 425 | """get [] 426 | Download remote file. If the second parameter is given, 427 | its value is used for the local file name. Otherwise the 428 | locale file will be named the same as the remote file. 429 | """ 430 | 431 | if not len(args): 432 | self.__error("Missing arguments: []") 433 | 434 | elif self.__is_open(): 435 | s_args = self.__parse_file_names(args) 436 | if not s_args: 437 | return 438 | elif len(s_args) > 2: 439 | self.__error( 440 | "Only one ore two arguments allowed: []" 441 | ) 442 | return 443 | 444 | rfile_name = s_args[0] 445 | 446 | if len(s_args) > 1: 447 | lfile_name = s_args[1] 448 | else: 449 | lfile_name = rfile_name 450 | 451 | try: 452 | self.fe.get(rfile_name, lfile_name) 453 | except IOError as e: 454 | self.__error(str(e)) 455 | 456 | def do_mget(self, args): 457 | """mget 458 | Download all remote files that match the given regular expression. 459 | The local files will be named the same as the remote files. 460 | 461 | "mget" does not get directories, and it is not recursive. 462 | """ 463 | 464 | if not len(args): 465 | self.__error("Missing argument: ") 466 | 467 | elif self.__is_open(): 468 | try: 469 | self.fe.mget(os.getcwd(), args, True) 470 | except IOError as e: 471 | self.__error(str(e)) 472 | 473 | def complete_get(self, *args): 474 | try: 475 | files = self.fe.ls(add_dirs=False) 476 | except Exception: 477 | files = [] 478 | 479 | return [i for i in files if i.startswith(args[0])] 480 | 481 | def do_rm(self, args): 482 | """rm 483 | Delete a remote file or directory. 484 | 485 | Note: only empty directories could be removed. 486 | """ 487 | 488 | if not len(args): 489 | self.__error("Missing argument: ") 490 | elif self.__is_open(): 491 | s_args = self.__parse_file_names(args) 492 | if not s_args: 493 | return 494 | elif len(s_args) > 1: 495 | self.__error("Only one argument allowed: ") 496 | return 497 | 498 | try: 499 | self.fe.rm(s_args[0]) 500 | except IOError as e: 501 | self.__error(str(e)) 502 | except PyboardError: 503 | self.__error("Unable to send request to %s" % self.fe.sysname) 504 | 505 | def do_mrm(self, args): 506 | """mrm 507 | Delete all remote files that match the given regular expression. 508 | 509 | "mrm" does not delete directories, and it is not recursive. 510 | """ 511 | 512 | if not len(args): 513 | self.__error("Missing argument: ") 514 | 515 | elif self.__is_open(): 516 | try: 517 | self.fe.mrm(args, True) 518 | except IOError as e: 519 | self.__error(str(e)) 520 | 521 | def complete_rm(self, *args): 522 | try: 523 | files = self.fe.ls() 524 | except Exception: 525 | files = [] 526 | 527 | return [i for i in files if i.startswith(args[0])] 528 | 529 | def do_cat(self, args): 530 | """cat 531 | Print the contents of a remote file. 532 | """ 533 | 534 | if not len(args): 535 | self.__error("Missing argument: ") 536 | elif self.__is_open(): 537 | s_args = self.__parse_file_names(args) 538 | if not s_args: 539 | return 540 | elif len(s_args) > 1: 541 | self.__error("Only one argument allowed: ") 542 | return 543 | 544 | try: 545 | print(self.fe.gets(s_args[0])) 546 | except IOError as e: 547 | self.__error(str(e)) 548 | 549 | complete_cat = complete_get 550 | 551 | def do_exec(self, args): 552 | """exec 553 | Execute a Python statement on remote. 554 | """ 555 | 556 | def data_consumer(data): 557 | # Delete garbage characters, as they can make the connection fail. 558 | data = data.replace(b"\x04", b"") 559 | sys.stdout.buffer.write(data) 560 | sys.stdout.flush() 561 | 562 | if not len(args): 563 | self.__error("Missing argument: ") 564 | elif self.__is_open(): 565 | try: 566 | self.fe.exec_raw_no_follow(args + "\n") 567 | ret = self.fe.follow(None, data_consumer) 568 | 569 | if len(ret[-1]): 570 | self.__error(ret[-1].decode("utf-8")) 571 | 572 | except IOError as e: 573 | self.__error(str(e)) 574 | except PyboardError as e: 575 | self.__error(str(e)) 576 | 577 | def do_repl(self, args): 578 | """repl 579 | Enter Micropython REPL. 580 | """ 581 | 582 | import serial 583 | 584 | ver = serial.VERSION.split(".") 585 | 586 | if int(ver[0]) < 2 or (int(ver[0]) == 2 and int(ver[1]) < 7): 587 | self.__error( 588 | "REPL needs PySerial version >= 2.7, found %s" % serial.VERSION 589 | ) 590 | return 591 | 592 | if self.__is_open(): 593 | if self.repl is None: 594 | from mp.term import Term 595 | 596 | self.repl = Term(self.fe.con) 597 | 598 | if platform.system() == "Windows": 599 | self.repl.exit_character = chr(0x11) 600 | else: 601 | self.repl.exit_character = chr(0x1D) 602 | 603 | self.repl.raw = True 604 | self.repl.set_rx_encoding("UTF-8") 605 | self.repl.set_tx_encoding("UTF-8") 606 | 607 | else: 608 | self.repl.serial = self.fe.con 609 | 610 | pwd = self.fe.pwd() 611 | self.fe.teardown() 612 | self.repl.start() 613 | 614 | if self.repl.exit_character == chr(0x11): 615 | print("\n*** Exit REPL with Ctrl+Q ***") 616 | else: 617 | print("\n*** Exit REPL with Ctrl+] ***") 618 | 619 | try: 620 | self.repl.join(True) 621 | except Exception: 622 | pass 623 | 624 | self.repl.console.cleanup() 625 | 626 | if self.caching: 627 | # Clear the file explorer cache so we can see any new files. 628 | self.fe.cache = {} 629 | 630 | self.fe.setup() 631 | try: 632 | self.fe.cd(pwd) 633 | except RemoteIOError as e: 634 | # Working directory does not exist anymore 635 | self.__error(str(e)) 636 | finally: 637 | self.__set_prompt_path() 638 | print("") 639 | 640 | def do_mpyc(self, args): 641 | """mpyc 642 | Compile a Python file into byte-code by using mpy-cross (which needs to be in the path). 643 | The compiled file has the same name as the original file but with extension '.mpy'. 644 | """ 645 | 646 | if not len(args): 647 | self.__error("Missing argument: ") 648 | else: 649 | s_args = self.__parse_file_names(args) 650 | if not s_args: 651 | return 652 | elif len(s_args) > 1: 653 | self.__error("Only one argument allowed: ") 654 | return 655 | 656 | try: 657 | self.fe.mpy_cross(s_args[0]) 658 | except IOError as e: 659 | self.__error(str(e)) 660 | 661 | def complete_mpyc(self, *args): 662 | files = [ 663 | o 664 | for o in os.listdir(".") 665 | if (os.path.isfile(os.path.join(".", o)) and o.endswith(".py")) 666 | ] 667 | return [i for i in files if i.startswith(args[0])] 668 | 669 | def do_putc(self, args): 670 | """mputc [] 671 | Compile a Python file into byte-code by using mpy-cross (which needs to be in the 672 | path) and upload it. The compiled file has the same name as the original file but 673 | with extension '.mpy' by default. 674 | """ 675 | if not len(args): 676 | self.__error("Missing arguments: []") 677 | 678 | elif self.__is_open(): 679 | s_args = self.__parse_file_names(args) 680 | if not s_args: 681 | return 682 | elif len(s_args) > 2: 683 | self.__error( 684 | "Only one ore two arguments allowed: []" 685 | ) 686 | return 687 | 688 | lfile_name = s_args[0] 689 | 690 | if len(s_args) > 1: 691 | rfile_name = s_args[1] 692 | else: 693 | rfile_name = ( 694 | lfile_name[: lfile_name.rfind(".")] 695 | if "." in lfile_name 696 | else lfile_name 697 | ) + ".mpy" 698 | 699 | _, tmp = tempfile.mkstemp() 700 | 701 | try: 702 | self.fe.mpy_cross(src=lfile_name, dst=tmp) 703 | self.fe.put(tmp, rfile_name) 704 | except IOError as e: 705 | self.__error(str(e)) 706 | 707 | os.unlink(tmp) 708 | 709 | complete_putc = complete_mpyc 710 | 711 | 712 | def get_available_ports(raw=False) -> [str]: 713 | try: 714 | ports = comports() 715 | if raw is False: 716 | connected = sorted(list(map(lambda x: x.device, ports))) 717 | else: 718 | connected = ports 719 | return connected 720 | except: 721 | return [] 722 | 723 | 724 | def ask_device(serial_devices=None) -> str: 725 | if serial_devices is None: 726 | serial_devices = get_available_ports() 727 | dev_cnt = len(serial_devices) 728 | if dev_cnt == 1: 729 | device = serial_devices[0] 730 | elif dev_cnt > 1: 731 | for i, t in enumerate(serial_devices): 732 | print("* " if i == 0 else " ", f"{i}", t) 733 | while True: 734 | n = input("select device [0] ") 735 | try: 736 | n = n.strip() 737 | if len(n) == 0: 738 | n = 0 739 | n = int(n) 740 | if n >= 0 or n < dev_cnt: 741 | break 742 | except: 743 | pass 744 | print("enter a valid number", file=sys.stderr) 745 | device = serial_devices[n] 746 | else: 747 | device = None 748 | return device 749 | 750 | 751 | def main(): 752 | parser = argparse.ArgumentParser() 753 | parser.add_argument( 754 | "-c", 755 | "--command", 756 | help="execute given commands (separated by ;)", 757 | default=None, 758 | nargs="*", 759 | ) 760 | parser.add_argument( 761 | "-s", "--script", help="execute commands from file", default=None 762 | ) 763 | parser.add_argument( 764 | "-n", 765 | "--noninteractive", 766 | help="non interactive mode (don't enter shell)", 767 | action="store_true", 768 | default=False, 769 | ) 770 | 771 | parser.add_argument( 772 | "--nocolor", help="disable color", action="store_true", default=False 773 | ) 774 | parser.add_argument( 775 | "--nocache", help="disable cache", action="store_true", default=False 776 | ) 777 | 778 | parser.add_argument("--logfile", help="write log to file", default=None) 779 | parser.add_argument( 780 | "--loglevel", 781 | help="loglevel (CRITICAL, ERROR, WARNING, INFO, DEBUG)", 782 | default="INFO", 783 | ) 784 | 785 | parser.add_argument( 786 | "--reset", 787 | help="hard reset device via DTR (serial connection only)", 788 | action="store_true", 789 | default=False, 790 | ) 791 | 792 | list_parser = parser.add_mutually_exclusive_group() 793 | list_parser.add_argument( 794 | "-ls", 795 | "--list-dev", 796 | help="list connected devices", 797 | action="store_true", 798 | default=False, 799 | ) 800 | list_parser.add_argument( 801 | "-lsr", 802 | "--list-dev-raw", 803 | help="list connected devices (raw)", 804 | action="store_true", 805 | default=False, 806 | ) 807 | list_parser.add_argument( 808 | "-lsrp", 809 | "--list-dev-raw-pretty", 810 | help="list connected devices pretty (raw)", 811 | action="store_true", 812 | default=False, 813 | ) 814 | 815 | parser.add_argument( 816 | "-i", 817 | "--ask", 818 | help="ask which board to open, or in case just one device is connect open this", 819 | action="store_true", 820 | default=False, 821 | ) 822 | parser.add_argument( 823 | "-o", 824 | "--open", 825 | help="directly opens board", 826 | metavar="board", 827 | action="store", 828 | default=None, 829 | nargs="?", 830 | ) 831 | parser.add_argument( 832 | "board", help="directly opens board", nargs="?", action="store", default=None 833 | ) 834 | 835 | args = parser.parse_args() 836 | 837 | if args.list_dev or args.list_dev_raw or args.list_dev_raw_pretty: 838 | devs = get_available_ports(raw=True) 839 | 840 | if args.list_dev_raw or args.list_dev_raw_pretty: 841 | infolist = [] 842 | for dev in devs: 843 | info = {} 844 | for k in [ 845 | "description", 846 | "device", 847 | "device_path", 848 | "hwid", 849 | "interface", 850 | "location", 851 | "manufacturer", 852 | "name", 853 | "product", 854 | "serial_number", 855 | "subsystem", 856 | "usb_device_path", 857 | "usb_interface_path", 858 | "vid", 859 | ]: 860 | try: 861 | v = getattr(dev, k) 862 | info[k] = v 863 | except: 864 | pass 865 | infolist.append(info) 866 | 867 | if args.list_dev_raw_pretty: 868 | print(json.dumps(infolist, indent=4)) 869 | else: 870 | print(infolist) 871 | else: 872 | for dev in devs: 873 | print(dev.device) 874 | sys.exit(0) 875 | 876 | format = "%(asctime)s\t%(levelname)s\t%(message)s" 877 | 878 | if args.logfile is not None: 879 | logging.basicConfig(format=format, filename=args.logfile, level=args.loglevel) 880 | else: 881 | logging.basicConfig(format=format, level=logging.CRITICAL) 882 | 883 | logging.info("Micropython File Shell v%s started" % version.FULL) 884 | logging.info( 885 | "Running on Python %d.%d using PySerial %s" 886 | % (sys.version_info[0], sys.version_info[1], serial.VERSION) 887 | ) 888 | 889 | mpfs = MpFileShell(not args.nocolor, not args.nocache, args.reset) 890 | 891 | if args.ask: 892 | dev = ask_device() 893 | pos = dev.rfind(os.sep) 894 | if pos >= 0: 895 | # todo 896 | # check on winows and mac platform 897 | dev = dev[pos + 1 :] 898 | args.open = dev 899 | 900 | if args.open is not None: 901 | if args.board is None: 902 | if not mpfs.do_open(args.open): 903 | return 1 904 | else: 905 | print( 906 | "Positional argument ({}) takes precedence over --open.".format( 907 | args.board 908 | ) 909 | ) 910 | if args.board is not None: 911 | mpfs.do_open(args.board) 912 | 913 | if args.command is not None: 914 | for acmd in " ".join(args.command).split(";"): 915 | scmd = acmd.strip() 916 | if len(scmd) > 0 and not scmd.startswith("#"): 917 | mpfs.onecmd(scmd) 918 | 919 | elif args.script is not None: 920 | if platform.system() == "Windows": 921 | mpfs.use_rawinput = True 922 | 923 | f = open(args.script, "r") 924 | script = "" 925 | 926 | for line in f: 927 | sline = line.strip() 928 | 929 | if len(sline) > 0 and not sline.startswith("#"): 930 | script += sline + "\n" 931 | 932 | if sys.version_info < (3, 0): 933 | sys.stdin = io.StringIO(script.decode("utf-8")) 934 | else: 935 | sys.stdin = io.StringIO(script) 936 | 937 | mpfs.intro = "" 938 | mpfs.prompt = "" 939 | 940 | if not args.noninteractive: 941 | try: 942 | mpfs.cmdloop() 943 | except KeyboardInterrupt: 944 | print("") 945 | 946 | 947 | if __name__ == "__main__": 948 | sys.exit(main()) 949 | --------------------------------------------------------------------------------