├── examples ├── app_demo │ ├── .friendly_name │ ├── lib │ │ └── sample_lib.py │ ├── app.json │ └── main.py ├── versions.json ├── boot_restore.py └── README.md ├── install.sh ├── .gitignore ├── arduino_tools ├── templates │ ├── boot_apps.tpl │ ├── boot_plain.tpl │ └── main.tpl ├── __init__.py ├── constants.py ├── app.py ├── files.py ├── loader.py ├── properties.py ├── common.py ├── updater.py ├── helpers.py ├── help.txt ├── utils │ └── semver.py ├── apps_manager.py └── wifi_utils.py ├── assets └── package_installer_button.png ├── shell_tools ├── _create.sh ├── README.md ├── _remove.sh ├── _backup.sh ├── _install.sh ├── app_util.sh └── _common.sh ├── package.json ├── README.md ├── mpinstall.sh └── LICENSE /examples/app_demo/.friendly_name: -------------------------------------------------------------------------------- 1 | Demo App -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | ./mpinstall.sh arduino_tools $1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.mpy 3 | __pycache__ 4 | TODO.md -------------------------------------------------------------------------------- /arduino_tools/templates/boot_apps.tpl: -------------------------------------------------------------------------------- 1 | from arduino_tools import * 2 | load_app() -------------------------------------------------------------------------------- /assets/package_installer_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arduino/arduino-tools-mpy/main/assets/package_installer_button.png -------------------------------------------------------------------------------- /examples/app_demo/lib/sample_lib.py: -------------------------------------------------------------------------------- 1 | def sample_lib_function(): 2 | print("This is a sample function from sample_lib") 3 | return True 4 | -------------------------------------------------------------------------------- /arduino_tools/templates/boot_plain.tpl: -------------------------------------------------------------------------------- 1 | # This file will be the first one to run on board startup/reset 2 | # main.py (if present) will follow 3 | # place your code in main.py -------------------------------------------------------------------------------- /examples/app_demo/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "friendly_name": "Demo App", 4 | "author": "ubi de feo", 5 | "created": 0, 6 | "modified": 0, 7 | "version": "1.0.0", 8 | "origin_url": "https://arduino.cc", 9 | "tools_version": "0.8.0" 10 | } -------------------------------------------------------------------------------- /arduino_tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .constants import TOOLS_VERSION 2 | from .loader import * 3 | from .helpers import show_commands 4 | __author__ = "Ubi de Feo" 5 | __credits__ = ["Ubi de Feo", "Sebastian Romero"] 6 | __license__ = "MPL 2.0" 7 | __version__ = TOOLS_VERSION 8 | __maintainer__ = "Arduino" 9 | -------------------------------------------------------------------------------- /arduino_tools/constants.py: -------------------------------------------------------------------------------- 1 | APP_PREFIX = 'app_' 2 | BOOT_FILE = 'boot.py' 3 | BOOT_CONFIG_FILE = 'boot.cfg' 4 | MAIN_FILE = 'main.py' 5 | APP_PROPERTIES = 'app.json' 6 | APP_FRIENDLY_NAME_FILE = '.friendly_name' 7 | APP_HIDDEN_FILE = '.hidden' 8 | APPS_FRAMEWORK_NAME = 'Arduino Apps' 9 | TOOLS_VERSION = '0.10.1' -------------------------------------------------------------------------------- /arduino_tools/templates/main.tpl: -------------------------------------------------------------------------------- 1 | # App Name: {app_name} - {app_friendly_name} 2 | # Created with Arduino Tools for MicroPython 3 | 4 | # The following two lines will take care of initializing the app 5 | from arduino_tools.app import * 6 | my_app = App('{app_name}') 7 | 8 | # Write your code below (no #) and have fun :) 9 | print("Hello, I am an App and my name is {app_friendly_name}") -------------------------------------------------------------------------------- /examples/app_demo/main.py: -------------------------------------------------------------------------------- 1 | # Project Name: {app_name} 2 | # Created with Arduino Tools for MicroPython 3 | 4 | from sample_lib import sample_lib_function 5 | # The following two lines will take care of initializing the app 6 | # as well making sure that once run the current working directory 7 | # is set to the app's folder. 8 | # This comes in handy when testing an App but the board is set to run 9 | # a different default app. 10 | from arduino_tools.app import * 11 | my_app = App('demo') 12 | 13 | # Write your code below (no #) and have fun :) 14 | print(f'Hello, I am an app and my name is {my_app.friendly_name}') 15 | 16 | # Call a function from the sample_lib module 17 | # To demonstrate that the app's lib/ folder is in sys.path 18 | sample_lib_function() -------------------------------------------------------------------------------- /examples/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "latest": "1.1.0", 3 | "versions": 4 | { 5 | "1.1.0": { 6 | "friendly_name": "Arduino Apps Demo", 7 | "author": "Ubi de Feo", 8 | "contibutors": [ 9 | ], 10 | "version": "1.1.0", 11 | "file_name": "demo_1.1.0.tar", 12 | "tools_version": "0.6.0" 13 | }, 14 | "1.0.0": { 15 | "friendly_name": "Arduino Apps Demo", 16 | "author": "Ubi de Feo", 17 | "contibutors": [ 18 | ], 19 | "version": "1.0.0", 20 | "file_name": "demo_1.0.0.tar", 21 | "tools_version": "0.5.0" 22 | }, 23 | "0.9.0": { 24 | "friendly_name": "Arduino Apps Demo", 25 | "author": "Ubi de Feo", 26 | "contibutors": [ 27 | ], 28 | "version": "0.9.0", 29 | "file_name": "demo_0.9.0.tar", 30 | "tools_version": "0.6.0" 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /examples/boot_restore.py: -------------------------------------------------------------------------------- 1 | # This file, if present, is executed on every boot/reset. 2 | # It can be used to force booting into a predefined app, 3 | # such as a launcher application, when a specific condition is met. 4 | 5 | # In this example, the condition is the state of pin D2: 6 | # At boot time, if the Arduino Tools Loader is enabled, the method 7 | # enter_default_app() will check for the presence of this file, 8 | # and if it exists, it will call the restore_target() method from it. 9 | # A return value of None will be ignored, while a string will be used 10 | # to enter the app with that name, in this case 'app_launcher'. 11 | 12 | from machine import Pin 13 | restore_pin = Pin('D2', Pin.IN, Pin.PULL_UP) 14 | restore_target_app = 'app_launcher' 15 | 16 | def restore_target(): 17 | if restore_pin.value() == False: 18 | return restore_target_app 19 | return None 20 | -------------------------------------------------------------------------------- /shell_tools/_create.sh: -------------------------------------------------------------------------------- 1 | function create_app { 2 | app_safe_name=$1 3 | shift 4 | app_friendly_name=${*:-$app_safe_name} 5 | 6 | # if [ "$app_friendly_name" = "" ]; then 7 | # app_friendly_name=$app_safe_name 8 | # fi 9 | 10 | output_msg="Creating app \"$app_safe_name\" with friendly name \"$app_friendly_name\"" 11 | echo -n "⏳ $output_msg" 12 | 13 | # Run mpremote and capture the error message 14 | cmd="${PYTHON_HELPERS}" 15 | cmd+=''' 16 | ''' 17 | cmd+="create_app('$app_safe_name', friendly_name = '$app_friendly_name')" 18 | cmd+=''' 19 | ''' 20 | 21 | cmd+="print('App $app_safe_name created')" 22 | error=$(mpremote exec "$cmd") 23 | # Print error message if return code is not 0 24 | if [ $? -ne 0 ]; then 25 | echo -ne "\r\033[2K" 26 | echo "❌ $output_msg" 27 | echo "Error: $error" 28 | exit 1 29 | fi 30 | echo -ne "\r\033[2K" 31 | echo "☑️ $output_msg" 32 | } 33 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples and demo assets 2 | 3 | ## boot_restore.py 4 | 5 | This file, when added to the board's root, is loaded at start and offers a method to restore a launcher in case of failure. 6 | In this case the condition to restore `app_launcher` as the default application loading is to have `Pin(2)` pulled `LOW`. 7 | 8 | ## versions.json 9 | 10 | This is an example `JSON` file to be reachable at an App's `origin_url` address. 11 | The method `update_app(path = None)`, if `path` is not provided, will query that `URL` and verify that new versions of the App are available. 12 | 13 | ### Example usage (requires an active network connection) 14 | 15 | ```python 16 | from arduino_tools.app import App 17 | my_app = App('valid_app_name') 18 | my_app.update() 19 | ``` 20 | 21 | ## app_demo 22 | 23 | This is a sample App that displays the structure of an application. 24 | It includes a local `lib` folder, which highlights the fact that libraries local to the application don't need to be installed system-wide, also allowing other versions of packages to be installed on an App basis. 25 | The loader add the local App's `lib` folder at the beginning of `system.path`. 26 | -------------------------------------------------------------------------------- /arduino_tools/app.py: -------------------------------------------------------------------------------- 1 | from .common import validate_app 2 | from .loader import enter_app 3 | from .properties import get_app_properties, update_app_properties 4 | from .apps_manager import import_app 5 | import os 6 | 7 | class App: 8 | properties = {} 9 | def __init__(self, app_name): 10 | self.app_name = app_name 11 | self.app_updater = None 12 | if not validate_app(app_name): 13 | raise ValueError('Invalid app') 14 | self.properties = get_app_properties(app_name) 15 | self.friendly_name = self.properties['friendly_name'] 16 | if os.getcwd() != self.get_path(): 17 | enter_app(app_name) 18 | 19 | 20 | def get_property(self, property): 21 | return self.properties.get(property) 22 | 23 | def set_property(self, property, value): 24 | self.properties[property] = value 25 | 26 | def save_properties(self): 27 | update_app_properties(self.app_name, self.properties) 28 | 29 | def get_path(self): 30 | return self.get_property('path') 31 | 32 | def update_app(self, path = None): 33 | if path is not None: 34 | self.app_updater = __import__('arduino_tools.updater') 35 | updater = __import__('arduino_tools.updater') 36 | updater.updater.check_for_updates(self.app_name) 37 | else: 38 | import_app(path) 39 | -------------------------------------------------------------------------------- /shell_tools/README.md: -------------------------------------------------------------------------------- 1 | # MicroPython Apps Framework developer helpers [WIP] 2 | 3 | These `bash` shell scripts simplify working with the framework from the host machine and allows creating, deploying and backing up applications to and fromt he board. 4 | 5 | ## Requirements 6 | 7 | These scripts require `arduino-tools-mpy` to be installed on the board and `mpremote` to be installed on the host computer: 8 | 9 | ```shell 10 | pip install mpremote 11 | ``` 12 | 13 | ## How to use 14 | ```shell 15 | ./chmod +x app_util 16 | ./app_util 17 | mpremote: no device found 18 | Usage: ./app_util.sh : 19 | • help 20 | • create 21 | • install 22 | • backup 23 | • remove 24 | • delete 25 | • list 26 | • run 27 | ``` 28 | 29 | ## How it works 30 | 31 | These scripts will leverage the board's installed `arduino-tools-mpy` to create, backup and delete MicroPython apps on the board. 32 | For example, creating an app with `./app_util create {APP_NAME} {App Friendly Name}` will run code on the board, create the app (will ask for confirmation to overwrite if already present), back it up to a `.tar` archive and transfer it to the local machine, expand it and make it available locally. 33 | 34 | Running 35 | 36 | ```shell 37 | ./app_util create demo_app Demo Application 38 | ``` 39 | 40 | Will generate the following output: 41 | 42 | ```shell 43 | ☑️ Querying MicroPython board... 44 | ☑️ Checking if "/app_weather" exists on board 45 | 📦 App "Weather Widget" does not exist on board. Creating... 46 | ☑️ Creating app "weather" with friendly name "Weather Widget" 47 | ☑️ Archiving "weather" on board 48 | ☑️ Copying app "weather" archive to local machine 49 | ☑️ 🗜️ Extracting "weather_23987.tar" to app_weather 50 | 51 | ✅ App "Weather Widget" created and available locally 52 | ``` 53 | 54 | -------------------------------------------------------------------------------- /arduino_tools/files.py: -------------------------------------------------------------------------------- 1 | from time import time as tm 2 | import os 3 | from .common import get_root 4 | def get_template_path(file_name): 5 | template_path = get_root().join(__file__.split('/')[:-1]) + f'/templates/{file_name}' 6 | return template_path 7 | 8 | def template_to_file(template_name, destination_file, **variables): 9 | template_path = get_template_path(template_name) 10 | try: 11 | with open(template_path, 'r') as input_template: 12 | input_text = input_template.read().format(**variables) 13 | except OSError as e: 14 | return False, f'{template_name} not found', e 15 | try: 16 | with open(destination_file, 'w') as output_file: 17 | output_file.write(input_text) 18 | except OSError as e: 19 | return False, f'{destination_file} not created', e 20 | return True, f'{destination_file} created', None 21 | 22 | ### UNUSED UNTIL EXAMPLES LOADING IS IMPLEMENTED 23 | def new_file_from_source(file_name = None, destination_path = '.', overwrite = False, source_path = None): 24 | if file_name is None: 25 | file_name = 'main' 26 | new_sketch_path = f'{destination_path}/{file_name}.py' 27 | try: 28 | # open(new_sketch_path, 'r') 29 | os.stat(new_sketch_path) 30 | if not overwrite: 31 | file_name = f'{file_name}_{tm()}' 32 | except OSError: 33 | pass 34 | 35 | # template_path = get_template_path() if source_path is None else source_path 36 | template_path = source_path 37 | template_sketch = open(template_path, 'r') 38 | new_sketch_path = f'{destination_path}/{file_name}.py' 39 | 40 | with open(new_sketch_path, 'w') as f: 41 | sketch_line = None 42 | while sketch_line is not '': 43 | sketch_line = template_sketch.readline() 44 | f.write(sketch_line) 45 | template_sketch.close() 46 | return new_sketch_path 47 | 48 | def copy_py(source_path = '.', destination_path = '.', file_name = None, overwrite = False): 49 | file_name = file_name or 'main' 50 | return new_file_from_source(file_name = file_name, destination_path = destination_path, overwrite = overwrite, source_path = source_path) 51 | -------------------------------------------------------------------------------- /arduino_tools/loader.py: -------------------------------------------------------------------------------- 1 | # from sys import path as sys_path 2 | import sys 3 | import os 4 | from .constants import * 5 | from .common import * 6 | 7 | try: 8 | from boot_restore import restore_target 9 | restore_available = True 10 | except ImportError: 11 | restore_available = False 12 | 13 | default_path = sys.path.copy() 14 | 15 | def load_app(app_name = None, cycle_mode = False): 16 | if app_name == None or cycle_mode: 17 | return enter_default_app(cycle_mode = cycle_mode) 18 | return enter_app(app_name) 19 | 20 | def enter_default_app(cycle_mode = False): 21 | if restore_available and restore_target(): 22 | enter_app(restore_target()) 23 | return None 24 | 25 | if fs_item_exists(APPS_ROOT + BOOT_CONFIG_FILE): 26 | boot_entries = [] 27 | with open(APPS_ROOT + BOOT_CONFIG_FILE, 'r') as a_cfg: 28 | boot_entries = [entry.strip() for entry in a_cfg] 29 | 30 | if len(boot_entries) > 1: 31 | default_p = boot_entries.pop(0) 32 | elif len(boot_entries) == 1: 33 | default_p = boot_entries[0] 34 | else: 35 | default_p = '' 36 | # default_p = default_p.strip() 37 | if default_p == '': 38 | return None 39 | if len(boot_entries) > 0: 40 | if cycle_mode: 41 | boot_entries.append(default_p) 42 | with open(APPS_ROOT + BOOT_CONFIG_FILE, 'w') as a_cfg: 43 | for i, entry in enumerate(boot_entries): 44 | new_line = '\n' if i < len(boot_entries) - 1 else '' 45 | a_cfg.write(entry + new_line) 46 | return enter_app(default_p) 47 | return None 48 | 49 | 50 | def enter_app(app_name): 51 | app = get_app(app_name) 52 | if app == None: 53 | return None 54 | 55 | # Try to remove the default local path 56 | # which will be added back later as the first element 57 | sys.path = default_path.copy() 58 | try: 59 | sys.path.remove('') 60 | except ValueError: 61 | pass 62 | sys.path.insert(0, app['path'] + '/lib') 63 | sys.path.insert(0, '') 64 | os.chdir(app['path']) 65 | return True 66 | 67 | def restore_path(): 68 | sys.path = default_path.copy() 69 | os.chdir('/') -------------------------------------------------------------------------------- /shell_tools/_remove.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # source _common.sh 3 | 4 | # folder_name=$1 5 | # app_name=$1 6 | # force_confirm=$2 7 | # app_safe_name=$(echo $app_name | tr ' [:punct:]' '_') 8 | # echo "$app_name" 9 | function remove_app { 10 | app_name=$1 11 | 12 | force_confirm=${2:-} 13 | app_safe_name=$(echo $app_name | tr ' [:punct:]' '_') 14 | if [ "$app_name" = "" ]; then 15 | input_msg="Insert App name: " 16 | read -p "❔ $input_msg" app_name 17 | if [[ $app_name == "" ]]; then 18 | echo "No app name provided. Exiting..." 19 | exit 1 20 | fi 21 | fi 22 | 23 | if [[ $app_name == "$APPS_PREFIX"* ]]; then 24 | folder_name=$app_name 25 | app_name=${app_name:4} 26 | else 27 | folder_name="$APPS_PREFIX$app_name" 28 | app_name=$app_name 29 | fi 30 | 31 | if [ "$app_name" == "" ]; then 32 | app_name=$folder_name 33 | fi 34 | 35 | APPNAME=$app_name 36 | APP_DIR="$folder_name" 37 | SRCDIR=$APP_DIR 38 | # if APPS_ROOT is not "/", make sure it's trailed with a "/" 39 | # e.g. APPS_ROOT="/apps/" 40 | 41 | 42 | # If the AMP root directory do not exist, create it 43 | echo "🗑️ Deleting app \"$APPNAME\"" 44 | 45 | if directory_exists "${APPS_ROOT}${APP_DIR}"; then 46 | # echo "Deleting previously existing $APPS_ROOT$APP_DIR on board" 47 | if [ "$force_confirm" != "Y" ]; then 48 | output_msg="Deleting \"$APPNAME\" from your board is not reversible. Are you sure you want to continue?" 49 | echo -n "⚠️ $output_msg [Y/n]: " 50 | read confirm 51 | confirm=${confirm:-N} 52 | else 53 | confirm="Y" 54 | fi 55 | # output_msg="Deleting \"$APPNAME\" from your board is not reversible. Are you sure you want to continue?" 56 | # echo -n "⚠️ $output_msg [Y/n]: " 57 | # read confirm 58 | # confirm=${confirm:-n} 59 | 60 | if [ "$confirm" = "Y" ]; then 61 | output_msg="Force deleting \"$APPNAME\" from your board" 62 | echo -ne "\033[F\r\033[2K" 63 | echo "☑️ $output_msg" 64 | delete_folder "${APPS_ROOT}${APP_DIR}" 65 | else 66 | echo "❌ $app_name not removed" 67 | exit 1 68 | fi 69 | 70 | else 71 | echo "❌ App \"$APPNAME\" does not exist on board" 72 | exit 1 73 | fi 74 | 75 | 76 | echo "✅ App \"$APPNAME\" uninstalled successfully" 77 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | [ 4 | "arduino_tools/__init__.py", 5 | "github:arduino/arduino-tools-mpy/arduino_tools/__init__.py" 6 | ], 7 | [ 8 | "arduino_tools/app.py", 9 | "github:arduino/arduino-tools-mpy/arduino_tools/app.py" 10 | ], 11 | [ 12 | "arduino_tools/apps_manager.py", 13 | "github:arduino/arduino-tools-mpy/arduino_tools/apps_manager.py" 14 | ], 15 | [ 16 | "arduino_tools/common.py", 17 | "github:arduino/arduino-tools-mpy/arduino_tools/common.py" 18 | ], 19 | [ 20 | "arduino_tools/constants.py", 21 | "github:arduino/arduino-tools-mpy/arduino_tools/constants.py" 22 | ], 23 | [ 24 | "arduino_tools/files.py", 25 | "github:arduino/arduino-tools-mpy/arduino_tools/files.py" 26 | ], 27 | [ 28 | "arduino_tools/help.txt", 29 | "github:arduino/arduino-tools-mpy/arduino_tools/help.txt" 30 | ], 31 | [ 32 | "arduino_tools/helpers.py", 33 | "github:arduino/arduino-tools-mpy/arduino_tools/helpers.py" 34 | ], 35 | [ 36 | "arduino_tools/loader.py", 37 | "github:arduino/arduino-tools-mpy/arduino_tools/loader.py" 38 | ], 39 | [ 40 | "arduino_tools/properties.py", 41 | "github:arduino/arduino-tools-mpy/arduino_tools/properties.py" 42 | ], 43 | [ 44 | "arduino_tools/updater.py", 45 | "github:arduino/arduino-tools-mpy/arduino_tools/updater.py" 46 | ], 47 | [ 48 | "arduino_tools/wifi_utils.py", 49 | "github:arduino/arduino-tools-mpy/arduino_tools/wifi_utils.py" 50 | ], 51 | [ 52 | "arduino_tools/utils/semver.py", 53 | "github:arduino/arduino-tools-mpy/arduino_tools/utils/semver.py" 54 | ], 55 | [ 56 | "arduino_tools/templates/main.tpl", 57 | "github:arduino/arduino-tools-mpy/arduino_tools/templates/main.tpl" 58 | ], 59 | [ 60 | "arduino_tools/templates/boot_apps.tpl", 61 | "github:arduino/arduino-tools-mpy/arduino_tools/templates/boot_apps.tpl" 62 | ], 63 | [ 64 | "arduino_tools/templates/boot_plain.tpl", 65 | "github:arduino/arduino-tools-mpy/arduino_tools/templates/boot_plain.tpl" 66 | ], 67 | ], 68 | "deps": [ 69 | [ 70 | "tarfile-write", 71 | "latest" 72 | ], 73 | [ 74 | "github:SpotlightKid/mrequests", 75 | "latest" 76 | ] 77 | ], 78 | "version": "0.10.1" 79 | } -------------------------------------------------------------------------------- /arduino_tools/properties.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from .common import * 3 | import json 4 | app_properties_template = OrderedDict({ 5 | "name": "", 6 | "friendly_name": "", 7 | "author": "", 8 | "created": 0, 9 | "modified": 0, 10 | "version": "0.0.0", 11 | "origin_url": "https://arduino.cc", 12 | "tools_version": "0.0.0" 13 | }) 14 | 15 | 16 | def get_app_properties(app_name, key = None): 17 | if not validate_app(app_name): 18 | raise ValueError(f'Invalid app: {app_name}') 19 | project_path = get_app(app_name)['path'] 20 | with open(project_path+'/' + APP_PROPERTIES, 'r') as json_file: 21 | loaded_properties = json.load(json_file) 22 | updated_data = app_properties_template.copy() 23 | for k, v in loaded_properties.items(): 24 | updated_data[k] = v 25 | updated_data['path'] = project_path 26 | return updated_data 27 | 28 | 29 | def get_app_property(app_name, key): 30 | app_properties = get_app_properties(app_name) 31 | return app_properties[key] 32 | 33 | def update_app_properties(app_name, properties = {}): 34 | if not validate_app(app_name) : 35 | raise ValueError(f'Invalid app: {app_name}') 36 | app_json_path = get_app(app_name)['path'] + '/' + APP_PROPERTIES 37 | if fs_item_exists(app_json_path): 38 | with open(app_json_path, 'r') as json_file: 39 | loaded_properties = json.load(json_file) 40 | else: 41 | loaded_properties = {} 42 | updated_data = app_properties_template.copy() 43 | updated_data.update(loaded_properties) 44 | for key, value in properties.items(): 45 | updated_data[key] = value 46 | 47 | with open(get_app(app_name)['path'] + '/' + APP_PROPERTIES, 'w') as json_file: 48 | json.dump(updated_data, json_file) 49 | 50 | def set_app_properties(app_name, **keys): 51 | if not validate_app(app_name) : 52 | raise ValueError(f'Invalid app: {app_name}') 53 | app_json_path = get_app(app_name)['path'] + '/' + APP_PROPERTIES 54 | if fs_item_exists(app_json_path): 55 | with open(app_json_path, 'r') as json_file: 56 | loaded_properties = json.load(json_file) 57 | else: 58 | loaded_properties = {} 59 | updated_data = app_properties_template.copy() 60 | updated_data.update(loaded_properties) 61 | for key, value in keys.items(): 62 | updated_data[key] = value 63 | 64 | with open(get_app(app_name)['path'] + '/' + APP_PROPERTIES, 'w') as json_file: 65 | json.dump(updated_data, json_file) 66 | 67 | -------------------------------------------------------------------------------- /shell_tools/_backup.sh: -------------------------------------------------------------------------------- 1 | function transfer_app { 2 | app_safe_name=$(echo "$1" | tr -d '\r\n') 3 | output_msg="Archiving \"$app_safe_name\" on board" 4 | echo -n "⏳ $output_msg" 5 | cmd="${PYTHON_HELPERS}" 6 | cmd+=''' 7 | ''' 8 | cmd+="res = export_app('$app_safe_name');print(res)" 9 | 10 | error=$(mpremote exec "$cmd") 11 | if [ $? -ne 0 ]; then 12 | echo -ne "\r\033[2K" 13 | echo "❌ $output_msg" 14 | echo "Error: $error" 15 | return 0 16 | fi 17 | 18 | echo -ne "\r\033[2K" 19 | echo "☑️ $output_msg" 20 | remote_archive_path=$(echo "$error" | tr -d '\r\n') 21 | 22 | output_msg="Copying app \"$app_safe_name\" archive to local machine" 23 | echo -n "⏳ $output_msg" 24 | 25 | error=$(mpremote cp :$remote_archive_path ./) 26 | if [ $? -ne 0 ]; then 27 | echo -ne "\r\033[2K" 28 | echo "❌ $output_msg" 29 | echo "Error: $error" 30 | return 0 31 | fi 32 | echo -ne "\r\033[2K" 33 | echo "☑️ $output_msg" 34 | 35 | local_folder_name="$APPS_PREFIX$app_safe_name" 36 | if [ -d "$local_folder_name" ]; then 37 | input_msg="❔ Delete local folder $local_folder_name?" 38 | read -p "❔ $input_msg [Y/n]: " confirm 39 | confirm=${confirm:-n} 40 | 41 | if [ $confirm == "Y" ]; then 42 | echo -ne "\033[F\r\033[2K" 43 | echo "☑️ $input_msg" 44 | output_msg="Deleting local folder $local_folder_name" 45 | echo -n "⏳ $output_msg" 46 | rm -rf "$local_folder_name" 47 | echo -ne "\r\033[2K" 48 | echo "☑️ $output_msg" 49 | else 50 | echo -ne "\r\033[2K" 51 | echo "❌ $input_msg" 52 | timestamp=$(date +%s) 53 | 54 | local_folder_backup_name="$local_folder_name""_backup_$timestamp" 55 | 56 | output_msg="Moving $local_folder_name to $local_folder_backup_name" 57 | echo -n "⏳ $output_msg" 58 | mv $local_folder_name $local_folder_backup_name 59 | echo -ne "\r\033[2K" 60 | echo "☑️ $output_msg" 61 | fi 62 | fi 63 | 64 | 65 | archive_name=`basename $remote_archive_path` 66 | output_msg="🗜️ Extracting \"$archive_name\" to $APPS_PREFIX$app_safe_name" 67 | tar --strip-components=1 -xf $archive_name 68 | rm -f $archive_name 69 | # Print error message if return code is not 0 70 | if [ $? -ne 0 ]; then 71 | echo -ne "\r\033[2K" 72 | echo "❌ $output_msg" 73 | echo "Error: $res" 74 | return 0 75 | fi 76 | echo -ne "\r\033[2K" 77 | echo "☑️ $output_msg" 78 | 79 | } 80 | -------------------------------------------------------------------------------- /arduino_tools/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .constants import * 3 | import sys 4 | 5 | def get_root(has_flash_mount = True): 6 | if '/flash' in sys.path: 7 | return '/flash/' 8 | else: 9 | return '/' 10 | 11 | APPS_ROOT = get_root() 12 | 13 | try: 14 | import tarfile 15 | from tarfile import write 16 | ALLOW_EXPORT = True 17 | except ImportError as e: 18 | ALLOW_EXPORT = False 19 | 20 | try: 21 | import network 22 | NETWORK_UPDATE = True 23 | except ImportError: 24 | NETWORK_UPDATE = False 25 | 26 | def validate_app(app_name): 27 | app_folder = APP_PREFIX + app_name.replace(APP_PREFIX, '') 28 | main_py_path = APPS_ROOT + app_folder + '/main.py' 29 | main_mpy_path = APPS_ROOT + app_folder + '/main.mpy' 30 | verify_main = fs_item_exists(main_py_path) or fs_item_exists(main_mpy_path) 31 | properties_file_path = APPS_ROOT + app_folder + '/' + APP_PROPERTIES 32 | if verify_main and fs_item_exists(properties_file_path): 33 | return True 34 | else: 35 | return False 36 | 37 | def default_app(app_name = None, fall_back = None): 38 | default_app_name = '' if app_name == None else app_name 39 | if app_name != None: 40 | if (not validate_app(default_app_name)) and default_app_name != '': 41 | return(OSError(9, f'Project {default_app_name} does not exist')) 42 | with open(APPS_ROOT + BOOT_CONFIG_FILE, 'w') as a_cfg: 43 | a_cfg.write(default_app_name) 44 | if fall_back != None: 45 | a_cfg.write('\n') 46 | a_cfg.write(fall_back) 47 | else: 48 | if fs_item_exists(APPS_ROOT + BOOT_CONFIG_FILE): 49 | with open(APPS_ROOT + BOOT_CONFIG_FILE, 'r') as a_cfg: 50 | default_app_name = a_cfg.readline().strip() 51 | else: 52 | default_app_name = '' 53 | return default_app_name if default_app_name != '' else None 54 | 55 | # more targeted approach 56 | def get_app(app_name): 57 | if validate_app(app_name): 58 | app_folder = APP_PREFIX + app_name.replace(APP_PREFIX, '') 59 | app_dict = { 60 | 'name': '', 61 | 'path': '', 62 | 'hidden': False 63 | } 64 | app_dict['name'] = app_folder.replace(APP_PREFIX, '') 65 | app_dict['path'] = APPS_ROOT + app_folder 66 | if fs_item_exists(APPS_ROOT + app_folder + '/.hidden'): 67 | app_dict['hidden'] = True 68 | return app_dict 69 | else: 70 | return None 71 | 72 | def get_apps(root_folder = None): 73 | if root_folder is None: 74 | root_folder = APPS_ROOT 75 | for fs_item in os.ilistdir(root_folder): 76 | fs_item_name = fs_item[0] 77 | if fs_item_name[0:len(APP_PREFIX)] != APP_PREFIX: 78 | continue 79 | if validate_app(fs_item_name): 80 | app_dict = { 81 | 'name': '', 82 | 'friendly_name': '', 83 | 'path': '', 84 | 'hidden': False 85 | } 86 | # app_name = fs_item_name.replace('app_', '') 87 | app_dict['name'] = fs_item_name.replace('app_', '') 88 | app_dict['path'] = APPS_ROOT + fs_item_name 89 | try: 90 | with open(APPS_ROOT + fs_item_name + '/' + APP_FRIENDLY_NAME_FILE, 'r') as friendly_name_file: 91 | friendly_name = friendly_name_file.read() 92 | except: 93 | friendly_name = '' 94 | 95 | app_dict['friendly_name'] = app_dict['name'] if friendly_name == '' else friendly_name 96 | if fs_item_exists(APPS_ROOT + fs_item_name + '/.hidden'): 97 | app_dict['hidden'] = True 98 | yield app_dict 99 | 100 | 101 | def fs_item_exists(path): 102 | try: 103 | os.stat(path) 104 | return True 105 | except OSError as e: 106 | return False 107 | -------------------------------------------------------------------------------- /arduino_tools/updater.py: -------------------------------------------------------------------------------- 1 | from .utils.semver import * 2 | from .apps_manager import get_apps_list, get_app_properties, import_app 3 | from .common import get_root, NETWORK_UPDATE 4 | 5 | import json 6 | from os import mkdir 7 | 8 | apps = [] 9 | for app in get_apps_list(): 10 | apps.append(get_app_properties(app['name'])) 11 | 12 | buf = bytearray(1024) 13 | 14 | if NETWORK_UPDATE: 15 | try: 16 | import mrequests 17 | except ImportError: 18 | raise ImportError('mrequests module not found. Please install https://github.com/SpotlightKid/mrequests.') 19 | 20 | class ResponseWithProgress(mrequests.Response): 21 | _total_read = 0 22 | 23 | def readinto(self, buf, size=0): 24 | bytes_read = super().readinto(buf, size) 25 | self._total_read += bytes_read 26 | print("Progress: {:.2f}%".format(self._total_read / (self._content_size * 0.01))) 27 | return bytes_read 28 | 29 | def get_updated_version(app_name, url, version): 30 | current_version = SemVer.from_string(version) 31 | r = mrequests.get(url + '/versions.json') 32 | updated_version = None 33 | if r.status_code == 200: 34 | updates = json.loads(r.text) 35 | latest_version = SemVer.from_string(updates['latest']) 36 | print(f'Current version: {version}') 37 | print(f'Latest version: {latest_version}') 38 | if current_version < latest_version: 39 | versions = updates['versions'] 40 | print("Update available.") 41 | for ver in versions: 42 | print(f'{ver} - {versions[ver]["file_name"]}') 43 | updated_version = versions[updates['latest']] 44 | else: 45 | print("No update available.") 46 | else: 47 | print("Request failed. Status: {}".format(r.status_code)) 48 | r.close() 49 | return updated_version 50 | 51 | 52 | file_download = None 53 | 54 | def file_get(url, file_path): 55 | r = mrequests.get(url, headers={b"accept": b"application/x-tar"}, response_class=ResponseWithProgress) 56 | download_success = False 57 | if r.status_code == 200: 58 | r.save(file_path, buf=buf) 59 | print(f'file saved to "{file_path}"') 60 | download_success = True 61 | else: 62 | print("Request failed. Status: {}".format(r.status_code)) 63 | 64 | r.close() 65 | return download_success 66 | 67 | def check_for_updates(app_name, force_update = False): 68 | properties = get_app_properties(app_name) 69 | ota_url = properties['origin_url'] 70 | version = properties['version'] 71 | print(ota_url, version) 72 | updated_version = get_updated_version(app_name, ota_url, version) 73 | if updated_version != None: 74 | if force_update: 75 | return network_update(app_name, ota_url, updated_version) 76 | else: 77 | confirm = input(f'Do you want to update {app_name} from {version} to {updated_version['version']}? (y/n): ') 78 | if confirm == 'y': 79 | return network_update(app_name, ota_url, updated_version) 80 | return False 81 | 82 | def network_update(app_name, ota_url, updated_version): 83 | """Download and install app update""" 84 | update_file_name = updated_version['file_name'] 85 | update_file_url = f'{ota_url}/{update_file_name}' 86 | tmp_path = get_root() + 'tmp' 87 | try: 88 | mkdir(tmp_path) 89 | except OSError: 90 | print(f'{tmp_path} already exists') 91 | 92 | download_file_path = f'/tmp/{update_file_name}' 93 | download_success = file_get(update_file_url, download_file_path) 94 | 95 | if download_success: 96 | import_app(download_file_path, True) 97 | return True 98 | return False 99 | -------------------------------------------------------------------------------- /arduino_tools/helpers.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | import os 3 | from .common import * 4 | from .files import * 5 | 6 | 7 | # Hashing Helpers 8 | def hash_generator(path): 9 | with open(path, 'rb') as file: 10 | for line in file: 11 | yield line 12 | 13 | 14 | def get_hash(file_path): 15 | hash_object = sha256() 16 | for l in hash_generator(file_path): 17 | hash_object.update(l) 18 | return hex(int.from_bytes(hash_object.digest(), 'big')) 19 | 20 | 21 | # MicroPython Helpers 22 | def create_plain_boot(): 23 | # create project's main from template 24 | success, message, exception = template_to_file('boot_plain.tpl', f'{APPS_ROOT}/{BOOT_FILE}') 25 | if not success: 26 | print(f'Error creating {BOOT_FILE}: {message}') 27 | return None 28 | return True 29 | 30 | 31 | # File System Helpers 32 | def get_module_path(): 33 | module_path = '/'.join(__file__.split('/')[:-1]) 34 | return module_path 35 | 36 | def fs_root(): 37 | os.chdir(get_root()) 38 | 39 | def fs_getpath(): 40 | return os.getcwd() 41 | 42 | 43 | def delete_fs_item(fs_path, is_folder=False): 44 | (os.rmdir if is_folder else os.remove)(fs_path) 45 | 46 | 47 | def read_file(path): 48 | if not fs_item_exists(path): 49 | print(f'{path} does not exist') 50 | with open(path, 'r') as file: 51 | for line in file.readlines(): 52 | print(line, end = '') 53 | print() 54 | 55 | 56 | def is_directory(path): 57 | return True if os.stat(path)[0] == 0x4000 else False 58 | 59 | 60 | def file_tree_generator(folder_path, depth=-1): 61 | try: 62 | os.listdir(folder_path) 63 | except OSError as err: 64 | print('path not existing') 65 | return False 66 | 67 | yield depth, True, folder_path 68 | 69 | for itm in os.ilistdir(folder_path): 70 | item_path = folder_path + '/' + itm[0] 71 | item_path = item_path.replace('//', '/') 72 | if is_directory(item_path): 73 | yield from file_tree_generator(item_path, depth + 1) 74 | else: 75 | yield depth + 1, False, item_path 76 | 77 | 78 | def get_directory_tree(path = '.', order = -1): 79 | tree_list = [] 80 | if path in ('', '.'): 81 | path = os.getcwd() 82 | for depth, is_folder, file_path in file_tree_generator(path): 83 | if file_path in ('.', os.getcwd()): 84 | continue 85 | file_name = file_path.split('/')[len(file_path.split('/'))-1] 86 | if order >= 0: 87 | tree_list.append((depth, file_path, file_name, is_folder)) 88 | else: 89 | tree_list.insert(0,(depth, file_path, file_name, is_folder)) 90 | return tree_list 91 | 92 | 93 | def print_directory(path = '.'): 94 | for depth, file_path, file_name, is_folder in get_directory_tree(path, 1): 95 | if depth < 0: 96 | continue 97 | print(' ' * depth + ('[D] ' if is_folder else '[F] ') + file_name) 98 | 99 | 100 | def delete_folder(path = '.', force_confirm = False): 101 | if path == '': 102 | path = '.' 103 | 104 | for fs_item in get_directory_tree(path): 105 | _, file_path, file_name, is_folder = fs_item 106 | if file_path in ('.', os.getcwd()): 107 | print('cannot delete current folder') 108 | continue 109 | if force_confirm == False: 110 | c = input(f'Are you sure you want to delete {'folder' if is_folder else 'file'} {file_path}? [Y/n]') 111 | if c == 'Y': 112 | delete_fs_item(file_path, is_folder) 113 | else: 114 | delete_fs_item(file_path, is_folder) 115 | 116 | 117 | # Console helpers 118 | def show_cursor(show = True): 119 | print('\033[?25' + ('h' if show else 'l'), end = '') 120 | 121 | 122 | def clear_terminal(cursor_visible = True): 123 | print("\033[2J\033[H", end = '') 124 | show_cursor(cursor_visible) 125 | 126 | 127 | def show_commands(): 128 | clear_terminal() 129 | with open(f'{get_module_path()}/help.txt', 'r') as help_file: 130 | for line in help_file.readlines(): 131 | print(line, end = '') 132 | 133 | -------------------------------------------------------------------------------- /shell_tools/_install.sh: -------------------------------------------------------------------------------- 1 | function install_app { 2 | 3 | folder_name=$1 4 | app_name=$1 5 | app_safe_name=$(echo $app_name | tr ' [:punct:]' '_') 6 | apps_root=${APPS_ROOT:-} 7 | app_friendly_name="" 8 | 9 | if [ "$app_name" == "" ]; then 10 | input_msg="Insert App or local folder name: " 11 | read -p "❔ $input_msg" app_name 12 | if [[ $app_name == "" ]]; then 13 | echo "No app name provided. Exiting..." 14 | exit 1 15 | fi 16 | fi 17 | 18 | if [[ $app_name == "$APPS_PREFIX"* ]]; then 19 | folder_name=$app_name 20 | app_name=${app_name:4} 21 | else 22 | folder_name="$APPS_PREFIX$app_name" 23 | app_name=$app_name 24 | fi 25 | if [ -f "$folder_name/.friendly_name" ]; then 26 | app_friendly_name=$(head -n 1 "$folder_name/.friendly_name") 27 | fi 28 | if [ ! -d "$folder_name" ]; then 29 | echo "App '$app_name' does not exist. Exiting..." 30 | exit 1 31 | fi 32 | 33 | if [ -f "$folder_name/.friendly_name" ]; then 34 | app_name=$(<"$folder_name/.friendly_name") 35 | fi 36 | 37 | if [ "$app_name" == "" ]; then 38 | app_name=$folder_name 39 | fi 40 | 41 | 42 | APPNAME=$app_name 43 | APP_DIR="$folder_name" 44 | SRCDIR=$APP_DIR 45 | # if APPS_ROOT is not "/", make sure it's trailed with a "/" 46 | # e.g. APPS_ROOT="/apps/" 47 | APPS_ROOT=$apps_root 48 | 49 | # File system operations such as "mpremote mkdir" or "mpremote rm" 50 | # will generate an error if the folder exists or if the file does not exist. 51 | # These errors can be ignored. 52 | # 53 | # Traceback (most recent call last): 54 | # File "", line 2, in 55 | # OSError: [Errno 17] EEXIST 56 | 57 | # Check if a directory exists 58 | # Returns 0 if directory exists, 1 if it does not 59 | 60 | 61 | 62 | echo "Installing app \"$app_friendly_name\"" 63 | 64 | # If the AMP root directory do not exist, create it 65 | if ! directory_exists "${APPS_ROOT}"; then 66 | # echo "Creating $APPS_ROOT on board" 67 | create_folder "${APPS_ROOT}" 68 | fi 69 | 70 | if directory_exists "${APPS_ROOT}${APP_DIR}"; then 71 | # echo "Deleting previously existing $APPS_ROOT$APP_DIR on board" 72 | delete_folder "${APPS_ROOT}${APP_DIR}" 73 | fi 74 | 75 | create_folder "${APPS_ROOT}${APP_DIR}" 76 | 77 | 78 | # echo "Creating $APPS_ROOT$APP_DIR on board" 79 | # mpremote mkdir "${APPS_ROOT}${APP_DIR}" 80 | 81 | # if ! directory_exists "${APPS_ROOT}${APP_DIR}"; then 82 | # echo "Creating $APPS_ROOT$APP_DIR on board" 83 | # mpremote mkdir "/${APPS_ROOT}${APP_DIR}" 84 | # fi 85 | 86 | 87 | ext=${2:-"py"} 88 | if [ "$ext" = "mpy" ]; then 89 | echo ".py files will be compiled to .mpy" 90 | fi 91 | 92 | reset=${3:-false} 93 | for arg in "$@"; do 94 | if [ "$arg" == "--no-reset" ]; then 95 | reset=false 96 | fi 97 | done 98 | 99 | find $APP_DIR -name ".DS_Store" -type f -delete 100 | 101 | 102 | app_files=($(find "$APP_DIR" -mindepth 1)) 103 | for item in "${app_files[@]}"; do 104 | if [ -d "$item" ]; then 105 | create_folder $APPS_ROOT$item 106 | elif [ -f "$item" ]; then 107 | f_name=`basename $item` 108 | source_extension="${f_name##*.}" 109 | destination_extension=$source_extension 110 | if [[ "$ext" == "mpy" && "$source_extension" == "py" ]]; then 111 | echo "Compiling $SRCDIR/$f_name to $SRCDIR/${f_name%.*}.$ext" 112 | mpy-cross "$SRCDIR/$f_name" 113 | destination_extension=$ext 114 | copy_file ${item%.*}.$destination_extension ":${APPS_ROOT}${item%.*}.$destination_extension" 115 | else 116 | copy_file $item ":${APPS_ROOT}$item" 117 | fi 118 | else 119 | echo -n "symlink file ignored" # Second echo, handles other cases like symlinks 120 | fi 121 | done 122 | 123 | 124 | if [ "$ext" == "mpy" ]; then 125 | output_msg="Cleaning up .mpy files" 126 | echo -n "⏳ $output_msg" 127 | rm $SRCDIR/*.mpy 128 | echo -ne "\r\033[2K" 129 | echo "✅ $output_msg" 130 | fi 131 | 132 | echo -e "\n✅ App \"$APPNAME\" installed successfully" 133 | if [ "$reset" = true ]; then 134 | echo "🔄 Resetting target board ..." 135 | mpremote reset 136 | exit 1 137 | fi 138 | 139 | } -------------------------------------------------------------------------------- /shell_tools/app_util.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | source _common.sh 4 | 5 | 6 | if ! device_present; then 7 | echo "❌ No MicroPython board found. Exiting." 8 | exit 1 9 | fi 10 | 11 | # clean_error=$(echo $error | tr -d '\r\n') 12 | APPS_ROOT=$(get_apps_root | tr -d '\n\r') 13 | 14 | 15 | commands=("help" "create" "install" "backup" "delete" "list" "run") 16 | function command_valid { 17 | local cmd=${1:-} 18 | if [ -z "$cmd" ]; then 19 | return 1 20 | fi 21 | 22 | for command in "${commands[@]}"; do 23 | if [ "$1" == "$command" ]; then 24 | return 0 25 | fi 26 | done 27 | return 1 28 | } 29 | 30 | function show_help { 31 | echo "📋 Usage: ./app_util.sh :" 32 | for cmd in "${commands[@]}"; do echo "◦ $cmd"; done 33 | 34 | } 35 | 36 | function run_local { 37 | echo "Running local app <$1> [WIP]" 38 | # 1. install local app to temporary folder on the board 39 | # 2. invoke run with app name 40 | } 41 | 42 | function run { 43 | echo "Running app <$1> from the board [WIP]" 44 | # 1. set default app to selected app 45 | # 2. soft reboot 46 | } 47 | 48 | 49 | if ! check_arduino_tools; then 50 | echo "Arduino Apps Framework not installed." 51 | echo "Please install Arduino Apps Framework for MicroPython on your board." 52 | echo "https://github.com/arduino/arduino-tools-mpy" 53 | read -p "Install now? [y/n]: " confirm 54 | confirm=${confirm:-n} 55 | if [ $confirm == "y" ]; then 56 | mpremote mip --target=/lib install $ARDUINO_TOOLS_MIP_URL 57 | mpremote mip --target=/lib install tarfile-write 58 | echo "Arduino Tools for MicroPython installed." 59 | fi 60 | echo -e "\nArduino Apps Framework for MicroPython not installed. Exiting." 61 | exit 1 62 | fi 63 | 64 | command=${1:-""} 65 | 66 | if ! command_valid $command; then 67 | show_help 68 | exit 1 69 | fi 70 | 71 | shift 72 | 73 | case "$command" in 74 | help) 75 | show_help 76 | exit 1 77 | ;; 78 | create) 79 | source ./_create.sh 80 | source ./_backup.sh 81 | app_name=$1 82 | app_safe_name=$(echo $app_name | tr ' [:punct:]' '_') 83 | shift 84 | app_friendly_name=$* 85 | remote_app_path="$APPS_ROOT""$APPS_PREFIX""$app_safe_name" 86 | 87 | if directory_exists $remote_app_path; then 88 | echo "📦 App \"$app_friendly_name\" already exists on board. Delete first." 89 | exit 1 90 | else 91 | echo "📦 App \"$app_friendly_name\" does not exist on board. Creating..." 92 | create_app $app_safe_name $app_friendly_name 93 | fi 94 | transfer_app $app_safe_name 95 | echo -e "\n✅ App \"$app_friendly_name\" created and available locally" 96 | 97 | ;; 98 | install) 99 | source _install.sh 100 | install_app $@ 101 | ;; 102 | delete) 103 | source _remove.sh 104 | remove_app $@ 105 | 106 | ;; 107 | backup) 108 | if [ $# -eq 0 ]; then 109 | echo "❌ Error: App name required" >&2 110 | exit 1 111 | fi 112 | source _backup.sh 113 | app_safe_name=$1 114 | remote_app_path="${APPS_ROOT}${APPS_PREFIX}${app_safe_name}" 115 | if directory_exists $remote_app_path; then 116 | echo -ne "App \"$app_safe_name\" exists on board. Backing up locally." 117 | transfer_app $app_safe_name 118 | else 119 | echo -ne "App \"$app_safe_name\" does not exist on board. Backup canceled." 120 | exit 1 121 | fi 122 | 123 | echo -e "\n✅ App \"$app_safe_name\" backed up and available locally" 124 | ;; 125 | list) 126 | cmd="${PYTHON_HELPERS}list_apps()" 127 | error=$(mpremote exec "$cmd") 128 | echo "Apps on board:" 129 | if [ $? -ne 0 ]; then 130 | echo "Error: $error" 131 | return 0 132 | fi 133 | clean_string=$(echo "$error" | tr -d '\r\n') 134 | apps_list=($clean_string) 135 | for app in "${apps_list[@]}"; do 136 | echo " 📦 $app" 137 | done 138 | ;; 139 | run) 140 | # Run an app on the board 141 | # WIP: currently only supports running main.py from the local (PC) filesystem 142 | # 143 | # TODO: 144 | # √ install local app to the board (overwrite if already exists) 145 | # - use temporary app folder to test run apps 146 | # - run app directly from the board 147 | echo "❌ Running apps directly from the board is not yet supported." 148 | exit 1 149 | if [ "$1" = "" ]; then 150 | echo "Please provide an app name to run." 151 | exit 1 152 | fi 153 | # app_folder=$1 154 | if [[ $1 == "$APPS_PREFIX"* ]]; then 155 | app_folder=$1 156 | else 157 | app_folder="$APPS_PREFIX$1" 158 | fi 159 | app_path="$app_folder/main.py" 160 | 161 | error=$(mpremote run "$app_path") 162 | if [ $? -ne 0 ]; then 163 | echo "Error: $error" 164 | fi 165 | ;; 166 | *) 167 | echo "Unknown command: $command" 168 | show_help 169 | exit 1 170 | ;; 171 | esac 172 | 173 | -------------------------------------------------------------------------------- /arduino_tools/help.txt: -------------------------------------------------------------------------------- 1 | enable_apps(): 2 | Enable support for Arduino MicroPython Apps. 3 | 4 | disable_apps(): 5 | Disable support for Arduino MicroPython Apps. 6 | 7 | create_app('{app name}', friendly_name = '{friendly_name}', set_default = False, hidden = False): 8 | Creates an app with the given name. No special characters allowed. Spaces will be converted to '_' 9 | Setting the app as default will make it run at boot/reset. 10 | Making the app hidden will prevent its listing. 11 | Useful for launchers and other management utilities not to be messed with. 12 | 13 | delete_app('{app_name}', force_confirm = False): 14 | Deletes the app with the given name. 15 | If force_confirm is set to 'Y' no confirmation will be required to delete the whole tree. 16 | 17 | hide_app('{app name}'): 18 | Will set the app to hidden. 19 | 20 | unhide_app('{app name}'): 21 | Will set the app to visible. 22 | 23 | list_apps(return_list = False, include_hidden = False): [REPL/interactive] 24 | Interactively list apps in REPL unless return_list is True. 25 | Hidden apps are excluded by default. 26 | 27 | get_apps_list(include_hidden = False) 28 | Returns an Array of apps and some basic info: 29 | Path, name, default, hidden. 30 | Hidden apps are excluded by default. 31 | 32 | default_app(app name = '', fall_back = None): 33 | If app name is '' (empty string) or not passed at all, no default will be set. 34 | If fall_back is set (and is a valid app based on name), at the next reset/boot that will become the default app to run. 35 | When no parameter is passed, the function returns the current default app or `None` 36 | 37 | export_app('{app name}') - requires `tarfile-write` to be installed: 38 | Creates a .tar archive of the app with the given name if valid. 39 | The archive file will be named appending a timestamp to the app's name. 40 | The archive will be saved in '/__ampexports/filename.tar' 41 | 42 | import_app('{archive path}', force_overwrite = False): 43 | Expands the .tar archive into a app folder. 44 | Requires confirmation if app exists unless force_overwrite is True. 45 | 46 | delete_folder('{folder path}', [force_confirm = False]): 47 | Will attempt to delete the folder and all its content recursively. 48 | If force_confirm is set to True it will not ask for confirmation at each file/folder. 49 | 50 | read_file('{file path}'): 51 | Will read the content of the file and output it to REPL 52 | 53 | --- WiFi Utilities --- 54 | 55 | auto_connect(progress_callback = None): 56 | Automatically connects to known networks. Returns True on success. 57 | Optional progress_callback function for connection updates. 58 | 59 | connect(ssid = None, key = None, interface = None, timeout = 10, display_progress = False, progress_callback = None): 60 | Connect to WiFi network. If no params provided, tries auto_connect then interactive mode. 61 | Returns network interface on success, None on failure. 62 | 63 | connect_to_network(id = None): 64 | Interactive WiFi connection. Lists available networks if no id provided. 65 | Prompts for password and saves credentials on successful connection. 66 | 67 | wifi_scan(force_scan = False): 68 | Scans for available WiFi networks. Uses cache unless force_scan=True. 69 | Returns list of network tuples (ssid, mac, channel, rssi, security, hidden). 70 | 71 | list_networks(rescan = False): 72 | Lists available WiFi networks with formatted output. 73 | Rescans if rescan=True. 74 | 75 | print_scan_results(): 76 | Pretty prints all scanned networks (or stored one if within `_NETWORK_CACHE_LIFE`) 77 | 78 | get_network_qrcode_string(ssid, password, security): 79 | Returns QR code string for WiFi network sharing. 80 | 81 | print_network_details(interface = _net_if): 82 | Prints network connection details (IP, MAC, gateway, etc.). 83 | `interface` defaults to `_net_if` which is `None` when not initialised. 84 | 85 | --- Access Point Mode --- 86 | 87 | init_ap(ssid, key): 88 | Initializes device as WiFi Access Point with given credentials. 89 | 90 | get_ap_settings(): 91 | retrieves Access Point configuration 92 | 93 | 94 | --- File System Helpers --- 95 | 96 | fs_root(): 97 | Changes current directory to filesystem root ('/'). 98 | 99 | fs_getpath(): 100 | Returns current working directory path. 101 | 102 | print_directory(path = '.'): 103 | Prints formatted directory tree structure. 104 | 105 | get_directory_tree(path = '.', order = -1): 106 | Returns directory tree as list of tuples (depth, path, name, is_folder). 107 | 108 | is_directory(path): 109 | Returns True if path is a directory, False if file. 110 | 111 | --- Console Utilities --- 112 | 113 | show_cursor(show = True): 114 | Show or hide terminal cursor. 115 | 116 | clear_terminal(cursor_visible = True): 117 | Clear terminal screen and optionally set cursor visibility. 118 | 119 | show_commands(): 120 | Display this help text in terminal. 121 | 122 | --- Hash Utilities --- 123 | 124 | get_hash(file_path): 125 | Calculate SHA256 hash of file content. 126 | -------------------------------------------------------------------------------- /arduino_tools/utils/semver.py: -------------------------------------------------------------------------------- 1 | class SemVer: 2 | def __init__(self, major, minor, patch, pre_release=None): 3 | self.major = major 4 | self.minor = minor 5 | self.patch = patch 6 | self.pre_release = pre_release 7 | 8 | @classmethod 9 | def from_string(cls, version_str): 10 | version_parts = version_str.split(".") 11 | major = int(version_parts[0]) 12 | minor = int(version_parts[1]) 13 | patch_and_pre_release = version_parts[2] 14 | 15 | pre_release = None 16 | if "-" in patch_and_pre_release: 17 | patch, pre_release = patch_and_pre_release.split("-") 18 | # Split pre-release into parts if it contains dots 19 | pre_release = pre_release.split(".") 20 | else: 21 | patch = patch_and_pre_release 22 | patch = int(patch) 23 | return cls(major, minor, patch, pre_release) 24 | 25 | def __str__(self): 26 | version_str = f"{self.major}.{self.minor}.{self.patch}" 27 | if self.pre_release: 28 | version_str += "-" + ".".join(self.pre_release) 29 | return version_str 30 | 31 | def __repr__(self): 32 | return f"SemVer({self.major}, {self.minor}, {self.patch}, {self.pre_release})" 33 | 34 | def __eq__(self, other): 35 | if isinstance(other, SemVer): 36 | return (self.major, self.minor, self.patch, self.pre_release) == ( 37 | other.major, 38 | other.minor, 39 | other.patch, 40 | other.pre_release, 41 | ) 42 | return NotImplemented 43 | 44 | def __lt__(self, other): 45 | if isinstance(other, SemVer): 46 | # Compare version numbers first 47 | if (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch): 48 | return True 49 | elif (self.major, self.minor, self.patch) > (other.major, other.minor, other.patch): 50 | return False 51 | # If version numbers are equal, consider pre-release 52 | else: 53 | if self.pre_release and not other.pre_release: 54 | return True # Pre-release is less than release 55 | elif not self.pre_release and other.pre_release: 56 | return False # Release is greater than pre-release 57 | elif self.pre_release and other.pre_release: 58 | return self.pre_release < other.pre_release 59 | else: # Both are releases or both are None 60 | return False # Equal versions 61 | return NotImplemented 62 | 63 | def __gt__(self, other): 64 | if isinstance(other, SemVer): 65 | # Compare version numbers first 66 | if (self.major, self.minor, self.patch) > (other.major, other.minor, other.patch): 67 | return True 68 | elif (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch): 69 | return False 70 | # If version numbers are equal, consider pre-release 71 | else: 72 | if self.pre_release and not other.pre_release: 73 | return False # Pre-release is less than release 74 | elif not self.pre_release and other.pre_release: 75 | return True # Release is greater than pre-release 76 | elif self.pre_release and other.pre_release: 77 | return self.pre_release > other.pre_release 78 | else: # Both are releases or both are None 79 | return False # Equal versions 80 | return NotImplemented 81 | 82 | # Add these for complete comparison operations 83 | def __le__(self, other): 84 | return self < other or self == other 85 | 86 | def __ge__(self, other): 87 | return self > other or self == other 88 | 89 | def increment_major(self): 90 | self.major += 1 91 | self.minor = 0 92 | self.patch = 0 93 | self.pre_release = None 94 | 95 | def increment_minor(self): 96 | self.minor += 1 97 | self.patch = 0 98 | self.pre_release = None 99 | 100 | def increment_patch(self): 101 | self.patch += 1 102 | self.pre_release = None 103 | 104 | def set_pre_release(self, pre_release): 105 | self.pre_release = pre_release 106 | 107 | if __name__ == "__main__": 108 | # Test cases 109 | cv = SemVer.from_string('0.7.0') 110 | nv = SemVer.from_string('0.7.0') 111 | print(f"cv: {cv}, nv: {nv}") 112 | print(f"cv < nv: {cv < nv}") # Should be False 113 | print(f"cv > nv: {cv > nv}") # Should be False 114 | print(f"cv == nv: {cv == nv}") # Should be True 115 | 116 | # Additional test cases 117 | v1 = SemVer.from_string('1.0.0') 118 | v2 = SemVer.from_string('1.1.0') 119 | v3 = SemVer.from_string('1.0.1') 120 | v4 = SemVer.from_string('1.0.0-alpha') 121 | v5 = SemVer.from_string('1.0.0-beta') 122 | 123 | print("\nAdditional test cases:") 124 | print(f"1.0.0 < 1.1.0: {v1 < v2}") # Should be True 125 | print(f"1.0.0 < 1.0.1: {v1 < v3}") # Should be True 126 | print(f"1.0.0-alpha < 1.0.0: {v4 < v1}") # Should be True 127 | print(f"1.0.0-alpha < 1.0.0-beta: {v4 < v5}") # Should be True -------------------------------------------------------------------------------- /shell_tools/_common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | APPS_PREFIX="app_" 4 | ARDUINO_TOOLS_MIP_URL="github:arduino/arduino-tools-mpy" 5 | 6 | PYTHON_HELPERS=''' 7 | import os 8 | import sys 9 | 10 | def get_root(has_flash_mount = True): 11 | if "/flash" in sys.path: 12 | return "/flash/" 13 | else: 14 | return "/" 15 | 16 | os.chdir(get_root()) 17 | 18 | from arduino_tools.apps_manager import create_app, export_app 19 | 20 | def is_directory(path): 21 | return True if os.stat(path)[0] == 0x4000 else False 22 | 23 | def get_all_files(path, array_of_files = []): 24 | files = os.ilistdir(path) 25 | for file in files: 26 | is_folder = file[1] == 16384 27 | p = path + "/" + file[0] 28 | array_of_files.append({ 29 | "path": p, 30 | "type": "folder" if is_folder else "file" 31 | }) 32 | if is_folder: 33 | array_of_files = get_all_files(p, array_of_files) 34 | return array_of_files 35 | 36 | def delete_folder(path): 37 | files = get_all_files(path) 38 | for file in files: 39 | if file["type"] == "file": 40 | os.remove(file["path"]) 41 | for file in reversed(files): 42 | if file["type"] == "folder": 43 | os.rmdir(file["path"]) 44 | os.rmdir(path) 45 | 46 | def sys_info(): 47 | import sys 48 | print(sys.platform, sys.implementation.version) 49 | 50 | def list_apps(): 51 | import os 52 | from arduino_tools.apps_manager import get_apps_list 53 | apps = get_apps_list() 54 | apps_names = "" 55 | for i, app in enumerate(apps): 56 | if i != 0: 57 | apps_names += " " 58 | apps_names += app["name"] 59 | print(apps_names) 60 | 61 | os.chdir(get_root()) 62 | ''' 63 | 64 | ARDUINO_TOOLS_CHECK=''' 65 | import sys 66 | sys.path.insert(0, "/lib") 67 | try: 68 | from arduino_tools.apps_manager import create_app 69 | from tarfile import write 70 | except ImportError as e: 71 | print("Error: ") 72 | 73 | ''' 74 | 75 | function get_apps_root { 76 | output_msg="Extracting board's apps root" 77 | # echo -n "⏳ $output_msg" >&2 78 | get_root="${PYTHON_HELPERS}print(get_root())" 79 | result=$(mpremote exec "$get_root" 2>&1) 80 | # Print result message if return code is not 0 81 | if [ $? -ne 0 ]; then 82 | echo >&2 83 | echo "Error: $error" >&2 84 | exit 1 85 | fi 86 | echo -ne "\r\033[2K" >&2 87 | # echo -e "\r☑️ $output_msg: $result" >&2 88 | echo "$result" 89 | } 90 | 91 | function check_arduino_tools { 92 | error=$(mpremote exec "$ARDUINO_TOOLS_CHECK") 93 | if [[ $error == *"Error"* ]]; then 94 | return 1 95 | else 96 | return 0 97 | fi 98 | } 99 | 100 | # Check if device is present/connectable 101 | # returns 0 if device is present, 1 if it is not 102 | function device_present { 103 | # Run mpremote and capture the error message 104 | output="Querying MicroPython board..." >&2 105 | echo -ne "⏳ $output" >&2 106 | 107 | sys_info="${PYTHON_HELPERS}sys_info()" 108 | error=$(mpremote exec "$sys_info") 109 | echo -ne "\r\033[2K" >&2 110 | echo -e "\r☑️ $output" >&2 111 | # Return error if error message contains "OSError: [Errno 2] ENOENT" 112 | if [[ $error == *"no device found"* ]]; then 113 | return 1 114 | else 115 | return 0 116 | fi 117 | } 118 | 119 | function directory_exists { 120 | # Run mpremote and capture the error message 121 | output="Checking if \"$1\" exists on board" 122 | echo -ne "❔ $output" >&2 123 | 124 | error=$(mpremote fs ls $1 2>&1) 125 | echo -ne "\r\033[2K" >&2 126 | echo -e "\r☑️ $output" >&2 127 | # Return error if error message contains "OSError: [Errno 2] ENOENT" 128 | # echo -ne "--- $error" >&2 129 | if [[ $error == *"OSError: [Errno 2] ENOENT"* || $error == *"No such"* ]]; then 130 | return 1 131 | else 132 | return 0 133 | fi 134 | 135 | } 136 | 137 | # Copies a file to the board using mpremote 138 | # Only produces output if an error occurs 139 | function copy_file { 140 | output="Copying $1 to $2" 141 | echo -n "⏳ $output" >&2 142 | # Run mpremote and capture the error message 143 | error=$(mpremote cp $1 $2) 144 | # Print error message if return code is not 0 145 | if [ $? -ne 0 ]; then 146 | echo "Error: $error" >&2 147 | fi 148 | echo -ne "\r\033[2K" >&2 149 | echo -e "\r☑️ $output" >&2 150 | } 151 | 152 | # Deletes a file from the board using mpremote 153 | # Only produces output if an error occurs 154 | function delete_file { 155 | echo "Deleting $1" 156 | # Run mpremote and capture the error message 157 | error=$(mpremote rm $1) 158 | 159 | # Print error message if return code is not 0 160 | if [ $? -ne 0 ]; then 161 | echo "Error: $error" 162 | fi 163 | } 164 | 165 | function create_folder { 166 | output_msg="Creating $1 on board" 167 | echo -n "⏳ $output_msg" >&2 168 | error=$(mpremote mkdir "$1") 169 | # Print error message if return code is not 0 170 | if [ $? -ne 0 ]; then 171 | echo "Error: $error" >&2 172 | fi 173 | echo -ne "\r\033[2K" >&2 174 | echo -e "\r☑️ $output_msg" >&2 175 | } 176 | 177 | function delete_folder { 178 | output_msg="Deleting $1 on board" 179 | echo -n "⏳ $output_msg" >&2 180 | delete_folder="${PYTHON_HELPERS}delete_folder(\"/$1\")" 181 | error=$(mpremote exec "$delete_folder") 182 | # Print error message if return code is not 0 183 | if [ $? -ne 0 ]; then 184 | echo "Error: $error" >&2 185 | fi 186 | echo -ne "\r\033[2K" >&2 187 | echo -e "\r☑️ $output_msg" >&2 188 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arduino Tools for MicroPython 2 | 3 | This package adds functionalities for 4 | 5 | * MicroPython Apps Framework 6 | * File system helpers 7 | * WiFi network management 8 | 9 | ## Installation 10 | 11 | ### Using Lab for MicroPython / MicroPython Package Installer 12 | 13 | If you are using [Arduino Lab for MicroPython](https://labs.arduino.cc/en/labs/micropython) to work on your MicroPython projects, you can use the button "Install Package". This will launch [MicroPython Package Installer](https://labs.arduino.cc/en/labs/micropython-package-installer) or take you to the web page to download and install it. 14 | Once installed, running it once will make sure that the Editor finds it at next time the button is pressed. 15 | 16 | ![Install package](assets/package_installer_button.png) 17 | 18 | This tool simplifies installing packages from both the official MicroPython index and Arduino's curated package index on any MicroPython board. 19 | 20 | ### Using `mpremote` 21 | 22 | We must specify the target especially if the framework is already installed and a default app is present, since `mpremote mip` will use the current path to look for or create the `lib` folder. 23 | We want to make sure the tools are accessible from every application/location. 24 | 25 | ```bash 26 | mpremote mip install --target=[flash]/lib github:arduino/arduino-tools-mpy 27 | ``` 28 | 29 | ### Using `mip` from the board. 30 | 31 | First make sure your board is connected to a network, or `mip` will fail. 32 | 33 | ```python 34 | import mip 35 | mip.install('github:arduino/arduino-tools-mpy', target='[flash]/lib') 36 | ``` 37 | 38 | ## MicroPython Apps Framework 39 | 40 | A set of tools and helpers to implement, create and manage MicroPython Apps. 41 | 42 | A new approach to enabling a MicroPython board to host/store multiple projects with the choice of running one as default, as well as have a mechanism of fallback to a default launcher. 43 | It does not interfere with the canonical `boot.py` > `main.py` run paradigm, and allows users to easily activate this functionality on top of any stock MicroPython file-system. 44 | 45 | The Arduino MicroPython App framework relies on the creation of aptly structured projects/apps enclosed in their own folders named "app_{app-name}", which in turn contain a set of files (`main.py`, `lib/`, `app.json`, etc.). 46 | These are the conditions for a project/app to be considered "valid". 47 | Other files can be added to user's discretion, for instance to store assets or log/save data. 48 | 49 | The framework exploits the standard behaviour of MicroPython at start/reset/soft-reset: 50 | 51 | 1. run `boot.py` 52 | 1. run `main.py` 53 | 54 | The framework's boot.py only requires two lines for the following operations: 55 | 56 | * import the minimum required parts of arduino_tools (common) from the board's File System (installed as a package in [flash]/lib/arduino_tools) 57 | * invoke the method `load_app(app_name = None, cycle_mode = False)` to enter the default app's path and apply some temporary settings to configure the running environment (search paths and launch configuration changes) which will be reset at the next start. 58 | 59 | If no default app is set, it will fall back to the `main.py` in the board's root if present. 60 | No error condition will be generated, as MicroPython is capable of handling the absence of `boot.py` and/or `main.py` at C level. 61 | 62 | If a default app is set, the `load_app(app_name = None, cycle_mode = False)` will issue an `os.chdir()` command and enter the app's folder. 63 | MicroPython will automatically run the main.py it finds in its Current Working Directory. 64 | 65 | `cycle_mode`: when this parameter is `True`, the loader will pop first item from the `boot.cfg` file and append it to the end. 66 | This could be useful if you have a board in demo mode (needing to display multiple applications) or need to run applications in a sequence for RAM or features constraints. 67 | You could think of a board that needs to connect to Internet to download data, but would not be able to process the data with the RAM available. 68 | 69 | **NOTES:** 70 | 71 | * each app can contain a `.hidden` file that will hide the app from AMP, effectively preventing listing or deletion. 72 | The `list_apps()` command accepts a `skip_hidden = False` parameter to return every app, not just the visible ones. 73 | 74 | * each app should contain a metadata file named `app.json` 75 | 76 | ```json 77 | { 78 | "name": "", 79 | "friendly_name": "", 80 | "author": "", 81 | "created": 0, 82 | "modified": 0, 83 | "version": "M.m.p", 84 | "origin_url": "https://arduino.cc", 85 | "tools_version": "M.m.p" 86 | } 87 | ``` 88 | 89 | * while some fields should be mandatory ("name", "tools_version") others could not be required, especially for students apps who do not care about versions or source URL. 90 | We should also handle if extra fields are added not to break legacy. 91 | Still WIP. 92 | 93 | * AMP can replace/update an app with the contents of a properly structured `.tar` archive. 94 | This is useful for updating versions of apps/launcher. 95 | An app launcher could be delegated to checking for available updates to any of the other apps it manages. 96 | 97 | ### How to setup 98 | 99 | **NOTE:** The API is not yet final, hence subject to changes. 100 | Same goes for the name of modules. 101 | 102 | The only requirement is that all the files in `arduino_tools` should be transferred to the board using one's preferred method. 103 | Best practice is to copy all the files in the board's `[flash]/lib/arduino_tools`, which is what happens when installing with the `mip` tool (or `mpremote mip`). 104 | 105 | Enter a REPL session 106 | 107 | ```python 108 | from arduino_tools.app_manager import * 109 | show_commands() 110 | ``` 111 | 112 | read through the commands to know more. 113 | 114 | To enable the apps framework run 115 | `enable_apps()` 116 | 117 | The current `boot.py` (if present) will be backed up to `boot_backup.py`. 118 | Any other file, including the `main.py` in the root (if present), will remain untouched. 119 | 120 | `disable_apps()` will restore boot.py from boot_backup.py if it was previously created. 121 | The method accepts a parameter `force_delete_boot` which defaults to `False` 122 | 123 | If no backup file will be found it will ask the following: 124 | 125 | This operation will delete "boot.py" from your board. 126 | You can choose to: 127 | A - Create a default one 128 | B - Proceed to delete 129 | C - Do nothing (default) 130 | 131 | unless `disable_apps(True)` is invoked, which will force the choice to be B. 132 | 133 | Setting the default app to '' (default_app('')) will also generate a choice menu. 134 | 135 | The above behaviour is the result of Q&A sessions with other MicroPython developers and might be subject to change until a v1.0.0 is released. 136 | 137 | ### Basic usage 138 | 139 | Note: creating an app and giving it a name with unallowed characters will replace them with an underscore (`_`). 140 | 141 | Enable AMP and create a few apps 142 | 143 | ```shell 144 | >>> from arduino_tools.apps_manager import * 145 | >>> enable_apps() 146 | 147 | >>> create_app('abc') 148 | >>> create_app('def') 149 | >>> create_app('ghi') 150 | >>> create_app('new app') # space will be converted to _ 151 | >>> create_app('friendly_name', 'App friendly name') # This name will be sanitised and used as a human-readable one 152 | 153 | 154 | >>> list_apps() 155 | abc 156 | def 157 | ghi 158 | new_app 159 | friendly_name 160 | 161 | 162 | >>> default_app() 163 | '' 164 | 165 | >>> default_app('def') 166 | >>> default_app() 167 | 'def' 168 | 169 | >>> list_apps() 170 | abc 171 | * def 172 | ghi 173 | new_app 174 | friendly_name 175 | 176 | >>> import machine 177 | >>> machine.soft_reset 178 | 179 | MPY: soft reboot 180 | Hello, I am an app and my name is def 181 | MicroPython v1.23.0-preview.138.gdef6ad474 on 2024-02-16; Arduino Nano ESP32 with ESP32S3 182 | Type "help()" for more information. 183 | >>> 184 | ``` 185 | 186 | ### Advanced Usage 187 | 188 | #### Restore script (very experimental) 189 | 190 | The restore script allows to have a way of restoring a default app at boot. 191 | This script may respond to a hardware condition such as a pressed pin in order to set the default app to run. 192 | This could be a menu or configuration script. 193 | Can be used as a fault-recovery method. 194 | See `generated_example/boot_restore.py`. 195 | -------------------------------------------------------------------------------- /arduino_tools/apps_manager.py: -------------------------------------------------------------------------------- 1 | from .common import * 2 | from .properties import * 3 | from .files import * 4 | from .loader import * 5 | from .helpers import * 6 | import errno 7 | 8 | import os 9 | 10 | import json 11 | from time import time as tm 12 | 13 | try: 14 | import mip 15 | MIP_SUPPORT = True 16 | except ImportError: 17 | MIP_SUPPORT = False 18 | 19 | try: 20 | import tarfile 21 | 22 | except ImportError: 23 | print('tarfile not installed') 24 | print('install tarfile-write') 25 | 26 | 27 | BOOT_BACKUP_FILE = 'boot_backup.py' 28 | EXPORT_FOLDER = f'__{APP_PREFIX}exports' 29 | BACKUP_FOLDER = f'__{APP_PREFIX}backups' 30 | 31 | app_data_cache = {} 32 | 33 | def enable_apps(): 34 | fs_root() 35 | if fs_item_exists(BOOT_FILE): 36 | os.rename(BOOT_FILE, BOOT_BACKUP_FILE) 37 | 38 | success, message, exception = template_to_file('boot_apps.tpl', f'{APPS_ROOT}{BOOT_FILE}') 39 | if not success: 40 | print(f'Error creating {BOOT_FILE}: {message}') 41 | return None 42 | 43 | if not fs_item_exists(BOOT_CONFIG_FILE): 44 | config_file = open(BOOT_CONFIG_FILE, 'w') 45 | config_file.close() 46 | 47 | 48 | def disable_apps(force_delete_boot = False): 49 | fs_root() 50 | if fs_item_exists(BOOT_BACKUP_FILE): 51 | os.rename(BOOT_BACKUP_FILE, BOOT_FILE) 52 | else: 53 | show_cursor(False) 54 | if not force_delete_boot: 55 | choice = input(f''' 56 | This operation will delete {BOOT_FILE} from your board. 57 | You can choose to: 58 | A - Create plain {BOOT_FILE} 59 | B - No {BOOT_FILE} 60 | C - Do nothing\n''').strip() or 'C' 61 | choice = choice.upper() 62 | else: 63 | choice = 'B' 64 | 65 | if choice == 'A': 66 | create_plain_boot() 67 | if choice == 'B': 68 | if fs_item_exists(f'/{BOOT_FILE}'): 69 | os.remove(f'/{BOOT_FILE}') 70 | 71 | if choice == 'A' or choice == 'B': 72 | show_cursor() 73 | return True 74 | 75 | show_cursor() 76 | print(f'{APPS_FRAMEWORK_NAME} still enabled') 77 | return False 78 | 79 | def install_package(package = None, app = None, url = None): 80 | if not MIP_SUPPORT: 81 | print('mip not supported') 82 | return False 83 | lib_install_path = '/lib' 84 | if app: 85 | lib_install_path = get_app(app)['path'] + lib_install_path 86 | if not url: 87 | mip.install(package, target=lib_install_path) 88 | else: 89 | mip.install(url, target=lib_install_path) 90 | 91 | 92 | # Managing apps 93 | 94 | def create_app(app_name = None, friendly_name = None, set_default = False, hidden = False): 95 | a_name = app_name or f'py_{tm()}' 96 | a_name = "".join(c for c in a_name if c.isalpha() or c.isdigit() or c==' ' or c == '_').rstrip() 97 | a_name = a_name.replace(' ', '_') 98 | if validate_app(app_name): 99 | return(OSError(errno.EEXIST, f'App {app_name} already exists')) 100 | app_path = f'{APPS_ROOT}{APP_PREFIX}{a_name}' 101 | 102 | if fs_item_exists(app_path): 103 | return(OSError(errno.EEXIST, f'Folder {app_path} already exists')) 104 | os.mkdir(app_path) 105 | os.mkdir(app_path + '/lib') 106 | app_friendly_name = friendly_name or a_name 107 | success, message, exception = template_to_file('main.tpl', f'{app_path}/{MAIN_FILE}', app_name = a_name, app_friendly_name = app_friendly_name) 108 | if not success: 109 | print(f'Error creating {MAIN_FILE}: {message}') 110 | return None 111 | 112 | md = app_properties_template.copy() 113 | md['name'] = a_name 114 | if friendly_name: 115 | md['friendly_name'] = friendly_name 116 | 117 | 118 | md['tools_version'] = TOOLS_VERSION 119 | with open(f'{app_path}/{APP_PROPERTIES}', 'w') as config_file: 120 | json.dump(md, config_file) 121 | 122 | 123 | if friendly_name: 124 | set_friendly_name(a_name, friendly_name) 125 | if hidden: 126 | hide_app(a_name) 127 | 128 | if set_default: 129 | default_app(app_name) 130 | return md 131 | 132 | 133 | def set_friendly_name(app_name, friendly_name): 134 | if not validate_app(app_name): 135 | print(f'{app_name} is not a valid app') 136 | return 137 | with open(get_app(app_name)['path']+'/'+APP_FRIENDLY_NAME_FILE, 'w') as friendly_name_file: 138 | friendly_name_file.write(friendly_name) 139 | set_app_properties(app_name, friendly_name = friendly_name) 140 | 141 | def set_app_visibility(app_name, visible = True): 142 | if not validate_app(app_name): 143 | print(f'app {app_name} does not exist') 144 | return False 145 | 146 | app_path = f'{APPS_ROOT}{APP_PREFIX}{app_name}' 147 | if visible: 148 | if fs_item_exists(f'{app_path}/{APP_HIDDEN_FILE}'): 149 | os.remove(f'{app_path}/{APP_HIDDEN_FILE}') 150 | else: 151 | with open(f'{app_path}/{APP_HIDDEN_FILE}', 'w') as hidden_file: 152 | hidden_file.write('# this app is hidden') 153 | return True 154 | 155 | def hide_app(app_name = None): 156 | return(set_app_visibility(app_name, False)) 157 | 158 | def unhide_app(app_name = None): 159 | return(set_app_visibility(app_name, True)) 160 | 161 | def delete_app(app_name = None, force_confirm = False): 162 | app_name = app_name.replace(APP_PREFIX, '') 163 | if validate_app(app_name): 164 | folder_name = APP_PREFIX + app_name 165 | is_default = app_name == default_app() 166 | is_default_message = f'"{app_name}" is your default app.\n' if is_default else '' 167 | confirm = input(f'{is_default_message}Are you sure you want to delete {app_name}? [Y/n]') if not force_confirm else 'Y' 168 | if confirm == 'Y': 169 | if is_default: 170 | default_app('') 171 | print(f'Deleting app {app_name}') 172 | fs_root() 173 | delete_folder(f'{APPS_ROOT}{folder_name}', confirm) 174 | return True 175 | else: 176 | print(f'Project {app_name} not deleted') 177 | return False 178 | else: 179 | return False 180 | 181 | 182 | def export_app(app_name = None): 183 | if validate_app(app_name): 184 | export_folder = f'{APPS_ROOT}{EXPORT_FOLDER}' 185 | if not fs_item_exists(export_folder): 186 | os.mkdir(export_folder) 187 | exported_file_path = f'{export_folder}/{app_name}.tar' 188 | if fs_item_exists(exported_file_path): 189 | exported_file_path = f'{export_folder}/{app_name}_{tm()}.tar' 190 | 191 | archive = tarfile.TarFile(exported_file_path, 'w') 192 | app_folder = APP_PREFIX + app_name 193 | app_path = f'{APPS_ROOT}{app_folder}' 194 | archive.add(app_path) 195 | archive.close() 196 | return exported_file_path 197 | return(OSError(errno.EINVAL, f'{app_name} is not a valid app')) 198 | 199 | 200 | def import_app(archive_path = None, force_overwrite = False): 201 | 202 | backup_folder = f'{APPS_ROOT}{BACKUP_FOLDER}' 203 | if not fs_item_exists(backup_folder): 204 | os.mkdir(backup_folder) 205 | 206 | 207 | if not fs_item_exists(archive_path): 208 | print(f'App archive {archive_path} does not exist') 209 | return False 210 | else: 211 | archive_file = tarfile.TarFile(archive_path, 'r') 212 | os.chdir(APPS_ROOT) 213 | first_tar_item = True 214 | confirm_delete_backup = 'n' 215 | confirm_overwrite = 'n' 216 | for item in archive_file: 217 | if item.type == tarfile.DIRTYPE: 218 | if first_tar_item: 219 | app_name = item.name.strip('/') 220 | app_backup_folder = app_name+'_'+str(tm()) 221 | backup_app_path = f'{backup_folder}/{app_name}' 222 | if fs_item_exists(backup_app_path): 223 | backup_app_path = f'{backup_folder}/{app_name}_{tm()}' 224 | 225 | if fs_item_exists(item.name.strip('/')): 226 | confirm_overwrite = input(f'Do you want to overwrite {app_name}? [Y/n]: ') if not force_overwrite else 'Y' 227 | if confirm_overwrite == 'Y': 228 | print('Backing up existing app to', app_backup_folder) 229 | os.rename(app_name, backup_app_path) 230 | else: 231 | print('Operation canceled.') 232 | return 233 | os.mkdir(item.name.strip('/')) 234 | else: 235 | f = archive_file.extractfile(item) 236 | with open(item.name, "wb") as of: 237 | of.write(f.read()) 238 | first_tar_item = False 239 | confirm_delete_backup = input(f'Delete backup folder {app_backup_folder}? [Y/n]: ') if not force_overwrite else 'Y' 240 | if confirm_delete_backup == 'Y': 241 | delete_folder(backup_app_path, 'Y') 242 | os.remove(archive_path) 243 | return True 244 | 245 | 246 | # PLACEHOLDER 247 | # this method will allow us to define which properties are required for this app 248 | # might turn useful for internet connection details, etc. 249 | def set_required_app_properties(app_name, **keys): 250 | if not validate_app(app_name) : 251 | raise ValueError(f'Invalid app: {app_name}') 252 | 253 | def list_apps(return_list = False, include_hidden = False): 254 | apps_list = [] 255 | for app in get_apps(): 256 | if app['hidden'] and not include_hidden: 257 | continue 258 | app['default'] = (default_app() == app['name']) 259 | if return_list: 260 | apps_list.append(app.copy()) 261 | else: 262 | print(f'{'*' if default_app() == app['name'] else ' '} {app['name']}') 263 | apps_list= sorted(apps_list, key = lambda d: d['name']) 264 | if return_list: 265 | return apps_list 266 | 267 | def get_apps_list(include_hidden = False): 268 | return list_apps(return_list = True, include_hidden= include_hidden) 269 | 270 | -------------------------------------------------------------------------------- /mpinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # MicroPython Package Installer 4 | # Created by: Ubi de Feo and Sebastian Romero 5 | # 6 | # Installs MicroPython Packages to the /lib folder of a board using mpremote. 7 | # 8 | # - Installation is recursive, so all files and folders in the package directory. 9 | # - Supports multiple packages and optional arguments. 10 | # - Accepts optional argument to compile .py files to .mpy. [--mpy] 11 | # - Accepts optional argument to skip resetting the board. [--no-reset] 12 | # 13 | # ./install.sh ... [--mpy][--no-reset] 14 | 15 | PYTHON_HELPERS=''' 16 | import os 17 | import sys 18 | 19 | def is_directory(path): 20 | return True if os.stat(path)[0] == 0x4000 else False 21 | 22 | def get_all_files(path, array_of_files = []): 23 | files = os.ilistdir(path) 24 | for file in files: 25 | is_folder = file[1] == 16384 26 | p = path + "/" + file[0] 27 | array_of_files.append({ 28 | "path": p, 29 | "type": "folder" if is_folder else "file" 30 | }) 31 | if is_folder: 32 | array_of_files = get_all_files(p, array_of_files) 33 | return array_of_files 34 | 35 | def delete_folder(path): 36 | files = get_all_files(path) 37 | for file in files: 38 | if file["type"] == "file": 39 | os.remove(file["path"]) 40 | for file in reversed(files): 41 | if file["type"] == "folder": 42 | os.rmdir(file["path"]) 43 | os.rmdir(path) 44 | 45 | def sys_info(): 46 | import sys 47 | print(sys.platform, sys.implementation.version) 48 | 49 | def get_root(has_flash_mount = True): 50 | if "/flash" in sys.path: 51 | return "/flash/" 52 | else: 53 | return "/" 54 | 55 | os.chdir(get_root()) 56 | ''' 57 | 58 | # Check if device is present/connectable 59 | # returns 0 if device is present, 1 if it is not 60 | function device_present { 61 | # Run mpremote and capture the error message 62 | echo "Checking if a MicroPython board is available..." 63 | sys_info="${PYTHON_HELPERS}sys_info()" 64 | error=$(mpremote exec "$sys_info") 65 | # Return error if error message contains "OSError: [Errno 2] ENOENT" 66 | if [[ $error == *"no device found"* ]]; then 67 | return 0 68 | else 69 | return 1 70 | fi 71 | } 72 | 73 | 74 | # Check if a directory exists 75 | # Returns 0 if directory exists, 1 if it does not 76 | function folder_exists { 77 | # Run mpremote and capture the error message 78 | error=$(mpremote fs ls "$1" 2>&1) 79 | # Return error if error message contains "ENOENT" or "No such file or directory" (>= 1.26.0) 80 | if [[ $error == *"ENOENT"* ]] || [[ $error == *"No such file or directory"* ]]; then 81 | return 1 82 | else 83 | return 0 84 | fi 85 | } 86 | 87 | # Copies a file to the board using mpremote 88 | # Only produces output if an error occurs 89 | function copy_file { 90 | output="Copying file to board: $1 >> $2" 91 | echo -n "$output" 92 | # Run mpremote and capture the error message 93 | error=$(mpremote cp $1 $2) 94 | # Print error message if return code is not 0 95 | if [ $? -ne 0 ]; then 96 | echo "Error: $error" 97 | fi 98 | echo -ne "\r\033[2K" 99 | echo -e "\r√ $output" 100 | } 101 | 102 | # Deletes a file from the board using mpremote 103 | # Only produces output if an error occurs 104 | function delete_file { 105 | output="Deleting file on board: $1" 106 | echo -n "$output" 107 | # Run mpremote and capture the error message 108 | error=$(mpremote rm $1) 109 | # Print error message if return code is not 0 110 | if [ $? -ne 0 ]; then 111 | echo "Error: $error" 112 | fi 113 | echo -ne "\r\033[2K" 114 | echo -e "\r√ $output" 115 | } 116 | 117 | function create_folder { 118 | output_msg="Creating folder on board: $1" 119 | echo -n "$output_msg" 120 | error=$(mpremote mkdir "$1") 121 | # Print error message if return code is not 0 122 | if [ $? -ne 0 ]; then 123 | echo "Error: $error" 124 | fi 125 | echo -ne "\r\033[2K" 126 | echo -e "\r√ $output_msg" 127 | } 128 | 129 | function delete_folder { 130 | output_msg="Deleting Folder on board: $1" 131 | echo -n "$output_msg" 132 | delete_folder="${PYTHON_HELPERS}delete_folder(\"/$1\")" 133 | error=$(mpremote exec "$delete_folder") 134 | # Print error message if return code is not 0 135 | if [ $? -ne 0 ]; then 136 | echo "Error: $error" 137 | fi 138 | echo -ne "\r\033[2K" 139 | echo -e "\r√ $output_msg" 140 | } 141 | 142 | function get_board_lib_path { 143 | device_root="${PYTHON_HELPERS}get_root()" 144 | output=$(mpremote exec "$device_root") 145 | output=$(echo "$output" | tr -d '[:space:]') 146 | if [[ -n "$output" ]]; then 147 | echo "$output/lib" 148 | else 149 | echo "lib" 150 | fi 151 | } 152 | 153 | function install_package { 154 | if [[ $1 == "" ]]; then 155 | echo "!!! No package path supplied !!!" 156 | exit 1 157 | fi 158 | # Name to display during installation 159 | PKGNAME=`basename $1` 160 | # Destination directory for the package on the board 161 | PKGDIR=`basename $1` 162 | # Source directory for the package on the host 163 | SRCDIR=`realpath .` 164 | 165 | LIBDIR="$(get_board_lib_path)" 166 | echo "Installing package $PKGNAME from $SRCDIR to $LIBDIR/$PKGDIR" 167 | 168 | IFS=$'\n' read -rd '' -a package_files < <(find . -mindepth 1) 169 | items_count=${#package_files[@]} 170 | current_item=0 171 | for item_path in "${package_files[@]}"; do 172 | destination_subpath="${item_path//.\//$PKGNAME/}" 173 | if [ ! -f "$item_path" ] && [ ! -d "$item_path" ]; then 174 | echo -n "symlink file ignored" 175 | continue 176 | else 177 | current_item=$((current_item+1)) 178 | # only delete and create package directory if it is the first item 179 | # if the script never made it here, it means no files were found 180 | if [ $current_item == 1 ]; then 181 | output_msg="Deleting Package folder on board: $LIBDIR/$PKGDIR" 182 | if folder_exists "${LIBDIR}/${PKGDIR}"; then 183 | echo -n "$output_msg" 184 | delete_folder="${PYTHON_HELPERS}delete_folder(\"${LIBDIR}/${PKGDIR}\")" 185 | mpremote exec "$delete_folder" 186 | fi 187 | echo -ne "\r\033[2K" 188 | echo -e "\r√ $output_msg" 189 | create_folder "$LIBDIR/$PKGDIR" 190 | fi 191 | step_counter="[$(printf "%2d" $current_item)/$(printf "%2d" $items_count)] " 192 | echo -n "$step_counter" 193 | if [ -d "$item_path" ]; then 194 | create_folder "$LIBDIR/$destination_subpath" 195 | elif [ -f "$item_path" ]; then 196 | f_name=`basename $item_path` 197 | source_extension="${f_name##*.}" 198 | destination_extension=$source_extension 199 | clean_item_path="${item_path//.\//}" 200 | if [[ "$ext" == "mpy" && "$source_extension" == "py" ]]; then 201 | mpy-cross "$item_path" 202 | destination_extension=$ext 203 | copy_file ${clean_item_path%.*}.$destination_extension :$LIBDIR/${destination_subpath%.*}.$destination_extension 204 | else 205 | copy_file $clean_item_path :$LIBDIR/$destination_subpath 206 | fi 207 | fi 208 | fi 209 | 210 | 211 | done 212 | 213 | if [ "$ext" == "mpy" ]; then 214 | echo "cleaning up mpy files" 215 | rm $SRCDIR/*.mpy 216 | fi 217 | if [ $items_count -gt 0 ]; then 218 | echo -e "\n*** Package $PKGNAME installed successfully ***\n" 219 | else 220 | echo -e "\n*** Nothing done: no files found in package $PKGNAME ***\n" 221 | fi 222 | 223 | } 224 | 225 | # No arguments passed 226 | if [[ $1 == "" ]]; then 227 | echo "Usage: $0 [--mpy][--no-reset]" 228 | exit 1 229 | fi 230 | 231 | # Check if mpremote is installed 232 | if ! command -v mpremote &> /dev/null 233 | then 234 | echo "mpremote could not be found. Please install it by running:" 235 | echo "pip install mpremote" 236 | exit 1 237 | fi 238 | 239 | reset=true 240 | ext="py" 241 | packages=() 242 | for arg in "$@"; do 243 | if [ "$arg" == "--no-reset" ]; then 244 | reset=false 245 | continue 246 | fi 247 | if [ "$arg" == "--mpy" ]; then 248 | ext="mpy" 249 | continue 250 | fi 251 | packages+=($arg) 252 | done 253 | 254 | echo "Packages: ${packages[@]}" 255 | # Start the installation process 256 | echo "MicroPython Package Installer" 257 | echo "-----------------------------" 258 | echo "Packages:" 259 | 260 | for package in "${packages[@]}"; do 261 | echo "• `basename $package`" 262 | done 263 | 264 | if device_present == 0; then 265 | echo "No device found. Please connect a MicroPython board and try again." 266 | exit 1 267 | fi 268 | 269 | LIBDIR="$(get_board_lib_path)" 270 | output_msg="" 271 | if folder_exists "${LIBDIR}"; then 272 | output_msg="Library folder ($LIBDIR) exists on board" 273 | else 274 | create_folder "$LIBDIR" 275 | output_msg="Library folder ($LIBDIR) did not exist on board, it was created." 276 | fi 277 | echo -ne "\r\033[2K" 278 | echo -e "\r√ $output_msg" 279 | package_number=0 280 | start_dir=`pwd` 281 | for package in "${packages[@]}"; do 282 | echo "-----------------------------" 283 | # echo "Installing package: `basename $package`" 284 | package_number=$((package_number+1)) 285 | echo "Installing `basename $package` [$package_number/${#packages[@]}]" 286 | package_dir=`realpath $package` 287 | find $package_dir -name ".DS_Store" -type f -delete 288 | cd $package_dir 289 | install_package `basename $package` 290 | cd $start_dir 291 | done 292 | 293 | if [ "$reset" = true ]; then 294 | echo "Resetting target board ..." 295 | mpremote reset 296 | exit 1 297 | fi 298 | -------------------------------------------------------------------------------- /arduino_tools/wifi_utils.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Module still in experimental phase, API might change. 3 | "network_credentials.json" will be created in root ("/" or "/flash"). 4 | auto_connect(): attempts connection to previously saved networks. 5 | connect(): will trickle down through 6 | - connecting to ssid:key 7 | - auto_connect() 8 | - interactive connect_to_network() in REPL 9 | ''' 10 | # TODO/IDEAS 11 | # encrypt passwords for stored network credentials 12 | # use encryption function with a 4-6 numeric PIN to decrypt 13 | 14 | 15 | import json 16 | import network 17 | import time 18 | import binascii 19 | from sys import platform, implementation 20 | from .common import get_root 21 | 22 | ''' 23 | ONLY for ESP32 24 | 1000 STAT_IDLE – no connection and no activity, 25 | 1001 STAT_CONNECTING – connecting in progress, 26 | 202 STAT_WRONG_PASSWORD – failed due to incorrect password, 27 | 201 STAT_NO_AP_FOUND – failed because no access point replied, 28 | 203 STAT_ASSOC_FAIL/STAT_CONNECT_FAIL – failed due to other problems, 29 | 1010 STAT_GOT_IP – connection successful. 30 | ''' 31 | 32 | _NETWORK_CACHE_LIFE = 10 33 | _NETWORK_CONFIG_FILE = f'{get_root()}network_config.json' 34 | 35 | _net_if = None 36 | 37 | _net_config = { 38 | "ap":{}, 39 | "known_networks":[] 40 | } 41 | 42 | _net_entry = { 43 | "name":b'', 44 | "mac": b'', 45 | "key": b'' 46 | } 47 | 48 | _local_networks_cache = { 49 | 'last_scan': 0, 50 | 'networks': [] 51 | } 52 | 53 | _ALLOW_PWD_CHAR_RANGE = range(32, 127) 54 | _MIN_PWD_LEN = 8 55 | _MAX_PWD_LEN = 63 56 | 57 | def init_if(network_interface = None): 58 | global _net_if 59 | interface = None 60 | if network_interface == None: 61 | interface = network.WLAN(network.STA_IF) 62 | else: 63 | interface = network_interface 64 | interface.active(False) 65 | time.sleep_ms(100) 66 | interface.active(True) 67 | _net_if = interface 68 | return interface 69 | 70 | def set_ap_config(ssid, key): 71 | _net_config['ap'] = {'ssid': ssid, 'key': key} 72 | save_network_config() 73 | 74 | def init_ap(ssid, key): 75 | global _net_if, _net_config 76 | if _net_if != None: 77 | _net_if.active(False) 78 | _net_if = network.WLAN(network.AP_IF) 79 | 80 | _net_if.active(True) 81 | network_security = None 82 | if platform == 'esp32': 83 | network_security = network.AUTH_WPA2_PSK 84 | if 'Nano RP2040' in implementation._machine: 85 | network_security = _net_if.SEC_WEP 86 | 87 | _net_if.config(ssid=ssid, security = network_security, key=key) 88 | set_ap_config(ssid, key) 89 | 90 | def get_network_index(network_name): 91 | if type(network_name) == bytes: 92 | network_name = network_name.decode('utf-8') 93 | # network_list = local_networks_cache['networks'] 94 | network_list = _net_config['known_networks'] 95 | result = list(filter(lambda m:network_list[m]['name'] == network_name, range(len(network_list)))) 96 | if len(result) > 0: 97 | return result[0] 98 | else: 99 | return None 100 | 101 | def get_network_index_by_mac(network_mac): 102 | if type(network_mac) == bytes: 103 | network_mac = binascii.hexlify(network_mac) 104 | network_list = _net_config['known_networks'] 105 | result = list(filter(lambda m:network_list[m]['mac'] == network_mac, range(len(network_list)))) 106 | if len(result) > 0: 107 | return result[0] 108 | else: 109 | return None 110 | 111 | def get_ap_settings(): 112 | try: 113 | ssid = _net_config['ap']['ssid'] 114 | key = _net_config['ap']['key'] 115 | return ssid, key 116 | except KeyError: 117 | return None 118 | 119 | def find_scanned_matches(): 120 | scanned_known_networks = [] 121 | 122 | for n in _net_config['known_networks']: 123 | # TODO: match b'MAC' first, b'NAME' after, since name might have changed 124 | # print('encoded name: ', (n['name']).encode()) 125 | # print('mac: ', (n['mac']).encode()) 126 | 127 | # filtered = [item for item in _local_networks_cache['networks'] if item[0] == (n['name']).encode() or binascii.hexlify(item[1]) == n['mac'].encode()] 128 | filtered = [item for item in _local_networks_cache['networks'] if binascii.hexlify(item[1]) == n['mac'].encode()] 129 | if len(filtered) > 0: 130 | scanned_known_networks.append(n) 131 | return scanned_known_networks 132 | 133 | def auto_connect(progress_callback = None): 134 | init_if() 135 | load_network_config() 136 | wifi_scan() 137 | matches = find_scanned_matches() 138 | connection_success = False 139 | for i, n in enumerate(matches): 140 | print(f'{i:<2}: {n}') 141 | if connect(ssid = n['name'], key = n['key'], progress_callback = progress_callback): 142 | connection_success = True 143 | break 144 | return connection_success 145 | 146 | 147 | def wifi_scan(force_scan = False): 148 | if _net_if == None: 149 | init_if() 150 | now = time.mktime(time.localtime()) 151 | if now - _local_networks_cache['last_scan'] < _NETWORK_CACHE_LIFE and force_scan == False: 152 | return _local_networks_cache['networks'] 153 | network_list = _net_if.scan() 154 | network_list.sort(key=lambda tup:tup[0]) 155 | _local_networks_cache['networks'] = network_list 156 | _local_networks_cache['last_scan'] = time.mktime(time.localtime()) 157 | return network_list 158 | 159 | def list_networks(rescan = False): 160 | wifi_scan(rescan) 161 | print_scan_results() 162 | 163 | def print_scan_results(): 164 | for i, n in enumerate(_local_networks_cache['networks']): 165 | mac = binascii.hexlify(n[1], ':') 166 | print(f'{i:>2}: {(n[0]).decode('utf-8'):<20} [{mac}]') 167 | 168 | 169 | def connect_to_network(id = None): 170 | print(f'*** CONNECTING TO NETWORK {id}') 171 | if id == None: 172 | list_networks() 173 | choice = input('Choose a network [or ENTER to skip]: ') 174 | if choice == '': 175 | print('Connection canceled') 176 | return 177 | id = int(choice) 178 | 179 | network_list = _local_networks_cache['networks'] 180 | ssid = network_list[id][0] 181 | print(f'Connect to {ssid.decode('utf-8')}') 182 | key = input('Password: ') 183 | net_if = connect(ssid, key, display_progress = True) 184 | if net_if is not None: 185 | print(f'Successful connection to {ssid} on interface: {net_if}') 186 | if net_if.ifconfig()[0] != '0.0.0.0': 187 | store_net_entry(id, key) 188 | else: 189 | raise OSError(f"Failed to connect to {ssid}") 190 | 191 | 192 | def connect(ssid = None, key = None, interface = None, timeout = 10, display_progress = False, progress_callback = None): 193 | global _net_if 194 | print(f'{ssid=} | {key=} | {progress_callback=}') 195 | if ssid == None and key == None: 196 | if not auto_connect(progress_callback): 197 | connect_to_network() 198 | return 199 | time.sleep_ms(500) 200 | global _net_if 201 | print("net_if:", _net_if) 202 | if interface == None: 203 | _net_if = init_if() 204 | else: 205 | _net_if = interface 206 | 207 | _net_if.active(False) 208 | time.sleep_ms(100) 209 | _net_if.active(True) 210 | time.sleep_ms(100) 211 | 212 | _net_if.connect(ssid, key) 213 | connection_attempt_start_time = time.time() 214 | if display_progress: 215 | print() 216 | print(f"Connecting to {ssid}") 217 | 218 | 219 | progress_index = 0 220 | progress_cycle = 0 221 | while _net_if.isconnected() != True: 222 | if display_progress: 223 | progress_callback = progress_callback or _sample_connection_progress_callback 224 | if progress_callback != None: 225 | progress_cycle += 1 226 | if progress_cycle % 10 == 0: 227 | progress_index += 1 228 | progress_callback(progress_index) 229 | if time.time() - connection_attempt_start_time > timeout: 230 | break 231 | 232 | if display_progress: 233 | print() 234 | print(f'{"C" if _net_if.isconnected() else "NOT c"}onnected to network') 235 | if _net_if.isconnected(): 236 | print(f'Connected to {ssid}') 237 | print_network_details(_net_if) 238 | else: 239 | _net_if.active(False) 240 | print(f'Connection to {ssid} failed') 241 | return None 242 | return _net_if 243 | 244 | def get_network_qrcode_string(ssid, password, security): 245 | return f'WIFI:S:{ssid};T:{security};P:{password};;' 246 | 247 | def print_network_details(interface = _net_if): 248 | if interface is None: 249 | raise AttributeError("Network interface not initialized") 250 | network_details = interface.ifconfig() 251 | mac_address = binascii.hexlify(interface.config('mac'), ':') 252 | print(f'MAC: {mac_address}') 253 | print(f'IP: {network_details[0]}') 254 | print(f'Subnet: {network_details[1]}') 255 | print(f'Gateway: {network_details[2]}') 256 | print(f'DNS: {network_details[3]}') 257 | 258 | 259 | def store_net_entry(id, key): 260 | network_list = _local_networks_cache['networks'] 261 | net_info = network_list[id] 262 | network_entry = _net_entry.copy() 263 | network_entry['name'] = net_info[0].decode('utf-8') 264 | network_entry['mac'] = binascii.hexlify(net_info[1]) 265 | network_entry['key'] = key 266 | network_match_index = get_network_index_by_mac(net_info[1]) 267 | if network_match_index != None: 268 | _net_config['known_networks'].pop(network_match_index) 269 | _net_config['known_networks'].append(network_entry) 270 | save_network_config() 271 | return network_entry 272 | 273 | def save_network_config(net_config_file_path = None): 274 | net_config_file_path = net_config_file_path or _NETWORK_CONFIG_FILE 275 | try: 276 | with open(net_config_file_path, 'w', encoding = 'utf-8') as f: 277 | return json.dump(_net_config, f, separators=(',', ':')) 278 | except OSError as e: 279 | return None 280 | 281 | def load_network_config(net_config_file_path = None): 282 | global _net_config 283 | net_config_file_path = net_config_file_path or _NETWORK_CONFIG_FILE 284 | try: 285 | with open(net_config_file_path, 'r') as f: 286 | _net_config = json.load(f) 287 | except OSError as e: 288 | print(e) 289 | pass 290 | return _net_config 291 | 292 | # Connection progress callback example 293 | _max_dot_cols = 20 294 | _dot_col = 0 295 | def _sample_connection_progress_callback(t): 296 | global _dot_col 297 | if(_dot_col % _max_dot_cols == 0): 298 | print() 299 | print('.', end = '') 300 | _dot_col +=1 301 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | --------------------------------------------------------------------------------