diff --git a/.vscode/.env b/.vscode/.env new file mode 100644 index 0000000..d0d4f31 --- /dev/null +++ b/.vscode/.env @@ -0,0 +1,7 @@ +# Note: seems that using 'circuitpython.local' does not work. But use it on the browser +# and then copy here the specific name as 'cpy-32c3_supermini-dcda0ca17814.local +URL='cpy-32c3_supermini-dcda0ca17814.local' +CIRCUITPY_WEB_API_PASSWORD='mypass' + +DEVICE_FILES_FOLDERS_TO_IGNORE='boot_out.txt, settings.toml, sd, lib' +PROJECT_FILES_FOLDERS_TO_IGNORE='.git, .gitignore, .settings.toml, .vscode, tests, .pytest_cache' \ No newline at end of file diff --git a/.vscode/cp-web-upload.py b/.vscode/cp-web-upload.py deleted file mode 100644 index 0761259..0000000 --- a/.vscode/cp-web-upload.py +++ /dev/null @@ -1,44 +0,0 @@ -import sys -import os -import requests -from dotenv import load_dotenv -load_dotenv() - -baseURL = "http://cpy-daa7a1.local/fs/" - -password = os.getenv("CIRCUITPY_WEB_API_PASSWORD") -workspaceFolder = sys.argv[1] -relativeFile = sys.argv[2] - -def create_parent_directory(relative_path): - relative_path = relative_path.removesuffix("/") - print("Creating parent directory for:",relative_path) - directory = relative_path.replace(relative_path.split("/")[-1],"") - dir_response = requests.put(baseURL + directory, auth=("",password)) - if(dir_response.status_code == 201): - print("Directory created:", directory) - else: - print(dir_response.status_code, dir_response.reason) - -response = requests.put(baseURL + relativeFile, data=open(workspaceFolder + "/" + relativeFile,"rb"), auth=("",password)) -if(response.status_code == 201): - print("Created file:", relativeFile) -elif(response.status_code == 204): - print("Overwrote file:", relativeFile) -elif(response.status_code == 401): - print("Incorrect password") -elif(response.status_code == 403): - print("CIRCUITPY_WEB_API_PASSWORD not set") -elif(response.status_code == 404): - print("Missing parent directory") - create_parent_directory(relativeFile) - retry_response = requests.put(baseURL + relativeFile, data=open(workspaceFolder + "/" + relativeFile,"rb"), auth=("",password)) - if(retry_response.status_code == 201): - print("Created file:", relativeFile) - else: - print(retry_response.status_code, retry_response.reason) -elif(response.status_code == 409): - print("USB is active and preventing file system modification") -else: - print(response.status_code, response.reason) - diff --git a/.vscode/cp_web_upload.py b/.vscode/cp_web_upload.py new file mode 100644 index 0000000..8418712 --- /dev/null +++ b/.vscode/cp_web_upload.py @@ -0,0 +1,127 @@ +import os +import requests +from dotenv import load_dotenv +load_dotenv() +from pathlib import Path +import time + +def main(): + # Following the CircuitPython Files Rest API: + # https://docs.circuitpython.org/en/latest/docs/workflows.html + + source_dir = Path('.').resolve() + base_url = 'http://' + url + '/fs/' + + # Get the list of files and folders from the device + device_files = list_device_files(base_url, password) + + # Copy project files and folders to device + for src_path in source_dir.rglob('*'): + rel_path = src_path.relative_to(source_dir) + device_path = rel_path.as_posix() + + # Should this file / folder be ignored? base on configs: + # DEVICE_FILES_FOLDERS_TO_IGNORE + # PROJECT_FILES_FOLDERS_TO_IGNORE + if should_ignore(rel_path): + continue + + # If is a directory, create it on the device + if src_path.is_dir(): + if device_path not in device_files: + create_device_folder(base_url, device_path) + # If is a file, copy it to the device + else: + upload_file(base_url, src_path, device_path, device_files) + + # Remove extra device files and folders + device_paths = {item['name'] for item in device_files} + local_paths = {p.relative_to(source_dir).as_posix() for p in source_dir.rglob('*') if not should_ignore(p.relative_to(source_dir))} + + # Remove files and folders on device that do not exist on the project + for device_path in device_paths - local_paths: + if not should_ignore(Path(device_path)): + delete_device_file_or_folder(base_url, device_path) + + # And it is finished + print("finished") + + +# Get variables from .env file +url = os.getenv("URL") +password = os.getenv("CIRCUITPY_WEB_API_PASSWORD") + +device_files_folders_to_ignore = set(os.getenv('DEVICE_FILES_FOLDERS_TO_IGNORE', '').split(',')) +device_files_folders_to_ignore = [file_folder.strip() for file_folder in device_files_folders_to_ignore] +device_files_folders_to_ignore = set(device_files_folders_to_ignore) + +project_files_folders_to_ignore = set(os.getenv('PROJECT_FILES_FOLDERS_TO_IGNORE', '').split(',')) +project_files_folders_to_ignore = [file_folder.strip() for file_folder in project_files_folders_to_ignore] +project_files_folders_to_ignore = set(project_files_folders_to_ignore) + +def should_ignore(path): + parts = set(path.parts) + return parts & device_files_folders_to_ignore or parts & project_files_folders_to_ignore + + +def list_device_files(base_url, password): + + response = requests.get(base_url, auth=("", password), headers={"Accept": "application/json"}) + if response.status_code == 200: + try: + data = response.json() + files_list = data.get('files', []) # Extract the 'files' list, default to empty list if not found + return files_list # Return only the list of files + except (ValueError, KeyError): # Handle JSON decoding or missing 'files' key errors + print("Error: Invalid JSON response or missing 'files' key") + return [] # Return an empty list in case of error + else: + print(f"Failed to list device files: {response.status_code}") + return {} + + +def create_device_folder(base_url, device_path): + response = requests.put(base_url + device_path + '/', auth=("",password), headers={"X-Timestamp": str(int(time.time_ns()/1000000))}) + if(response.status_code == 201): + print("Directory created:", device_path) + elif(response.status_code == 204): + print("Skipped (already exist):", device_path) + else: + print("Failed to create directory:", response.status_code, response.reason) + +def upload_file(base_url, src_path, device_path, device_files): + local_timestamp_ns = src_path.stat().st_mtime_ns + + device_file_info = None # Initialize to None + for file_info in device_files: # Iterate through the list of file dictionaries + if file_info.get('name') == device_path: # Check if the name matches + device_file_info = file_info # Store the matching dictionary + break # Exit the loop once found + + device_timestamp_ns = 0 + if device_file_info: # Check if a matching file was found + device_timestamp_ns = device_file_info.get('modified_ns') + + if device_timestamp_ns and local_timestamp_ns <= device_timestamp_ns: + return + + with open(src_path, 'rb') as file: + response = requests.put(base_url + device_path, data=file, auth=("", password), headers={"X-Timestamp": str(int(time.time_ns()/1000000))}) + if response.status_code not in [201, 204]: + print(f"Failed to copy {device_path}: {response.status_code} - {response.reason}") + else: + print(f"File copy: {device_path}") + + +def delete_device_file_or_folder(base_url, device_path): + response = requests.delete(base_url + device_path, auth=("", password)) + if response.status_code == 200: + print(f"Deleted: {device_path}") + elif response.status_code == 204: + print(f"Deleted: {device_path}") + else: + print(f"Failed to delete {device_path}: {response.status_code}") + + +if __name__ == "__main__": # Only run main() when the script is executed directly + main() \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..077f286 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.languageServer": "Pylance", + "python.analysis.diagnosticSeverityOverrides": { + "reportMissingModuleSource": "none", + "reportShadowedImports": "none" + }, + "circuitpython.board.version": null +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a7c064a..fc0031d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,12 +4,13 @@ "version": "2.0.0", "tasks": [ { - "label": "Upload", + "label": "Update device files", "type": "shell", - "command": "python3 .vscode/cp-web-upload.py '${workspaceFolder}' '${relativeFile}'", + "command": "python3 .vscode/cp_web_upload.py", "presentation": { "echo": true, - "reveal": "silent", + // "reveal": "silent", // do not show output + "reveal": "always", // show the output "focus": false, "panel": "shared", "showReuseMessage": true, diff --git a/README.md b/README.md index 14df8d2..c4abbaf 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ Task definition and Python script to upload from VS Code to CircuitPython board CircuitPython 8.x adds [web workflow](https://docs.circuitpython.org/en/latest/docs/workflows.html#web) allowing code to be edited/uploaded via the local network. There is built-in browser support and also a Web REST API. This project utilizes the latter to upload a file directly from VS Code. -***NOTE: ~~This is very rough and you will find some bugs.~~ No major bugs so far, but PRs for improvement appreciated!*** - ## Setup * Python 3 installed and in your path. * [requests](https://pypi.org/project/requests/) and [python-dotenv](https://pypi.org/project/python-dotenv/) @@ -12,22 +10,14 @@ CircuitPython 8.x adds [web workflow](https://docs.circuitpython.org/en/latest/d * Board connected to same Wi-Fi with web workflow configured and reachable from machine running VS Code. * [This is for ESP32 (original) but should be close enough for any of the ESP32-S2 or S3 boards, also](https://learn.adafruit.com/circuitpython-with-esp32-quick-start/setting-up-web-workflow). * Copy .vscode directory from this project to the root of your CircuitPython project. It does not have to be copied to your CircuitPython board, just the machine running VS Code. -* Edit .vscode/cp-web-upload.py and set _baseURL_. -* Web API password is taken from .env -* From the file you want to upload, execute the "Run Task..." command. +* Edit .vscode/.env and set _baseURL_, CIRCUITPY_WEB_API_PASSWORD and the files and folders to be ignored. +* To update the files on the device, execute the "Run Task..." command. * Menu: _Terminal, Run Task..._ * Command pallet: _Tasks: Run Task_ * Shortcut keys: TODO:DOCUMENT_THESE * [Keybindings can be configured to call a specific task](https://code.visualstudio.com/docs/editor/tasks#_binding-keyboard-shortcuts-to-tasks). -## Notes -* Directories in the file's path are created if they don't exist. -* Only single files can be uploaded. -* Moved files will be recreated in the new location but the old file/directories will not be removed. -* Existing files will be overwritten, even if they haven't changed. +NOTE: + * New files and folders that exist in the project folder but not on the device will be copied to the device. + * Files and folders present on the device but missing from the project folder will be deleted from the device. -## TODO -- [X] get password from /.env -- [ ] set/get URL from /.env -- [ ] Get timestamp from source file and set on new file -- [ ] use argparse diff --git a/main.py b/main.py new file mode 100644 index 0000000..f23453d --- /dev/null +++ b/main.py @@ -0,0 +1,7 @@ +import time + +counter = 0 +while True: + print('counter =', counter) + counter += 1 + time.sleep(1) \ No newline at end of file diff --git a/settings.toml b/settings.toml new file mode 100644 index 0000000..821e65c --- /dev/null +++ b/settings.toml @@ -0,0 +1,10 @@ +# To auto-connect to Wi-Fi +CIRCUITPY_WIFI_SSID="mywifi" +CIRCUITPY_WIFI_PASSWORD="mypass" + +# To enable the web workflow. Change this too! +# Leave the User field blank in the browser. +CIRCUITPY_WEB_API_PASSWORD="mypass" + +CIRCUITPY_WEB_API_PORT=80 +CIRCUITPY_WEB_INSTANCE_NAME="ESP32-C3-0001"