feat(micropython): install libraries from the micropython-lib index (#214)
A MicroPython library in Velxio IS a .py file in the project, copied onto the board's filesystem before main.py runs. That mechanism worked, but getting the file meant hunting GitHub by hand — the gap issue #214 is about. Now the Library Manager fills it, in both halves: Backend: /api/micropython-libs proxies the official micropython-lib package index (the same one `mip` uses on real hardware). /search filters the index; /fetch resolves a package plus its declared deps into .py files. The proxy is what makes this safe to expose: only the official index is reachable (the name must exist in index.json, files come from its content-addressed store), source only (.py, never .mpy bytecode), path- safe, and capped at 24 files / 256 KB per install. In-process TTL cache over the index. Frontend: the same Library Manager modal, mode-aware. On a MicroPython board the search hits the index and "Add to project" writes the package's files into the board's workspace (upsert — re-adding is the upgrade path), visible in the explorer and travelling with the project. Before typing it shows featured picks (ssd1306, umqtt.*, urequests) and the modules already frozen into the firmware (neopixel, dht, onewire, ds18x20, machine, network) labeled "built into the firmware", so searching for one answers "just import it" instead of an empty list. The file-materialization prelude now creates package directories: umqtt ships as umqtt/simple.py, and MicroPython's open() does not mkdir, so the prelude died with ENOENT on any packaged module. Also ships a gallery example ("ESP32 + MicroPython: use an external library") that documents the file-is-the-library mechanism with the ssd1306 driver on the wired OLED.
This commit is contained in:
parent
9936d1956c
commit
d7a4b5e4eb
|
|
@ -0,0 +1,256 @@
|
|||
"""MicroPython library installation (issue #214).
|
||||
|
||||
MicroPython has no Library Manager step: a library IS a .py file in the
|
||||
project, copied onto the board's filesystem before main.py runs. What was
|
||||
missing is a way to GET those files without hunting GitHub by hand. This
|
||||
router answers it against the official micropython-lib package index — the
|
||||
same index `mip` uses on real hardware (https://micropython.org/pi/v2) —
|
||||
proxied server-side so the browser needs no CORS exception and so the
|
||||
limits below are enforced somewhere the client cannot skip.
|
||||
|
||||
Security model (the concern that kept this feature parked):
|
||||
- Only the OFFICIAL index is reachable — the package name is looked up in
|
||||
index.json and the files come from the index's own content-addressed
|
||||
store. No arbitrary URLs, no GitHub fetch.
|
||||
- Source only: the `py` arch is requested, every returned path must end in
|
||||
.py, and native/bytecode (.mpy) is rejected. What lands in the editor is
|
||||
exactly what will run, readable by the user.
|
||||
- Bounded: per-package file count / total size caps, bounded dependency
|
||||
recursion, in-process TTL cache over the index.
|
||||
|
||||
The files land in the user's WORKSPACE (the frontend writes them into the
|
||||
board's file group), not on any server store — so quota is the project's own
|
||||
size and the "installed" library is visible, editable and travels with the
|
||||
project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
INDEX_URL = "https://micropython.org/pi/v2/index.json"
|
||||
PACKAGE_URL = "https://micropython.org/pi/v2/package/py/{name}/{version}.json"
|
||||
FILE_URL = "https://micropython.org/pi/v2/file/{prefix}/{hash}"
|
||||
|
||||
# The index changes when micropython-lib publishes — rarely. 15 min keeps a
|
||||
# busy classroom from hammering micropython.org through us.
|
||||
INDEX_TTL_S = 15 * 60
|
||||
FETCH_TIMEOUT_S = 20.0
|
||||
|
||||
# Caps: micropython-lib's largest source packages are a handful of files and
|
||||
# a few tens of KB. Anything past these limits is not a driver, it is a
|
||||
# mistake (or an index we should not trust that day).
|
||||
MAX_FILES_PER_PACKAGE = 24
|
||||
MAX_TOTAL_BYTES = 262_144 # 256 KB of source
|
||||
MAX_DEP_DEPTH = 3
|
||||
|
||||
_index_cache: tuple[float, list[dict[str, Any]]] | None = None
|
||||
_index_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class MpyPackage(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
description: str = ""
|
||||
author: str = ""
|
||||
license: str = ""
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
success: bool
|
||||
packages: list[MpyPackage] = []
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class FetchRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
version: str | None = Field(None, max_length=40)
|
||||
|
||||
|
||||
class MpyFile(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
class FetchResponse(BaseModel):
|
||||
success: bool
|
||||
name: str
|
||||
version: str | None = None
|
||||
files: list[MpyFile] = []
|
||||
error: str | None = None
|
||||
|
||||
|
||||
async def _get_index() -> list[dict[str, Any]]:
|
||||
global _index_cache
|
||||
now = time.monotonic()
|
||||
if _index_cache is not None and now - _index_cache[0] < INDEX_TTL_S:
|
||||
return _index_cache[1]
|
||||
async with _index_lock:
|
||||
if _index_cache is not None and time.monotonic() - _index_cache[0] < INDEX_TTL_S:
|
||||
return _index_cache[1]
|
||||
async with httpx.AsyncClient(timeout=FETCH_TIMEOUT_S) as client:
|
||||
resp = await client.get(INDEX_URL)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
packages = [p for p in data.get("packages", []) if "py" in p.get("versions", {})]
|
||||
_index_cache = (time.monotonic(), packages)
|
||||
return packages
|
||||
|
||||
|
||||
def filter_packages(packages: list[dict[str, Any]], q: str) -> list[dict[str, Any]]:
|
||||
"""Name/description substring match, name-prefix hits first. Pure so the
|
||||
unit tests need no network."""
|
||||
needle = q.strip().lower()
|
||||
if not needle:
|
||||
return packages
|
||||
starts, contains = [], []
|
||||
for p in packages:
|
||||
name = str(p.get("name", "")).lower()
|
||||
desc = str(p.get("description", "")).lower()
|
||||
if name.startswith(needle):
|
||||
starts.append(p)
|
||||
elif needle in name or needle in desc:
|
||||
contains.append(p)
|
||||
return starts + contains
|
||||
|
||||
|
||||
def validate_package_files(
|
||||
hashes: list[Any], *, package: str
|
||||
) -> list[tuple[str, str]]:
|
||||
"""The (path, hash) pairs a package json may install, or raise.
|
||||
|
||||
Enforces the source-only and path-safety rules: every path is a relative
|
||||
.py with no traversal, and the count cap holds. Pure for unit tests.
|
||||
"""
|
||||
if len(hashes) > MAX_FILES_PER_PACKAGE:
|
||||
raise ValueError(f"{package}: too many files ({len(hashes)})")
|
||||
out: list[tuple[str, str]] = []
|
||||
for entry in hashes:
|
||||
if not isinstance(entry, (list, tuple)) or len(entry) != 2:
|
||||
raise ValueError(f"{package}: malformed hashes entry")
|
||||
path, digest = str(entry[0]), str(entry[1])
|
||||
if not path.endswith(".py"):
|
||||
raise ValueError(f"{package}: non-source file {path!r} (only .py is installable)")
|
||||
if path.startswith("/") or ".." in path.split("/") or "\\" in path:
|
||||
raise ValueError(f"{package}: unsafe path {path!r}")
|
||||
if not all(seg for seg in path.split("/")):
|
||||
raise ValueError(f"{package}: unsafe path {path!r}")
|
||||
if not digest.isalnum():
|
||||
raise ValueError(f"{package}: malformed hash for {path!r}")
|
||||
out.append((path, digest))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/search", response_model=SearchResponse)
|
||||
async def search_packages(q: str = Query("", max_length=100)) -> SearchResponse:
|
||||
"""Search the official micropython-lib index (name + description)."""
|
||||
try:
|
||||
packages = filter_packages(await _get_index(), q)
|
||||
except Exception as e: # network / index shape — degrade, don't 500 the modal
|
||||
logger.warning("[mpy-libs] index fetch failed: %s", e)
|
||||
return SearchResponse(success=False, error=f"micropython-lib index unavailable: {e}")
|
||||
return SearchResponse(
|
||||
success=True,
|
||||
packages=[
|
||||
MpyPackage(
|
||||
name=str(p.get("name", "")),
|
||||
version=str(p.get("version", "")),
|
||||
description=str(p.get("description", "")),
|
||||
author=str(p.get("author", "")),
|
||||
license=str(p.get("license", "")),
|
||||
)
|
||||
for p in packages[:60]
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_package_files(
|
||||
client: httpx.AsyncClient,
|
||||
name: str,
|
||||
version: str,
|
||||
*,
|
||||
seen: set[str],
|
||||
depth: int,
|
||||
budget: dict[str, int],
|
||||
) -> list[MpyFile]:
|
||||
if name in seen:
|
||||
return []
|
||||
seen.add(name)
|
||||
if depth > MAX_DEP_DEPTH:
|
||||
raise ValueError(f"dependency chain too deep at {name!r}")
|
||||
|
||||
resp = await client.get(PACKAGE_URL.format(name=name, version=version))
|
||||
if resp.status_code == 404:
|
||||
raise ValueError(f"package {name!r} not found in the micropython-lib index")
|
||||
resp.raise_for_status()
|
||||
pkg = resp.json()
|
||||
|
||||
files: list[MpyFile] = []
|
||||
for path, digest in validate_package_files(pkg.get("hashes", []), package=name):
|
||||
fresp = await client.get(FILE_URL.format(prefix=digest[:2], hash=digest))
|
||||
fresp.raise_for_status()
|
||||
content = fresp.text
|
||||
budget["bytes"] += len(content.encode("utf-8"))
|
||||
if budget["bytes"] > MAX_TOTAL_BYTES:
|
||||
raise ValueError(
|
||||
f"{name}: install exceeds the {MAX_TOTAL_BYTES // 1024} KB source cap"
|
||||
)
|
||||
files.append(MpyFile(path=path, content=content))
|
||||
|
||||
# mip-style dependencies: [[name, version], ...]. Same store, same rules.
|
||||
for dep in pkg.get("deps", []) or []:
|
||||
if isinstance(dep, (list, tuple)) and dep:
|
||||
dep_name = str(dep[0])
|
||||
dep_version = str(dep[1]) if len(dep) > 1 and dep[1] else "latest"
|
||||
files.extend(
|
||||
await _fetch_package_files(
|
||||
client, dep_name, dep_version, seen=seen, depth=depth + 1, budget=budget
|
||||
)
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
@router.post("/fetch", response_model=FetchResponse)
|
||||
async def fetch_package(request: FetchRequest) -> FetchResponse:
|
||||
"""Resolve one package (plus its declared deps) into .py files the
|
||||
frontend writes into the board's workspace."""
|
||||
name = request.name.strip()
|
||||
version = (request.version or "latest").strip() or "latest"
|
||||
# The name must exist in the official index — this is what pins the fetch
|
||||
# to micropython-lib and nothing else.
|
||||
try:
|
||||
index = await _get_index()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"micropython-lib index unavailable: {e}")
|
||||
if not any(p.get("name") == name for p in index):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"'{name}' is not in the micropython-lib index. Community drivers outside "
|
||||
"the index can be added as a .py file in the workspace.",
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=FETCH_TIMEOUT_S) as client:
|
||||
files = await _fetch_package_files(
|
||||
client, name, version, seen=set(), depth=0, budget={"bytes": 0}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[mpy-libs] fetch %s failed: %s", name, e)
|
||||
raise HTTPException(status_code=502, detail=f"fetch failed: {e}")
|
||||
if not files:
|
||||
raise HTTPException(status_code=422, detail=f"{name}: package resolved to no .py files")
|
||||
return FetchResponse(success=True, name=name, version=version, files=files)
|
||||
|
|
@ -13,7 +13,7 @@ if sys.platform == 'win32':
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import compile, compile_chip, compile_rom, flash, intellisense, libraries
|
||||
from app.api.routes import compile, compile_chip, compile_rom, flash, intellisense, libraries, micropython_libs
|
||||
from app.core.config import settings
|
||||
from app.core.hooks import run_lifespan_startup
|
||||
|
||||
|
|
@ -94,6 +94,11 @@ app.include_router(compile.router, prefix="/api/compile", tags=["compilation"])
|
|||
app.include_router(compile_chip.router, prefix="/api/compile-chip", tags=["custom-chips"])
|
||||
app.include_router(compile_rom.router, prefix="/api/compile-rom", tags=["custom-chips"])
|
||||
app.include_router(libraries.router, prefix="/api/libraries", tags=["libraries"])
|
||||
# MicroPython libraries (issue #214): resolves the official micropython-lib
|
||||
# index into .py files the frontend writes into the board's workspace.
|
||||
app.include_router(
|
||||
micropython_libs.router, prefix="/api/micropython-libs", tags=["micropython-libraries"]
|
||||
)
|
||||
app.include_router(intellisense.router, prefix="/api/intellisense", tags=["intellisense"])
|
||||
# Hardware flash: subprocesses arduino-cli upload to write a compiled
|
||||
# sketch to a real USB-attached board. Desktop-only in practice (the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* micropythonLibs service (issue #214) — the workspace-write half.
|
||||
*
|
||||
* The backend resolves a micropython-lib package into .py files; this side
|
||||
* upserts them into the board's file group. Pinned here: upsert semantics
|
||||
* (re-adding = upgrade, not duplicate), package-dir paths, and the
|
||||
* in-project detection the modal's pills rely on.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useEditorStore } from '../store/useEditorStore';
|
||||
import {
|
||||
packageFilesInGroup,
|
||||
writeFilesIntoGroup,
|
||||
BUILTIN_MPY_MODULES,
|
||||
FEATURED_MPY_PACKAGES,
|
||||
} from '../services/micropythonLibs';
|
||||
|
||||
const GROUP = 'group-mpy-test';
|
||||
|
||||
describe('writeFilesIntoGroup', () => {
|
||||
beforeEach(() => {
|
||||
const ed = useEditorStore.getState();
|
||||
ed.deleteFileGroup(GROUP);
|
||||
ed.createFileGroup(GROUP, [{ name: 'main.py', content: 'print(1)\n' }]);
|
||||
ed.setActiveGroup(GROUP);
|
||||
});
|
||||
|
||||
it('adds the package files next to main.py', () => {
|
||||
const written = writeFilesIntoGroup(GROUP, [
|
||||
{ path: 'ssd1306.py', content: '# driver\n' },
|
||||
]);
|
||||
expect(written).toEqual(['ssd1306.py']);
|
||||
const names = useEditorStore.getState().getGroupFiles(GROUP).map((f) => f.name);
|
||||
expect(names).toContain('main.py');
|
||||
expect(names).toContain('ssd1306.py');
|
||||
});
|
||||
|
||||
it('re-adding overwrites instead of duplicating (that is the upgrade path)', () => {
|
||||
writeFilesIntoGroup(GROUP, [{ path: 'ssd1306.py', content: 'v1\n' }]);
|
||||
writeFilesIntoGroup(GROUP, [{ path: 'ssd1306.py', content: 'v2\n' }]);
|
||||
const files = useEditorStore.getState().getGroupFiles(GROUP);
|
||||
const matches = files.filter((f) => f.name === 'ssd1306.py');
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].content).toBe('v2\n');
|
||||
});
|
||||
|
||||
it('handles package-dir paths the way micropython-lib ships them', () => {
|
||||
writeFilesIntoGroup(GROUP, [{ path: 'umqtt/simple.py', content: '# mqtt\n' }]);
|
||||
const names = useEditorStore.getState().getGroupFiles(GROUP).map((f) => f.name);
|
||||
expect(names).toContain('umqtt/simple.py');
|
||||
});
|
||||
});
|
||||
|
||||
describe('packageFilesInGroup', () => {
|
||||
beforeEach(() => {
|
||||
const ed = useEditorStore.getState();
|
||||
ed.deleteFileGroup(GROUP);
|
||||
ed.createFileGroup(GROUP, [{ name: 'main.py', content: '' }]);
|
||||
ed.setActiveGroup(GROUP);
|
||||
});
|
||||
|
||||
it('detects a plain module and a dotted package', () => {
|
||||
expect(packageFilesInGroup(GROUP, 'ssd1306')).toBe(false);
|
||||
writeFilesIntoGroup(GROUP, [{ path: 'ssd1306.py', content: 'x' }]);
|
||||
expect(packageFilesInGroup(GROUP, 'ssd1306')).toBe(true);
|
||||
|
||||
expect(packageFilesInGroup(GROUP, 'umqtt.simple')).toBe(false);
|
||||
writeFilesIntoGroup(GROUP, [{ path: 'umqtt/simple.py', content: 'x' }]);
|
||||
expect(packageFilesInGroup(GROUP, 'umqtt.simple')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('curated lists', () => {
|
||||
it('featured picks never claim firmware builtins (one truth per module)', () => {
|
||||
const builtins = new Set(BUILTIN_MPY_MODULES.map((b) => b.name));
|
||||
for (const f of FEATURED_MPY_PACKAGES) {
|
||||
expect(builtins.has(f.name)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,15 @@ import {
|
|||
} from '../../services/libraryService';
|
||||
import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService';
|
||||
import { trackInstallLibrary } from '../../utils/analytics';
|
||||
import {
|
||||
BUILTIN_MPY_MODULES,
|
||||
FEATURED_MPY_PACKAGES,
|
||||
fetchMpyPackage,
|
||||
packageFilesInGroup,
|
||||
searchMpyPackages,
|
||||
writeFilesIntoGroup,
|
||||
} from '../../services/micropythonLibs';
|
||||
import type { MpyPackage } from '../../services/micropythonLibs';
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import { boardDisplayName } from '../../types/board';
|
||||
import './LibraryManagerModal.css';
|
||||
|
|
@ -57,6 +66,17 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
const manifestLibs = activeBoard?.libraries ?? null;
|
||||
const declared = manifestLibs ?? [];
|
||||
|
||||
// ── MicroPython mode (issue #214) ─────────────────────────────────────────
|
||||
// Same modal, different backing: a MicroPython library is a .py file in
|
||||
// the project, so search hits the micropython-lib index (backend proxy)
|
||||
// and "Add to project" writes the package's files into this board's file
|
||||
// group — no libraries.json, no arduino-cli.
|
||||
const isMpy = activeBoard?.languageMode === 'micropython';
|
||||
const [mpyResults, setMpyResults] = useState<MpyPackage[]>([]);
|
||||
// Bumped after add/remove so the "In project" pills re-derive from the
|
||||
// file group (which lives outside React state).
|
||||
const [mpyTick, setMpyTick] = useState(0);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<ArduinoLibrary[]>([]);
|
||||
const [installedLibraries, setInstalledLibraries] = useState<InstalledLibrary[]>([]);
|
||||
|
|
@ -169,10 +189,12 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
setLoadingSearch(true);
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
setSearchResults(await searchLibraries(searchQuery));
|
||||
if (isMpy) setMpyResults(await searchMpyPackages(searchQuery));
|
||||
else setSearchResults(await searchLibraries(searchQuery));
|
||||
} catch (e: unknown) {
|
||||
setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Search failed' });
|
||||
setSearchResults([]);
|
||||
if (isMpy) setMpyResults([]);
|
||||
else setSearchResults([]);
|
||||
} finally {
|
||||
setLoadingSearch(false);
|
||||
}
|
||||
|
|
@ -180,7 +202,7 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchQuery, isOpen]);
|
||||
}, [searchQuery, isOpen, isMpy]);
|
||||
|
||||
// A custom .zip upload (pro) lands in the user's per-user store; auto-declare
|
||||
// it on the active board and refresh the list. The upload BUTTON is injected
|
||||
|
|
@ -277,6 +299,83 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
}));
|
||||
}, [searchQuery, searchResults, installedLibraries, isInstalled]);
|
||||
|
||||
// ── MicroPython actions ───────────────────────────────────────────────────
|
||||
const mpyGroupId = activeBoard?.activeFileGroupId ?? null;
|
||||
const addMpyPackage = useCallback(
|
||||
async (name: string) => {
|
||||
if (!mpyGroupId) return;
|
||||
setBusyLib(name);
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const files = await fetchMpyPackage(name);
|
||||
const written = writeFilesIntoGroup(mpyGroupId, files);
|
||||
trackInstallLibrary(`mpy:${name}`);
|
||||
setStatusMsg({
|
||||
type: 'success',
|
||||
text: `"${name}" added to the workspace: ${written.join(', ')}. main.py can import it now.`,
|
||||
});
|
||||
setMpyTick((n) => n + 1);
|
||||
} catch (e: unknown) {
|
||||
const detail =
|
||||
(e as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
setStatusMsg({
|
||||
type: 'error',
|
||||
text: detail || (e instanceof Error ? e.message : `Failed to add "${name}"`),
|
||||
});
|
||||
} finally {
|
||||
setBusyLib(null);
|
||||
}
|
||||
},
|
||||
[mpyGroupId],
|
||||
);
|
||||
|
||||
interface MpyRow {
|
||||
name: string;
|
||||
version: string;
|
||||
desc: string;
|
||||
builtin: boolean;
|
||||
inProject: boolean;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mpyTick re-derives from the file group
|
||||
const mpyRows: MpyRow[] = useMemo(() => {
|
||||
if (!isMpy || !mpyGroupId) return [];
|
||||
const inProj = (name: string) => packageFilesInGroup(mpyGroupId, name);
|
||||
if (searchQuery.trim()) {
|
||||
const needle = searchQuery.trim().toLowerCase();
|
||||
const builtins = BUILTIN_MPY_MODULES.filter((b) => b.name.includes(needle)).map((b) => ({
|
||||
name: b.name,
|
||||
version: '',
|
||||
desc: b.blurb,
|
||||
builtin: true,
|
||||
inProject: false,
|
||||
}));
|
||||
return builtins.concat(
|
||||
mpyResults.map((p) => ({
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
desc: p.description,
|
||||
builtin: false,
|
||||
inProject: inProj(p.name),
|
||||
})),
|
||||
);
|
||||
}
|
||||
return FEATURED_MPY_PACKAGES.map((f) => ({
|
||||
name: f.name,
|
||||
version: '',
|
||||
desc: f.blurb,
|
||||
builtin: false,
|
||||
inProject: inProj(f.name),
|
||||
})).concat(
|
||||
BUILTIN_MPY_MODULES.map((b) => ({
|
||||
name: b.name,
|
||||
version: '',
|
||||
desc: b.blurb,
|
||||
builtin: true,
|
||||
inProject: false,
|
||||
})),
|
||||
);
|
||||
}, [isMpy, mpyGroupId, searchQuery, mpyResults, mpyTick]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const browsing = !searchQuery.trim();
|
||||
|
||||
|
|
@ -305,7 +404,11 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
<span>{t('editor.libraryManager.title')}</span>
|
||||
{activeBoard && (
|
||||
<span
|
||||
title="Libraries apply to this board (its libraries.json = compile scope)"
|
||||
title={
|
||||
isMpy
|
||||
? 'MicroPython: a library is a .py file in this board\'s workspace'
|
||||
: 'Libraries apply to this board (its libraries.json = compile scope)'
|
||||
}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 11,
|
||||
|
|
@ -316,7 +419,8 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
padding: '1px 8px',
|
||||
}}
|
||||
>
|
||||
{boardDisplayName(activeBoard)} · {declared.length}
|
||||
{boardDisplayName(activeBoard)}
|
||||
{isMpy ? ' · MicroPython' : ` · ${declared.length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -362,12 +466,82 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
{/* Single unified list */}
|
||||
<div className="lib-content">
|
||||
<div className="lib-list">
|
||||
{browsing && loadingInstalled && (
|
||||
{isMpy && browsing && (
|
||||
<div className="lib-empty" style={{ padding: '10px 14px 4px' }}>
|
||||
<p className="lib-empty-sub" style={{ margin: 0 }}>
|
||||
A MicroPython library is a .py file in the project — adding one
|
||||
writes it into this board's workspace, next to main.py. Search
|
||||
the official micropython-lib index above.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isMpy &&
|
||||
mpyRows.map((row) => {
|
||||
const busy = busyLib === row.name;
|
||||
return (
|
||||
<div key={`mpy-${row.name}`} className="lib-item">
|
||||
<div className="lib-item-info">
|
||||
<div className="lib-item-header">
|
||||
<span className="lib-item-name">{row.name}</span>
|
||||
{row.builtin && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#8ecae6',
|
||||
background: '#12303f',
|
||||
borderRadius: 6,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
>
|
||||
built into the firmware
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.desc && <p className="lib-item-desc">{row.desc}</p>}
|
||||
</div>
|
||||
<div className="lib-item-actions">
|
||||
{row.version && <span className="lib-item-version">{row.version}</span>}
|
||||
{row.builtin ? (
|
||||
<span className="lib-item-version" title="No install needed — just import it">
|
||||
import {row.name}
|
||||
</span>
|
||||
) : row.inProject ? (
|
||||
<span
|
||||
className="lib-uninstall-btn"
|
||||
style={{ color: '#a5d6a7', borderColor: '#2e7d32', cursor: 'default' }}
|
||||
title="Its .py file is in the workspace — edit or delete it from the file explorer"
|
||||
>
|
||||
In project ✓
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="lib-install-btn"
|
||||
onClick={() => addMpyPackage(row.name)}
|
||||
disabled={busy}
|
||||
title="Write this package's .py files into the board's workspace"
|
||||
>
|
||||
{busy ? '…' : '+ Add to project'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isMpy && !browsing && !loadingSearch && mpyRows.length === 0 && (
|
||||
<div className="lib-empty">
|
||||
<p>{t('editor.libraryManager.noResultsFor', { query: searchQuery })}</p>
|
||||
<p className="lib-empty-sub">
|
||||
Not in micropython-lib? Paste the driver as a .py file in the
|
||||
workspace — that is all an install does.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isMpy && browsing && loadingInstalled && (
|
||||
<div className="lib-empty">
|
||||
<p>{t('editor.libraryManager.loadingInstalled')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!loadingSearch && rows.length === 0 && !(browsing && loadingInstalled) && (
|
||||
{!isMpy && !loadingSearch && rows.length === 0 && !(browsing && loadingInstalled) && (
|
||||
<div className="lib-empty">
|
||||
<p>
|
||||
{browsing
|
||||
|
|
@ -379,7 +553,7 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
|
|||
{browsing && <p className="lib-empty-sub">Search above to find and add a library.</p>}
|
||||
</div>
|
||||
)}
|
||||
{rows.map((row, i) => {
|
||||
{!isMpy && rows.map((row, i) => {
|
||||
const inProj = inManifest(row.name);
|
||||
const busy = busyLib === row.name;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -267,6 +267,242 @@ void loop() {
|
|||
},
|
||||
],
|
||||
},
|
||||
// ── MicroPython libraries are files (no Library Manager, no requirements) ──
|
||||
{
|
||||
id: 'esp32-micropython-external-library',
|
||||
title: 'ESP32 + MicroPython: use an external library',
|
||||
description:
|
||||
"How a MicroPython project uses a library in Velxio: there is no requirements.txt and no package manager — the driver is a .py file in the workspace. Every file here is copied onto the board's filesystem before main.py runs, so `import ssd1306` picks up the ssd1306.py next to it and draws on the wired OLED. The toolbar's Library Manager installs ARDUINO libraries and has no effect in MicroPython mode.",
|
||||
category: 'displays',
|
||||
difficulty: 'beginner',
|
||||
boardType: 'esp32',
|
||||
languageMode: 'micropython',
|
||||
tags: ['esp32', 'micropython', 'library', 'module', 'import', 'ssd1306', 'oled', 'i2c'],
|
||||
code: '',
|
||||
files: [
|
||||
{
|
||||
name: 'main.py',
|
||||
content: `# ESP32 + MicroPython: use an external library.
|
||||
#
|
||||
# MicroPython has no Library Manager here: a library IS a .py file in the
|
||||
# project. Every file in this workspace is written to the board's filesystem
|
||||
# before main.py runs, so the "import ssd1306" below resolves to the ssd1306.py
|
||||
# sitting next to this file (open it in the explorer). To use another driver,
|
||||
# add a new file and paste it in - that is the whole mechanism.
|
||||
#
|
||||
# (The toolbar's Library Manager installs ARDUINO libraries. In MicroPython
|
||||
# mode it has no effect on the run.)
|
||||
from machine import Pin, I2C
|
||||
import ssd1306
|
||||
import time
|
||||
|
||||
# Velxio decodes the hardware I2C controller (no bit-banged buses):
|
||||
# ESP32 SDA = GPIO21, SCL = GPIO22.
|
||||
i2c = I2C(0, sda=Pin(21), scl=Pin(22))
|
||||
oled = ssd1306.SSD1306_I2C(128, 64, i2c)
|
||||
|
||||
n = 0
|
||||
while True:
|
||||
oled.fill(0)
|
||||
oled.text("ssd1306.py", 0, 0)
|
||||
oled.text("imported OK", 0, 12)
|
||||
oled.text("count: {}".format(n), 0, 32)
|
||||
oled.text("MicroPython", 0, 52)
|
||||
oled.show()
|
||||
print("tick", n)
|
||||
n += 1
|
||||
time.sleep(1)`,
|
||||
},
|
||||
{
|
||||
// The stock MicroPython firmware ships no display drivers, so the
|
||||
// project carries the one it needs: micropython-lib's ssd1306 driver,
|
||||
// unmodified. This is exactly what a user would paste in.
|
||||
name: 'ssd1306.py',
|
||||
content: `# MicroPython SSD1306 OLED driver, I2C and SPI interfaces
|
||||
|
||||
from micropython import const
|
||||
import framebuf
|
||||
|
||||
|
||||
# register definitions
|
||||
SET_CONTRAST = const(0x81)
|
||||
SET_ENTIRE_ON = const(0xA4)
|
||||
SET_NORM_INV = const(0xA6)
|
||||
SET_DISP = const(0xAE)
|
||||
SET_MEM_ADDR = const(0x20)
|
||||
SET_COL_ADDR = const(0x21)
|
||||
SET_PAGE_ADDR = const(0x22)
|
||||
SET_DISP_START_LINE = const(0x40)
|
||||
SET_SEG_REMAP = const(0xA0)
|
||||
SET_MUX_RATIO = const(0xA8)
|
||||
SET_COM_OUT_DIR = const(0xC0)
|
||||
SET_DISP_OFFSET = const(0xD3)
|
||||
SET_COM_PIN_CFG = const(0xDA)
|
||||
SET_DISP_CLK_DIV = const(0xD5)
|
||||
SET_PRECHARGE = const(0xD9)
|
||||
SET_VCOM_DESEL = const(0xDB)
|
||||
SET_CHARGE_PUMP = const(0x8D)
|
||||
|
||||
# Subclassing FrameBuffer provides support for graphics primitives
|
||||
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
|
||||
class SSD1306(framebuf.FrameBuffer):
|
||||
def __init__(self, width, height, external_vcc):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.external_vcc = external_vcc
|
||||
self.pages = self.height // 8
|
||||
self.buffer = bytearray(self.pages * self.width)
|
||||
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
|
||||
self.init_display()
|
||||
|
||||
def init_display(self):
|
||||
for cmd in (
|
||||
SET_DISP | 0x00, # off
|
||||
# address setting
|
||||
SET_MEM_ADDR,
|
||||
0x00, # horizontal
|
||||
# resolution and layout
|
||||
SET_DISP_START_LINE | 0x00,
|
||||
SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
|
||||
SET_MUX_RATIO,
|
||||
self.height - 1,
|
||||
SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
|
||||
SET_DISP_OFFSET,
|
||||
0x00,
|
||||
SET_COM_PIN_CFG,
|
||||
0x02 if self.width > 2 * self.height else 0x12,
|
||||
# timing and driving scheme
|
||||
SET_DISP_CLK_DIV,
|
||||
0x80,
|
||||
SET_PRECHARGE,
|
||||
0x22 if self.external_vcc else 0xF1,
|
||||
SET_VCOM_DESEL,
|
||||
0x30, # 0.83*Vcc
|
||||
# display
|
||||
SET_CONTRAST,
|
||||
0xFF, # maximum
|
||||
SET_ENTIRE_ON, # output follows RAM contents
|
||||
SET_NORM_INV, # not inverted
|
||||
# charge pump
|
||||
SET_CHARGE_PUMP,
|
||||
0x10 if self.external_vcc else 0x14,
|
||||
SET_DISP | 0x01,
|
||||
): # on
|
||||
self.write_cmd(cmd)
|
||||
self.fill(0)
|
||||
self.show()
|
||||
|
||||
def poweroff(self):
|
||||
self.write_cmd(SET_DISP | 0x00)
|
||||
|
||||
def poweron(self):
|
||||
self.write_cmd(SET_DISP | 0x01)
|
||||
|
||||
def contrast(self, contrast):
|
||||
self.write_cmd(SET_CONTRAST)
|
||||
self.write_cmd(contrast)
|
||||
|
||||
def invert(self, invert):
|
||||
self.write_cmd(SET_NORM_INV | (invert & 1))
|
||||
|
||||
def show(self):
|
||||
x0 = 0
|
||||
x1 = self.width - 1
|
||||
if self.width == 64:
|
||||
# displays with width of 64 pixels are shifted by 32
|
||||
x0 += 32
|
||||
x1 += 32
|
||||
self.write_cmd(SET_COL_ADDR)
|
||||
self.write_cmd(x0)
|
||||
self.write_cmd(x1)
|
||||
self.write_cmd(SET_PAGE_ADDR)
|
||||
self.write_cmd(0)
|
||||
self.write_cmd(self.pages - 1)
|
||||
self.write_data(self.buffer)
|
||||
|
||||
|
||||
class SSD1306_I2C(SSD1306):
|
||||
def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
|
||||
self.i2c = i2c
|
||||
self.addr = addr
|
||||
self.temp = bytearray(2)
|
||||
self.write_list = [b"\\x40", None] # Co=0, D/C#=1
|
||||
super().__init__(width, height, external_vcc)
|
||||
|
||||
def write_cmd(self, cmd):
|
||||
self.temp[0] = 0x80 # Co=1, D/C#=0
|
||||
self.temp[1] = cmd
|
||||
self.i2c.writeto(self.addr, self.temp)
|
||||
|
||||
def write_data(self, buf):
|
||||
self.write_list[1] = buf
|
||||
self.i2c.writevto(self.addr, self.write_list)
|
||||
|
||||
|
||||
class SSD1306_SPI(SSD1306):
|
||||
def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
|
||||
self.rate = 10 * 1024 * 1024
|
||||
dc.init(dc.OUT, value=0)
|
||||
res.init(res.OUT, value=0)
|
||||
cs.init(cs.OUT, value=1)
|
||||
self.spi = spi
|
||||
self.dc = dc
|
||||
self.res = res
|
||||
self.cs = cs
|
||||
import time
|
||||
|
||||
self.res(1)
|
||||
time.sleep_ms(1)
|
||||
self.res(0)
|
||||
time.sleep_ms(10)
|
||||
self.res(1)
|
||||
super().__init__(width, height, external_vcc)
|
||||
|
||||
def write_cmd(self, cmd):
|
||||
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
|
||||
self.cs(1)
|
||||
self.dc(0)
|
||||
self.cs(0)
|
||||
self.spi.write(bytearray([cmd]))
|
||||
self.cs(1)
|
||||
|
||||
def write_data(self, buf):
|
||||
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
|
||||
self.cs(1)
|
||||
self.dc(1)
|
||||
self.cs(0)
|
||||
self.spi.write(buf)
|
||||
self.cs(1)`,
|
||||
},
|
||||
],
|
||||
components: [{ type: 'velxio-ssd1306-i2c-4pin', id: 'oled', x: 470, y: 100, properties: {} }],
|
||||
wires: [
|
||||
{
|
||||
id: 'w-vcc',
|
||||
start: { componentId: 'esp32', pinName: '3V3' },
|
||||
end: { componentId: 'oled', pinName: 'VCC' },
|
||||
color: '#ff4444',
|
||||
},
|
||||
{
|
||||
id: 'w-gnd',
|
||||
start: { componentId: 'esp32', pinName: 'GND' },
|
||||
end: { componentId: 'oled', pinName: 'GND' },
|
||||
color: '#000000',
|
||||
},
|
||||
{
|
||||
id: 'w-sda',
|
||||
start: { componentId: 'esp32', pinName: '21' },
|
||||
end: { componentId: 'oled', pinName: 'SDA' },
|
||||
color: '#22aaff',
|
||||
},
|
||||
{
|
||||
id: 'w-scl',
|
||||
start: { componentId: 'esp32', pinName: '22' },
|
||||
end: { componentId: 'oled', pinName: 'SCL' },
|
||||
color: '#ff8800',
|
||||
},
|
||||
],
|
||||
},
|
||||
// ── Pure ESP-IDF (issue #139): user app_main(), no Arduino core. ──
|
||||
{
|
||||
id: 'esp32-idf-blink',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* micropythonLibs — the MicroPython side of the Library Manager (issue #214).
|
||||
*
|
||||
* There is no install step on the board: a MicroPython library IS a .py file
|
||||
* in the project, copied onto the board's filesystem before main.py runs. So
|
||||
* "installing" here means resolving a package from the official
|
||||
* micropython-lib index (via the backend proxy, which enforces source-only +
|
||||
* size caps) and WRITING its files into the active board's file group —
|
||||
* visible in the explorer, editable, and travelling with the project.
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '../lib/apiBase';
|
||||
import { useEditorStore } from '../store/useEditorStore';
|
||||
|
||||
export interface MpyPackage {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
license: string;
|
||||
}
|
||||
|
||||
export interface MpyFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured picks shown before the user types — the drivers Velxio actually
|
||||
* emulates as parts, so "add → wire → run" works end to end. All resolve
|
||||
* through the same index fetch; nothing here is a separate install path.
|
||||
*/
|
||||
export const FEATURED_MPY_PACKAGES: ReadonlyArray<{ name: string; blurb: string }> = [
|
||||
{ name: 'ssd1306', blurb: 'SSD1306 OLED driver (I2C/SPI) — the 128x64 display part' },
|
||||
{ name: 'umqtt.simple', blurb: 'Minimal MQTT client (WiFi boards)' },
|
||||
{ name: 'umqtt.robust', blurb: 'MQTT client with auto-reconnect' },
|
||||
{ name: 'urequests', blurb: 'HTTP requests (WiFi boards)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Modules already frozen into the MicroPython firmware Velxio boots — no
|
||||
* install needed, `import` just works. Listed so a search for "dht" answers
|
||||
* "you already have it" instead of a confusing empty result.
|
||||
*/
|
||||
export const BUILTIN_MPY_MODULES: ReadonlyArray<{ name: string; blurb: string }> = [
|
||||
{ name: 'machine', blurb: 'Pins, I2C, SPI, ADC, PWM, timers' },
|
||||
{ name: 'network', blurb: 'WiFi (WLAN) on ESP32-family boards' },
|
||||
{ name: 'neopixel', blurb: 'WS2812 RGB LED strips' },
|
||||
{ name: 'dht', blurb: 'DHT11 / DHT22 temperature & humidity' },
|
||||
{ name: 'onewire', blurb: '1-Wire bus' },
|
||||
{ name: 'ds18x20', blurb: 'DS18B20 temperature sensor (over onewire)' },
|
||||
{ name: 'framebuf', blurb: 'Frame buffer drawing (used by display drivers)' },
|
||||
];
|
||||
|
||||
export async function searchMpyPackages(q: string): Promise<MpyPackage[]> {
|
||||
const { data } = await axios.get<{ success: boolean; packages: MpyPackage[]; error?: string }>(
|
||||
`${getApiBase()}/micropython-libs/search`,
|
||||
{ params: { q } },
|
||||
);
|
||||
if (!data.success) throw new Error(data.error || 'search failed');
|
||||
return data.packages;
|
||||
}
|
||||
|
||||
export async function fetchMpyPackage(name: string): Promise<MpyFile[]> {
|
||||
const { data } = await axios.post<{ success: boolean; files: MpyFile[]; error?: string }>(
|
||||
`${getApiBase()}/micropython-libs/fetch`,
|
||||
{ name },
|
||||
);
|
||||
if (!data.success) throw new Error(data.error || 'fetch failed');
|
||||
return data.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the package's files into a board's file group. Existing files with
|
||||
* the same path are overwritten (that IS the upgrade path); everything else
|
||||
* is untouched. The group must be the ACTIVE one — the modal operates on the
|
||||
* active board, whose group the editor is already pointed at.
|
||||
*/
|
||||
export function writeFilesIntoGroup(groupId: string, files: MpyFile[]): string[] {
|
||||
const ed = useEditorStore.getState();
|
||||
if (ed.activeGroupId !== groupId) ed.setActiveGroup(groupId);
|
||||
const written: string[] = [];
|
||||
for (const f of files) {
|
||||
const existing = useEditorStore
|
||||
.getState()
|
||||
.getGroupFiles(groupId)
|
||||
.find((x) => x.name === f.path);
|
||||
if (existing) {
|
||||
useEditorStore.getState().updateGroupFile(groupId, existing.id, f.content);
|
||||
} else {
|
||||
const id = useEditorStore.getState().createFile(f.path);
|
||||
useEditorStore.getState().setFileContent(id, f.content);
|
||||
}
|
||||
written.push(f.path);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/** Which of the package's files are already in the group (by exact path). */
|
||||
export function packageFilesInGroup(groupId: string, name: string): boolean {
|
||||
// Cheap heuristic for list rendering: the package's canonical module file
|
||||
// ("ssd1306" -> ssd1306.py, "umqtt.simple" -> umqtt/simple.py) is present.
|
||||
const candidates = [`${name}.py`, `${name.replace(/\./g, '/')}.py`, `${name}/__init__.py`];
|
||||
const names = new Set(
|
||||
useEditorStore
|
||||
.getState()
|
||||
.getGroupFiles(groupId)
|
||||
.map((f) => f.name),
|
||||
);
|
||||
return candidates.some((c) => names.has(c));
|
||||
}
|
||||
|
|
@ -2002,14 +2002,26 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
const auxFiles = files.filter(
|
||||
(f) => f !== mainFile && f.name.endsWith('.py'),
|
||||
);
|
||||
const preludeLines = auxFiles.map((f) => {
|
||||
// A packaged module ("umqtt/simple.py" — how micropython-lib ships
|
||||
// umqtt.simple) needs its directory created first: MicroPython's
|
||||
// open() does not mkdir, so without this the prelude died with
|
||||
// ENOENT before main.py ever ran.
|
||||
const mkdirs = new Set<string>();
|
||||
for (const f of auxFiles) {
|
||||
const segs = f.name.split('/').slice(0, -1);
|
||||
for (let i = 1; i <= segs.length; i++) mkdirs.add(segs.slice(0, i).join('/'));
|
||||
}
|
||||
const mkdirLines = [...mkdirs].sort().map(
|
||||
(d) => `try:\n __import__('os').mkdir(${JSON.stringify(d)})\nexcept OSError:\n pass`,
|
||||
);
|
||||
const preludeLines = mkdirLines.concat(auxFiles.map((f) => {
|
||||
// JSON.stringify produces an ASCII-safe Python-compatible
|
||||
// string literal (both languages share the same \n \r \t \" \\
|
||||
// escapes, and JSON does not emit any escape Python rejects).
|
||||
const lit = JSON.stringify(f.content);
|
||||
const path = JSON.stringify(f.name);
|
||||
return `with open(${path},'w') as _f:\n _f.write(${lit})`;
|
||||
});
|
||||
}));
|
||||
|
||||
// Does this run get a real, WORKING network driver? Two gates:
|
||||
// - the sketch must want WiFi (must match what startBoard()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
"""micropython-libs route (issue #214) — the pure validation layer.
|
||||
|
||||
The network calls are exercised end-to-end against the real index by the
|
||||
deploy smoke; here we pin the rules that make the endpoint safe to expose:
|
||||
source-only, path-safe, bounded, and honest search ordering.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.routes.micropython_libs import (
|
||||
MAX_FILES_PER_PACKAGE,
|
||||
filter_packages,
|
||||
validate_package_files,
|
||||
)
|
||||
|
||||
|
||||
# ── validate_package_files ──────────────────────────────────────────────────
|
||||
|
||||
def test_accepts_plain_py_files():
|
||||
out = validate_package_files(
|
||||
[["ssd1306.py", "156dcec1"], ["umqtt/simple.py", "abc123"]], package="x"
|
||||
)
|
||||
assert out == [("ssd1306.py", "156dcec1"), ("umqtt/simple.py", "abc123")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"driver.mpy", # bytecode — the user could not read what runs
|
||||
"native.so",
|
||||
"README.md",
|
||||
"/etc/passwd.py", # absolute
|
||||
"../evil.py", # traversal
|
||||
"a/../../evil.py",
|
||||
"a//b.py", # empty segment
|
||||
"c:\\windows\\x.py", # backslash
|
||||
],
|
||||
)
|
||||
def test_rejects_unsafe_or_non_source_paths(path):
|
||||
with pytest.raises(ValueError):
|
||||
validate_package_files([[path, "abc123"]], package="x")
|
||||
|
||||
|
||||
def test_rejects_malformed_hash():
|
||||
with pytest.raises(ValueError):
|
||||
validate_package_files([["ok.py", "../f"]], package="x")
|
||||
|
||||
|
||||
def test_rejects_oversized_package():
|
||||
entries = [[f"f{i}.py", "abc123"] for i in range(MAX_FILES_PER_PACKAGE + 1)]
|
||||
with pytest.raises(ValueError):
|
||||
validate_package_files(entries, package="x")
|
||||
|
||||
|
||||
def test_rejects_malformed_entries():
|
||||
with pytest.raises(ValueError):
|
||||
validate_package_files([["only-one-element"]], package="x")
|
||||
|
||||
|
||||
# ── filter_packages ─────────────────────────────────────────────────────────
|
||||
|
||||
INDEX = [
|
||||
{"name": "ssd1306", "description": "OLED display driver"},
|
||||
{"name": "umqtt.simple", "description": "MQTT client"},
|
||||
{"name": "aiohttp", "description": "HTTP client/server with websockets"},
|
||||
]
|
||||
|
||||
|
||||
def test_name_prefix_ranks_before_description_hits():
|
||||
got = [p["name"] for p in filter_packages(INDEX, "ss")]
|
||||
assert got == ["ssd1306"]
|
||||
got = [p["name"] for p in filter_packages(INDEX, "client")]
|
||||
assert set(got) == {"umqtt.simple", "aiohttp"}
|
||||
|
||||
|
||||
def test_empty_query_returns_everything():
|
||||
assert filter_packages(INDEX, " ") == INDEX
|
||||
|
||||
|
||||
def test_prefix_hits_come_first():
|
||||
idx = [
|
||||
{"name": "requests", "description": "umqtt helper"},
|
||||
{"name": "umqtt.robust", "description": ""},
|
||||
]
|
||||
got = [p["name"] for p in filter_packages(idx, "umqtt")]
|
||||
assert got == ["umqtt.robust", "requests"]
|
||||
Loading…
Reference in New Issue