Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .vscode/.env
Original file line number Diff line number Diff line change
@@ -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'
44 changes: 0 additions & 44 deletions .vscode/cp-web-upload.py

This file was deleted.

127 changes: 127 additions & 0 deletions .vscode/cp_web_upload.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 8 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"python.languageServer": "Pylance",
"python.analysis.diagnosticSeverityOverrides": {
"reportMissingModuleSource": "none",
"reportShadowedImports": "none"
},
"circuitpython.board.version": null
}
7 changes: 4 additions & 3 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 5 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,21 @@ 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/)
* CircuitPython 8.x on your board.
* 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
7 changes: 7 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import time

counter = 0
while True:
print('counter =', counter)
counter += 1
time.sleep(1)
10 changes: 10 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
@@ -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"