diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..cfc412a3 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/devcontainers/python:3.10 + +# System packages for firmware build and board communication +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc-arm-none-eabi \ + libnewlib-arm-none-eabi \ + openocd \ + udev \ + && rm -rf /var/lib/apt/lists/* + +# udev rules for STeaMi board (DAPLink / STM32) +RUN mkdir -p /etc/udev/rules.d \ + && echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="0d28", MODE="0666"' \ + > /etc/udev/rules.d/99-steami.rules diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..ef9b23f4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,92 @@ +{ + "name": "STeaMi MicroPython Dev", + "build": { + "dockerfile": "Dockerfile" + }, + + // USB access for STeaMi board (DAPLink / mpremote / OpenOCD). + // Privileged mode is required for firmware flashing and board communication. + // This is incompatible with GitHub Codespaces but essential for local use. + "privileged": true, + "mounts": ["type=bind,source=/dev/bus/usb,target=/dev/bus/usb"], + "runArgs": ["--device=/dev/bus/usb"], + + "remoteEnv": { + "VENV_DIR": "/home/vscode/.venv" + }, + + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "installOhMyZsh": true, + "upgradePackages": true, + "username": "vscode" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/stuartleeks/dev-container-features/shell-history:0": {} + }, + + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + + "editor.minimap.enabled": false, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.rulers": [99], + + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[makefile]": { + "editor.tabSize": 8 + }, + "[yaml]": { + "editor.tabSize": 2 + }, + + "python.defaultInterpreterPath": "/home/vscode/.venv/bin/python", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["-v", "-k", "mock"], + + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/node_modules/**": true, + "**/.build/**": true, + "**/__pycache__/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/.build": true + } + }, + "extensions": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-vscode.vscode-serial-monitor", + "esbenp.prettier-vscode", + "github.vscode-pull-request-github", + "vivaxy.vscode-conventional-commits" + ] + } + }, + + "postCreateCommand": "make setup VENV_DIR=/home/vscode/.venv && sudo /usr/lib/systemd/systemd-udevd --daemon", + "remoteUser": "vscode" +} diff --git a/.github/workflows/check-commits.yml b/.github/workflows/check-commits.yml index 3b1e9241..48cb13a7 100644 --- a/.github/workflows/check-commits.yml +++ b/.github/workflows/check-commits.yml @@ -22,7 +22,7 @@ jobs: - name: "🟢 Set up Node.js" uses: actions/setup-node@v4 with: - node-version: "20.17.x" + node-version: "22" cache: "npm" - name: "🛠 Install commitlint" diff --git a/.gitignore b/.gitignore index e0ce0887..1f6e00c8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __pycache__ node_modules/ CLAUDE.md .build/ +.venv/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..486bae57 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-vscode.vscode-serial-monitor", + "esbenp.prettier-vscode", + "github.vscode-pull-request-github", + "vivaxy.vscode-conventional-commits" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d6817251 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,30 @@ +{ + "python.languageServer": "Pylance", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.analysis.typeCheckingMode": "basic", + "python.analysis.extraPaths": [ + "lib/apds9960", + "lib/bme280", + "lib/bq27441", + "lib/daplink_flash", + "lib/gc9a01", + "lib/hts221", + "lib/im34dt05", + "lib/ism330dl", + "lib/lis2mdl", + "lib/mcp23009e", + "lib/ssd1327", + "lib/steami_config", + "lib/vl53l1x", + "lib/wsen-hids", + "lib/wsen-pads" + ], + "python.analysis.stubPath": "typings", + "python.analysis.diagnosticSeverityOverrides": { + "reportMissingModuleSource": "none", + "reportWildcardImportFromLibrary": "none", + "reportGeneralTypeIssues": "warning" + }, + "pylint.enabled": false, + "mypy-type-checker.enabled": false +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3bc0a18..6719e00f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,9 +83,40 @@ docs: Update README driver table. test(mcp23009e): Add mock scenarios for mcp23009e driver. ``` +## Prerequisites + +For local development (without dev container): + +* Python 3.10+ +* Node.js 22+ (for husky, commitlint, lint-staged, semantic-release) +* `arm-none-eabi-gcc` toolchain (for `make firmware`) +* OpenOCD (for `make deploy`) +* `mpremote` (installed via `pip install -e ".[test]"`) +* GitHub CLI (`gh`) + +Then run `make setup` to install all dependencies and git hooks. This creates a `.venv` with ruff, pytest, mpremote, and MicroPython type stubs for Pylance. + +## Dev Container + +A dev container is available for VS Code (local Docker only, not GitHub Codespaces). It includes all prerequisites out of the box: Python 3.10, Node.js 22, ruff, pytest, mpremote, arm-none-eabi-gcc, OpenOCD, and the GitHub CLI. + +1. Open the repository in VS Code +2. When prompted, click **Reopen in Container** (or use the command palette: *Dev Containers: Reopen in Container*) +3. The container runs `make setup` automatically on creation + +The container also provides: + +* **zsh + oh-my-zsh** as default shell with persistent shell history +* **Pylance** configured with MicroPython STM32 stubs (no false `import machine` errors) +* **Serial Monitor** extension for board communication +* **USB passthrough** for mpremote, OpenOCD, and firmware flashing (the container runs in privileged mode with `/dev/bus/usb` mounted) +* **udev rules** for the DAPLink interface (auto-started on container creation) + +Note: GitHub Codespaces is not supported because the container requires privileged mode and USB device access for board communication. + ## Workflow -1. Set up your environment: `make setup` +1. Set up your environment: open in the dev container, or run `make setup` locally 2. Create a branch from main (format: `feat/`, `fix/`, `docs/`, `tooling/`, `ci/`, `test/`, `style/`, `chore/`, `refactor/`) 3. Write your code and add tests in `tests/scenarios/.yaml` 4. Run `make ci` to verify everything passes (lint + tests + examples) @@ -142,7 +173,7 @@ The firmware source is cloned into `.build/micropython-steami/` (gitignored). A Use `make firmware` for normal rebuilds from the existing local clone. Use `make firmware-update` only when you want to refresh the `micropython-steami` checkout itself or resync the board-specific submodules before rebuilding. -**Requirements**: `arm-none-eabi-gcc` toolchain, OpenOCD for flashing, and `mpremote` for running scripts on the board. +All these tools are included in the dev container. For local development, see the [Prerequisites](#prerequisites) section. ## Notes diff --git a/Makefile b/Makefile index 69fed4c7..6cd067bf 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,12 @@ include env.mk +# Venv path (override with VENV_DIR=/path for devcontainer) +VENV_DIR ?= .venv + +# Use venv Python/tools when available, fallback to system +PYTHON := $(shell [ -x $(VENV_DIR)/bin/python ] && echo $(VENV_DIR)/bin/python || echo python3) + # --- Setup --- # npm install is re-run only when package.json changes @@ -18,31 +24,34 @@ prepare: node_modules/.package-lock.json ## Install git hooks .PHONY: setup setup: install prepare ## Full dev environment setup +$(VENV_DIR)/bin/activate: + python3 -m venv $(VENV_DIR) + .PHONY: install -install: node_modules/.package-lock.json ## Install dev tools (pip + npm) - python3 -m pip install -e ".[dev,test]" +install: $(VENV_DIR)/bin/activate node_modules/.package-lock.json ## Install dev tools (pip + npm) + $(VENV_DIR)/bin/pip install -e ".[dev,test]" # --- Linting --- .PHONY: lint lint: ## Run ruff linter - ruff check + $(PYTHON) -m ruff check .PHONY: lint-fix lint-fix: ## Auto-fix lint issues - ruff check --fix + $(PYTHON) -m ruff check --fix # --- Testing --- # Dynamic per-scenario targets (test-apds9960, test-hts221, etc.) # Uses 'driver' field for driver scenarios, filename stem for board scenarios. # Convention: for board scenarios, the YAML 'name' field must match the filename. -SCENARIOS := $(shell python3 -c "import yaml,glob,os; [print(d.get('driver',os.path.basename(f).replace('.yaml',''))) for f in sorted(glob.glob('tests/scenarios/*.yaml')) for d in [yaml.safe_load(open(f))]]" 2>/dev/null) -$(foreach s,$(SCENARIOS),$(eval .PHONY: test-$(s))$(eval test-$(s): ; python3 -m pytest tests/ -v -k "$(s)" --port $$(PORT) -s)) +SCENARIOS := $(shell $(PYTHON) -c "import yaml,glob,os; [print(d.get('driver',os.path.basename(f).replace('.yaml',''))) for f in sorted(glob.glob('tests/scenarios/*.yaml')) for d in [yaml.safe_load(open(f))]]" 2>/dev/null) +$(foreach s,$(SCENARIOS),$(eval .PHONY: test-$(s))$(eval test-$(s): ; $(PYTHON) -m pytest tests/ -v -k "$(s)" --port $$(PORT) -s)) .PHONY: test-mock test-mock: ## Run mock tests (no hardware needed) - python3 -m pytest tests/ -v -k mock + $(PYTHON) -m pytest tests/ -v -k mock .PHONY: test test: test-mock ## Run mock tests (use 'make test-all' for mock + hardware) @@ -51,23 +60,23 @@ test: test-mock ## Run mock tests (use 'make test-all' for mock + hardware) .PHONY: test-hardware test-hardware: ## Run all hardware tests (needs board on PORT) - python3 -m pytest tests/ -v --port $(PORT) -s -k hardware + $(PYTHON) -m pytest tests/ -v --port $(PORT) -s -k hardware .PHONY: test-board test-board: ## Run board tests only (buttons, LEDs, buzzer, screen) - python3 -m pytest tests/ -v --port $(PORT) -s -k "board_ and hardware" + $(PYTHON) -m pytest tests/ -v --port $(PORT) -s -k "board_ and hardware" .PHONY: test-sensors test-sensors: ## Run sensor driver hardware tests (I2C devices) - python3 -m pytest tests/ -v --port $(PORT) -s -k "hardware and not board_" + $(PYTHON) -m pytest tests/ -v --port $(PORT) -s -k "hardware and not board_" .PHONY: test-all test-all: ## Run all tests (mock + hardware) - python3 -m pytest tests/ -v --port $(PORT) -s + $(PYTHON) -m pytest tests/ -v --port $(PORT) -s .PHONY: test-examples test-examples: ## Validate all example files (syntax + imports) - python3 -m pytest tests/test_examples.py -v + $(PYTHON) -m pytest tests/test_examples.py -v # --- CI --- @@ -113,20 +122,20 @@ run: ## Run a script on the board with live output (SCRIPT=path/to/file.py) @if [ -z "$(SCRIPT)" ]; then \ echo "Error: SCRIPT is required. Usage: make run SCRIPT=lib/.../example.py"; exit 1; \ fi - mpremote connect $(PORT) run $(SCRIPT) + $(PYTHON) -m mpremote connect $(PORT) run $(SCRIPT) .PHONY: deploy-script deploy-script: ## Deploy a script as main.py for autonomous execution (SCRIPT=path/to/file.py) @if [ -z "$(SCRIPT)" ]; then \ echo "Error: SCRIPT is required. Usage: make deploy-script SCRIPT=lib/.../example.py"; exit 1; \ fi - mpremote connect $(PORT) cp $(SCRIPT) :main.py - mpremote connect $(PORT) reset + $(PYTHON) -m mpremote connect $(PORT) cp $(SCRIPT) :main.py + $(PYTHON) -m mpremote connect $(PORT) reset @echo "Script deployed as main.py and board reset." .PHONY: run-main run-main: ## Re-execute main.py on the board and capture output - mpremote connect $(PORT) exec "exec(open('/flash/main.py').read())" + $(PYTHON) -m mpremote connect $(PORT) exec "exec(open('/flash/main.py').read())" .PHONY: firmware-clean firmware-clean: ## Clean firmware build artifacts @@ -138,11 +147,11 @@ firmware-clean: ## Clean firmware build artifacts .PHONY: repl repl: ## Open MicroPython REPL on the board - mpremote connect $(PORT) + $(PYTHON) -m mpremote connect $(PORT) .PHONY: mount mount: ## Mount lib/ on the board for live testing - mpremote connect $(PORT) mount lib/ + $(PYTHON) -m mpremote connect $(PORT) mount lib/ # --- Release --- @@ -179,7 +188,7 @@ bump: ## Create a version tag (PART=patch|minor|major, default: patch) fi; \ echo "$$LAST → $$NEXT"; \ VERSION=$${NEXT#v}; \ - python3 -c "import re, pathlib; p=pathlib.Path('pyproject.toml'); p.write_text(re.sub(r'^version = \".*\"', 'version = \"$$VERSION\"', p.read_text(), count=1, flags=re.MULTILINE))"; \ + $(PYTHON) -c "import re, pathlib; p=pathlib.Path('pyproject.toml'); p.write_text(re.sub(r'^version = \".*\"', 'version = \"$$VERSION\"', p.read_text(), count=1, flags=re.MULTILINE))"; \ git add pyproject.toml; \ git commit -m "chore: Bump version to $$NEXT."; \ git tag -a "$$NEXT" -m "Release $$NEXT"; \ @@ -194,10 +203,11 @@ clean: ## Remove build artifacts and caches find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true find . -type d -name .ruff_cache -exec rm -rf {} + 2>/dev/null || true find . -type d -name .mypy_cache -exec rm -rf {} + 2>/dev/null || true + find . -type d -name '*.egg-info' -exec rm -rf {} + 2>/dev/null || true .PHONY: deepclean -deepclean: clean ## Remove everything including node_modules and firmware - rm -rf node_modules +deepclean: clean ## Remove everything including node_modules, venv and firmware + rm -rf node_modules $(VENV_DIR) @if [ -d "$(BUILD_DIR)" ]; then rm -rf "$(BUILD_DIR)"; fi .PHONY: help diff --git a/package-lock.json b/package-lock.json index 9a748406..fa6697f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "validate-branch-name": "^1.3.1" }, "engines": { - "node": ">=20.17" + "node": ">=22" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index bf7b5f89..ae438338 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "description": "MicroPython driver library for the STeaMi board.", "engines": { - "node": ">=20.17" + "node": ">=22" }, "scripts": { "build": "make build", diff --git a/pyproject.toml b/pyproject.toml index b450e32d..eaeb557f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ license = {text = "GPL-3.0-or-later"} requires-python = ">=3.7" [project.optional-dependencies] -dev = ["ruff==0.11.6"] +dev = ["ruff==0.11.6", "micropython-stm32-stubs>=1.24"] test = ["pytest==7.4.0", "pyyaml==6.0.2", "mpremote>=1.0"] [tool.setuptools] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000..d4ca64a5 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,34 @@ +{ + "pythonVersion": "3.7", + "pythonPlatform": "All", + "typeCheckingMode": "basic", + "venvPath": ".", + "venv": ".venv", + "extraPaths": [ + "lib/apds9960", + "lib/bme280", + "lib/bq27441", + "lib/daplink_flash", + "lib/gc9a01", + "lib/hts221", + "lib/im34dt05", + "lib/ism330dl", + "lib/lis2mdl", + "lib/mcp23009e", + "lib/ssd1327", + "lib/steami_config", + "lib/vl53l1x", + "lib/wsen-hids", + "lib/wsen-pads" + ], + "exclude": [ + ".build", + "node_modules", + "**/__pycache__", + "**/.*", + ".venv" + ], + "reportMissingModuleSource": false, + "reportWildcardImportFromLibrary": false, + "reportGeneralTypeIssues": "warning" +} diff --git a/typings/time.pyi b/typings/time.pyi new file mode 100644 index 00000000..3c499964 --- /dev/null +++ b/typings/time.pyi @@ -0,0 +1,16 @@ +# MicroPython time module stub — overrides CPython typeshed for Pylance. +from typing import Tuple + +def sleep(seconds: float, /) -> None: ... +def sleep_ms(ms: int, /) -> None: ... +def sleep_us(us: int, /) -> None: ... +def ticks_ms() -> int: ... +def ticks_us() -> int: ... +def ticks_cpu() -> int: ... +def ticks_diff(ticks1: int, ticks2: int, /) -> int: ... +def ticks_add(ticks: int, delta: int, /) -> int: ... +def time() -> int: ... +def time_ns() -> int: ... +def localtime(secs: int | None = None, /) -> Tuple: ... +def gmtime(secs: int | None = None, /) -> Tuple: ... +def mktime(local_time: Tuple, /) -> int: ...