velxio/backend/app/services/arduino_cli.py

954 lines
44 KiB
Python
Raw Normal View History

import subprocess
import tempfile
import asyncio
import base64
import shutil
import re
import os
from pathlib import Path
from app.core.hooks import materialize_library_scope
# A preprocessor "fatal error: Foo.h: No such file or directory" — the signature
# of a missing #include. Used to decide whether a FAILED manifest-scoped compile
# should retry scan-all (the manifest omitted a needed / transitive library) vs
# surface the failure as-is (a genuine source error).
_MISSING_HEADER_RE = re.compile(
r"fatal error:\s*\S+\.h(?:pp)?:\s*No such file or directory", re.IGNORECASE
)
def _looks_like_missing_header(stderr: str | None) -> bool:
return bool(stderr and _MISSING_HEADER_RE.search(stderr))
class ArduinoCLIService:
# Board manager URLs for cores that aren't built-in
CORE_URLS: dict[str, str] = {
"rp2040:rp2040": "https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json",
"esp32:esp32": "https://espressif.github.io/arduino-esp32/package_esp32_index.json",
# Spence Konde's ATTinyCore — needed for ATtiny85 FQBNs like
# ATTinyCore:avr:attinyx5:chip=85,clock=internal16mhz
# Without it arduino-cli reports
# "Platform 'ATTinyCore:avr' not found: platform not installed".
"ATTinyCore:avr": "http://drazzy.com/package_drazzy.com_index.json",
}
# Cores to auto-install on startup
REQUIRED_CORES = ["arduino:avr"]
# Cores to install on-demand when a board FQBN is requested.
# Match order matters: longer / more-specific prefixes first so we don't
# mis-route (e.g. an FQBN that mentions both vendors).
ON_DEMAND_CORES: dict[str, str] = {
"ATTinyCore:avr": "ATTinyCore:avr",
"rp2040": "rp2040:rp2040",
"mbed_rp2040": "arduino:mbed_rp2040",
"esp32": "esp32:esp32",
}
# Version pins for `arduino-cli core install`. Keyed by core ID; if a core
# is in this map we pass `<core>@<version>` instead of just `<core>`.
# ATTinyCore: >=1.5.0 depends on micronucleus hosted at azduino.com, which
# has been unreachable for extended periods. 1.4.1 is the last release
# whose micronucleus tool is on github.com (digistump release) AND still
# supports the FQBN options we use (clock=16pll, etc.). Compile-only —
# micronucleus itself is never invoked here.
CORE_INSTALL_VERSIONS: dict[str, str] = {
"ATTinyCore:avr": "1.4.1",
}
def __init__(self, cli_path: str = "arduino-cli"):
self.cli_path = cli_path
self._ensure_board_urls()
self._ensure_core_installed()
def _ensure_board_urls(self):
"""Register additional board-manager URLs in arduino-cli config."""
try:
# Ensure config file exists (arduino-cli requires it for config add)
result = subprocess.run(
[self.cli_path, "config", "dump", "--format", "json"],
capture_output=True, text=True
)
import json
try:
cfg = json.loads(result.stdout)
except Exception:
cfg = {}
# If config is empty/missing, initialize it
config_dict = cfg.get("config", cfg)
if not config_dict or config_dict == {}:
print("[arduino-cli] Initializing config file...")
subprocess.run(
[self.cli_path, "config", "init", "--overwrite"],
capture_output=True, text=True
)
# Re-read after init
result = subprocess.run(
[self.cli_path, "config", "dump", "--format", "json"],
capture_output=True, text=True
)
try:
cfg = json.loads(result.stdout)
except Exception:
cfg = {}
existing = set()
# Handle both flat and nested config shapes
config_dict = cfg.get("config", cfg)
bm = config_dict.get("board_manager", config_dict)
urls = bm.get("additional_urls", [])
if isinstance(urls, str):
existing.add(urls)
elif isinstance(urls, list):
existing.update(urls)
for url in self.CORE_URLS.values():
if url not in existing:
print(f"[arduino-cli] Adding board manager URL: {url}")
subprocess.run(
[self.cli_path, "config", "add", "board_manager.additional_urls", url],
capture_output=True, text=True
)
# Refresh index so new cores are discoverable
print("[arduino-cli] Updating core index...")
subprocess.run(
[self.cli_path, "core", "update-index"],
capture_output=True, text=True
)
except Exception as e:
print(f"Warning: Could not configure board URLs: {e}")
def _ensure_core_installed(self):
"""
Ensure essential cores (arduino:avr) are installed at startup.
Other cores (RP2040, ESP32) are installed on-demand.
"""
try:
result = subprocess.run(
[self.cli_path, "core", "list"],
capture_output=True,
text=True
)
for core_id in self.REQUIRED_CORES:
if core_id not in result.stdout:
print(f"[arduino-cli] Core {core_id} not installed. Installing...")
subprocess.run(
[self.cli_path, "core", "install", core_id],
check=True
)
print(f"[arduino-cli] Core {core_id} installed successfully")
except Exception as e:
print(f"Warning: Could not verify cores: {e}")
print("Please ensure arduino-cli is installed and in PATH")
def _core_id_for_fqbn(self, fqbn: str) -> str | None:
"""Extract the core ID needed for a given FQBN."""
for prefix, core_id in self.ON_DEMAND_CORES.items():
if prefix in fqbn:
return core_id
return None
def _is_core_installed(self, core_id: str) -> bool:
"""Check whether a core is currently installed."""
result = subprocess.run(
[self.cli_path, "core", "list"],
capture_output=True, text=True
)
return core_id in result.stdout
async def ensure_core_for_board(self, fqbn: str) -> dict:
"""
Auto-install the core required by a board FQBN if not present.
Returns status dict with install log.
"""
core_id = self._core_id_for_fqbn(fqbn)
if core_id is None:
# Built-in core (arduino:avr) — should already be there
return {"needed": False, "installed": True, "core_id": None, "log": ""}
if self._is_core_installed(core_id):
return {"needed": False, "installed": True, "core_id": core_id, "log": ""}
# Install the core (optionally pinned to a specific version)
version = self.CORE_INSTALL_VERSIONS.get(core_id)
install_spec = f"{core_id}@{version}" if version else core_id
print(f"[arduino-cli] Auto-installing core {install_spec} for board {fqbn}...")
def _install():
return subprocess.run(
[self.cli_path, "core", "install", install_spec],
capture_output=True, text=True
)
result = await asyncio.to_thread(_install)
log = result.stdout + "\n" + result.stderr
if result.returncode == 0:
print(f"[arduino-cli] Core {core_id} installed successfully")
return {"needed": True, "installed": True, "core_id": core_id, "log": log.strip()}
else:
print(f"[arduino-cli] Failed to install core {core_id}: {result.stderr}")
return {"needed": True, "installed": False, "core_id": core_id, "log": log.strip()}
async def get_setup_status(self) -> dict:
"""Return the current state of arduino-cli and installed cores."""
try:
version_result = subprocess.run(
[self.cli_path, "version"],
capture_output=True, text=True
)
version = version_result.stdout.strip() if version_result.returncode == 0 else "unknown"
list_result = subprocess.run(
[self.cli_path, "core", "list"],
capture_output=True, text=True
)
cores_raw = list_result.stdout.strip()
except FileNotFoundError:
return {
"cli_available": False,
"version": None,
"cores": [],
"error": "arduino-cli not found in PATH"
}
except Exception as e:
return {
"cli_available": False,
"version": None,
"cores": [],
"error": str(e)
}
# Parse installed cores
cores = []
for line in cores_raw.splitlines()[1:]:
parts = line.split()
if len(parts) >= 3:
cores.append({"id": parts[0], "installed": parts[1], "latest": parts[2]})
return {
"cli_available": True,
"version": version,
"cores": cores,
"error": None
}
def _is_rp2040_board(self, fqbn: str) -> bool:
"""Return True if the FQBN targets an RP2040/RP2350 board."""
return any(p in fqbn for p in ("rp2040", "rp2350", "mbed_rp2040", "mbed_rp2350"))
def _is_esp32_board(self, fqbn: str) -> bool:
"""Return True if the FQBN targets an ESP32 family board."""
return fqbn.startswith("esp32:")
def _is_stm32_board(self, fqbn: str) -> bool:
"""Return True if the FQBN targets an STM32 (STM32duino) board.
STM32 boots from an ELF via QEMU's -kernel (libqemu-arm), so the
emulator wants the .elf artifact, not a flash image."""
return fqbn.startswith("STMicroelectronics:stm32")
def _is_esp32c3_board(self, fqbn: str) -> bool:
"""Return True if the FQBN targets an ESP32-C3 (RISC-V) board.
ESP32-C3 places the bootloader at flash offset 0x0000, unlike Xtensa
boards (ESP32, ESP32-S3) which use 0x1000.
"""
return "esp32c3" in fqbn or "xiao-esp32-c3" in fqbn or "aitewinrobot-esp32c3-supermini" in fqbn
async def compile(
self,
files: list[dict],
board_fqbn: str = "arduino:avr:uno",
board_options: dict | None = None,
allowed_libraries: set[str] | None = None,
owner_id: str | None = None,
) -> dict:
"""
Compile Arduino sketch using arduino-cli.
`files` is a list of {"name": str, "content": str} dicts.
arduino-cli requires the sketch directory to contain a .ino file whose
name matches the directory ("sketch"). If none exists we promote the
first .ino file to sketch.ino automatically.
`board_options` is accepted for API symmetry with the ESP-IDF path
(ESP32 partition/PSRAM/etc selectors live in the UI). It is currently
ignored AVR / RP2040 / ATTiny toolchains don't expose those knobs.
Reserved for future per-board options on those families.
`allowed_libraries` is the per-board manifest = library resolution SCOPE
(P2.1f). When set, ONLY those libraries are made visible to arduino-cli
(a throwaway scratch sketchbook of symlinks materialized by the pro
overlay from the content-addressed cache / owner store, pointed at via
ARDUINO_DIRECTORIES_USER), instead of the shared global volume.
`owner_id` is the project OWNER's id so a shared / embed compile resolves
that owner's custom libraries. None/empty manifest (or no overlay) ->
arduino-cli's default sketchbook -> scan-all (legacy parity).
Returns:
dict with keys: success, hex_content, stdout, stderr, error
"""
_ = board_options # reserved; see docstring
print(f"\n=== Starting compilation ===")
print(f"Board: {board_fqbn}")
print(f"Files: {[f['name'] for f in files]}")
# Create temporary directory for sketch
with tempfile.TemporaryDirectory() as temp_dir:
sketch_dir = Path(temp_dir) / "sketch"
sketch_dir.mkdir()
# Determine whether the caller already provides a "sketch.ino"
has_sketch_ino = any(f["name"] == "sketch.ino" for f in files)
main_ino_written = False
for file_entry in files:
name: str = file_entry["name"]
content: str = file_entry["content"]
# Promote the first .ino to sketch.ino if none explicitly named so
write_name = name
if not has_sketch_ino and name.endswith(".ino") and not main_ino_written:
write_name = "sketch.ino"
main_ino_written = True
# RP2040: redirect Serial → Serial1 in the main sketch file only
if "rp2040" in board_fqbn and write_name == "sketch.ino":
content = "#define Serial Serial1\n" + content
(sketch_dir / write_name).write_text(content, encoding="utf-8")
# Fallback: no .ino files provided at all
if not any(f["name"].endswith(".ino") for f in files):
(sketch_dir / "sketch.ino").write_text("void setup(){}\nvoid loop(){}", encoding="utf-8")
print(f"Sketch directory contents: {[p.name for p in sketch_dir.iterdir()]}")
build_dir = sketch_dir / "build"
build_dir.mkdir()
print(f"Build directory: {build_dir}")
# P2.1f — manifest-scoped library resolution. Symlink ONLY the
# declared libraries (resolved owner-store -> content-addressed
# cache -> legacy global dir) into a throwaway scratch sketchbook and
# point arduino-cli's USER directory at it, so it scans ONLY those
# libraries instead of the shared mutable global volume. None/empty
# manifest (or no pro overlay) -> no override -> arduino-cli's default
# sketchbook -> legacy global scan-all (parity).
#
# Mechanism: ARDUINO_DIRECTORIES_USER (the sketchbook), NOT the
# --libraries flag. Verified empirically that `--libraries` ADDS to
# the search path (the global sketchbook is STILL scanned, so it does
# not isolate), whereas pointing ARDUINO_DIRECTORIES_USER at the
# scratch root makes <scratch>/libraries the ONLY user-library dir.
# scope_dir == <scratch>/libraries, so its parent is the sketchbook
# root. Cores + board-manager URLs live in the DATA dir and are
# untouched, so RP2040 / ATTinyCore / AVR core resolution stays intact.
scope_dir = None
try:
scope = materialize_library_scope(allowed_libraries, owner_id)
scope_dir = scope[0] if scope else None
compile_env = dict(os.environ)
if scope_dir is not None:
compile_env["ARDUINO_DIRECTORIES_USER"] = str(scope_dir.parent)
else:
# P2.1h: NO manifest -> point the default sketchbook at the
# content-addressed cache (VELXIO_FALLBACK_SKETCHBOOK, whose
# libraries/ is the cache root) instead of the shared global
# volume, so a from-scratch / no-manifest compile (and the
# scan-all retry, which re-enters here unscoped) resolves user
# libraries from the cache. Unset (OSS self-host) -> arduino-
# cli's default sketchbook (legacy global volume).
_fb = os.environ.get("VELXIO_FALLBACK_SKETCHBOOK")
if _fb:
compile_env["ARDUINO_DIRECTORIES_USER"] = _fb
# Run compilation using subprocess.run in a thread (Windows compatible)
# ESP32 lcgamboa emulator requires DIO flash mode and
# IRAM-safe interrupt placement to avoid cache errors.
# Force these at compile time for all ESP32 targets.
cmd = [self.cli_path, "compile", "--fqbn", board_fqbn]
if self._is_esp32_board(board_fqbn):
# FlashMode=dio: required by esp32-picsimlab QEMU machine
# IRAM_ATTR on all interrupt handlers prevents cache crashes
# when WiFi emulation disables the SPI flash cache on core 1.
fqbn_dio = board_fqbn
if 'FlashMode' not in board_fqbn:
fqbn_dio = board_fqbn + ':FlashMode=dio'
cmd[2] = '--fqbn'
cmd.insert(3, fqbn_dio)
cmd = cmd[:4] # trim accidental duplicates
cmd = [self.cli_path, "compile", "--fqbn", fqbn_dio,
"--build-property",
"build.extra_flags=-DARDUINO_ESP32_LCGAMBOA=1",
# Adafruit_BusIO 1.17.x dropped BitOrder on ESP32 3.x;
# this define restores it as uint8_t (the type it was).
"--build-property",
"compiler.cpp.extra_flags=-DBitOrder=uint8_t",
"--output-dir", str(build_dir),
str(sketch_dir)]
else:
cmd = [self.cli_path, "compile", "--fqbn", board_fqbn,
"--output-dir", str(build_dir),
str(sketch_dir)]
print(f"Running command: {' '.join(cmd)}")
# Use subprocess.run in a thread for Windows compatibility
def run_compile():
return subprocess.run(
cmd,
capture_output=True,
text=True,
env=compile_env,
)
result = await asyncio.to_thread(run_compile)
print(f"Process return code: {result.returncode}")
print(f"Stdout: {result.stdout}")
print(f"Stderr: {result.stderr}")
if result.returncode == 0:
print(f"Files in build dir: {list(build_dir.iterdir())}")
if self._is_rp2040_board(board_fqbn):
# RP2040 outputs a .bin file (and optionally .uf2)
# Try .bin first (raw binary, simplest to load into emulator)
bin_file = build_dir / "sketch.ino.bin"
uf2_file = build_dir / "sketch.ino.uf2"
target_file = bin_file if bin_file.exists() else (uf2_file if uf2_file.exists() else None)
if target_file:
raw_bytes = target_file.read_bytes()
binary_b64 = base64.b64encode(raw_bytes).decode('ascii')
print(f"[RP2040] Binary file: {target_file.name}, size: {len(raw_bytes)} bytes")
print("=== RP2040 Compilation successful ===\n")
return {
"success": True,
"hex_content": None,
"binary_content": binary_b64,
"binary_type": "bin" if target_file == bin_file else "uf2",
"stdout": result.stdout,
"stderr": result.stderr
}
else:
print(f"[RP2040] Binary file not found. Files: {list(build_dir.iterdir())}")
print("=== RP2040 Compilation failed: binary not found ===\n")
return {
"success": False,
"error": "RP2040 binary (.bin/.uf2) not found after compilation",
"stdout": result.stdout,
"stderr": result.stderr
}
elif self._is_esp32_board(board_fqbn):
# ESP32 outputs individual .bin files that must be merged into a
# single 4MB flash image for QEMU lcgamboa to boot correctly.
bin_file = build_dir / "sketch.ino.bin"
bootloader_file = build_dir / "sketch.ino.bootloader.bin"
partitions_file = build_dir / "sketch.ino.partitions.bin"
merged_file = build_dir / "sketch.ino.merged.bin"
print(f"[ESP32] Build dir contents: {[f.name for f in build_dir.iterdir()]}")
# Merge individual .bin files into a single 4MB flash image in pure Python.
# Flash layout differs by chip:
# ESP32 / ESP32-S3 (Xtensa): 0x1000 bootloader | 0x8000 partitions | 0x10000 app
# ESP32-C3 (RISC-V): 0x0000 bootloader | 0x8000 partitions | 0x10000 app
# QEMU lcgamboa requires exactly 2/4/8/16 MB flash — raw app binary won't boot.
if not merged_file.exists() and bin_file.exists() and bootloader_file.exists() and partitions_file.exists():
print("[ESP32] Merging binaries into 4MB flash image (pure Python)...")
try:
FLASH_SIZE = 4 * 1024 * 1024 # 4 MB
flash = bytearray(b'\xff' * FLASH_SIZE)
bootloader_offset = 0x0000 if self._is_esp32c3_board(board_fqbn) else 0x1000
for offset, path in [
(bootloader_offset, bootloader_file),
(0x8000, partitions_file),
(0x10000, bin_file),
]:
data = path.read_bytes()
flash[offset:offset + len(data)] = data
merged_file.write_bytes(bytes(flash))
print(f"[ESP32] Merged image: {merged_file.stat().st_size} bytes (bootloader @ 0x{bootloader_offset:04X})")
except Exception as e:
print(f"[ESP32] Merge failed: {e} — falling back to raw app binary")
target_file = merged_file if merged_file.exists() else (bin_file if bin_file.exists() else None)
if target_file:
raw_bytes = target_file.read_bytes()
binary_b64 = base64.b64encode(raw_bytes).decode('ascii')
print(f"[ESP32] Binary file: {target_file.name}, size: {len(raw_bytes)} bytes")
print("=== ESP32 Compilation successful ===\n")
return {
"success": True,
"hex_content": None,
"binary_content": binary_b64,
"binary_type": "bin",
"stdout": result.stdout,
"stderr": result.stderr
}
else:
print(f"[ESP32] Binary file not found. Files: {list(build_dir.iterdir())}")
print("=== ESP32 Compilation failed: binary not found ===\n")
return {
"success": False,
"error": "ESP32 binary (.bin) not found after compilation",
"stdout": result.stdout,
"stderr": result.stderr
}
elif self._is_stm32_board(board_fqbn):
# STM32 (STM32duino) boots from an ELF via QEMU -kernel.
elf_file = build_dir / "sketch.ino.elf"
bin_file = build_dir / "sketch.ino.bin"
target_file = elf_file if elf_file.exists() else (bin_file if bin_file.exists() else None)
if target_file:
raw_bytes = target_file.read_bytes()
binary_b64 = base64.b64encode(raw_bytes).decode('ascii')
print(f"[STM32] Binary file: {target_file.name}, size: {len(raw_bytes)} bytes")
print("=== STM32 Compilation successful ===\n")
return {
"success": True,
"hex_content": None,
"binary_content": binary_b64,
"binary_type": "elf" if target_file == elf_file else "bin",
"stdout": result.stdout,
"stderr": result.stderr,
}
else:
print(f"[STM32] ELF/bin not found. Files: {list(build_dir.iterdir())}")
return {
"success": False,
"error": "STM32 firmware (.elf/.bin) not found after compilation",
"stdout": result.stdout,
"stderr": result.stderr,
}
else:
# AVR outputs a .hex file (Intel HEX format)
hex_file = build_dir / "sketch.ino.hex"
print(f"Looking for hex file at: {hex_file}")
print(f"Hex file exists: {hex_file.exists()}")
if hex_file.exists():
hex_content = hex_file.read_text()
print(f"Hex file size: {len(hex_content)} bytes")
print("=== AVR Compilation successful ===\n")
return {
"success": True,
"hex_content": hex_content,
"binary_content": None,
"stdout": result.stdout,
"stderr": result.stderr
}
else:
print(f"Files in build dir: {list(build_dir.iterdir())}")
print("=== Compilation failed: hex file not found ===\n")
return {
"success": False,
"error": "Hex file not found after compilation",
"stdout": result.stdout,
"stderr": result.stderr
}
else:
print("=== Compilation failed ===\n")
# P2.1f graceful fallback (mirrors the ESP-IDF path): a
# manifest-scoped compile points ARDUINO_DIRECTORIES_USER at
# a sketchbook holding ONLY the declared libraries, so the
# global volume is not scanned. If the manifest omitted a
# needed library or a transitive dependency, a header goes
# missing and the build hard-fails where the legacy global
# scan-all would have found it. So when a scope was applied
# and the failure is a missing #include, retry ONCE without
# the scope (global scan-all) and flag the manifest as
# incomplete. A genuine source error fails both attempts and
# returns the original scoped failure below.
if scope_dir is not None and _looks_like_missing_header(result.stderr):
print("=== Incomplete manifest — retrying scan-all ===\n")
retry = await self.compile(
files, board_fqbn, board_options=board_options,
) # allowed_libraries=None -> no scope -> no further retry
if retry.get("success"):
retry["manifest_incomplete"] = True
return retry
return {
"success": False,
"error": "Compilation failed",
"stdout": result.stdout,
"stderr": result.stderr
}
except Exception as e:
print(f"=== Exception during compilation: {e} ===\n")
import traceback
traceback.print_exc()
return {
"success": False,
"error": str(e),
"stdout": "",
"stderr": ""
}
finally:
if scope_dir is not None:
# rmtree unlinks the symlinks, never their cache / store /
# legacy targets.
shutil.rmtree(scope_dir.parent, ignore_errors=True)
async def list_boards(self) -> list:
"""
List available Arduino boards
"""
try:
process = await asyncio.create_subprocess_exec(
self.cli_path,
"board",
"listall",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, _ = await process.communicate()
# Parse output (format: "Board Name FQBN")
boards = []
for line in stdout.decode().splitlines()[1:]: # Skip header
if line.strip():
parts = line.split()
if len(parts) >= 2:
name = " ".join(parts[:-1])
fqbn = parts[-1]
boards.append({"name": name, "fqbn": fqbn})
return boards
except Exception as e:
print(f"Error listing boards: {e}")
return []
async def search_libraries(self, query: str) -> dict:
"""
Search for Arduino libraries
"""
try:
def _run():
return subprocess.run(
[self.cli_path, "lib", "search", query, "--format", "json"],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
result = await asyncio.to_thread(_run)
stdout, stderr = result.stdout, result.stderr
if result.returncode != 0:
print(f"Error searching libraries: {stderr}")
return {"success": False, "error": stderr}
import json
try:
results = json.loads(stdout)
libraries = results.get("libraries", [])
# arduino-cli search returns each lib with a "releases" dict.
# Inject a "latest" key with the data of the highest version so the
# frontend can access lib.latest.version / author / sentence directly.
def _parse_version(v: str):
try:
parts = v.split(".")
# Reject if any part is not a digit (filters out "1_2_3", "beta", "latest")
if any(not p.isdigit() for p in parts):
return (0,)
return tuple(int(p) for p in parts)
except Exception:
return (0,)
for lib in libraries:
releases = lib.get("releases") or {}
if releases:
latest_key = max(releases.keys(), key=_parse_version)
lib["latest"] = {**releases[latest_key], "version": latest_key}
return {"success": True, "libraries": libraries}
except json.JSONDecodeError:
return {"success": False, "error": "Invalid output format from arduino-cli"}
except Exception as e:
print(f"Exception searching libraries: {e}")
return {"success": False, "error": str(e)}
async def install_library(self, library_name: str) -> dict:
"""
Install an Arduino library.
Handles standard library names as well as Wokwi-hosted entries in
the form "LibName@wokwi:projectHash".
Also handles versioned installs via "LibName@version" syntax
(e.g. "Adafruit NeoPixel@1.11.0").
@latest is stripped arduino-cli does not support it.
Malformed version strings (non-semver) fall back to plain name install.
"""
if '@wokwi:' in library_name:
return await self._install_wokwi_library(library_name)
# Strip @latest — arduino-cli does not support this token
if library_name.endswith('@latest'):
library_name = library_name[:-7]
try:
print(f"Installing library: {library_name}")
# Handle "Name@version" syntax for versioned installs
# Only quote if the version part is valid semver (major.minor.patch)
import re
lib_spec = library_name
if '@' in library_name:
parts = library_name.rsplit('@', 1)
if len(parts) == 2 and parts[1]:
version = parts[1]
# Validate semver: major.minor.patch (all numeric)
if re.fullmatch(r'\d+\.\d+\.\d+', version):
lib_spec = library_name # no quotes needed — subprocess passes args literally
else:
# Bad/empty version — fall back to plain name
library_name = parts[0]
lib_spec = library_name
def _run():
return subprocess.run(
[self.cli_path, "lib", "install", lib_spec],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
result = await asyncio.to_thread(_run)
if result.returncode == 0:
print(f"Successfully installed {library_name}")
return {"success": True, "stdout": result.stdout}
else:
# If a specific version failed, retry with plain name (latest) in case
# the version string is valid semver but rejected by arduino-cli for
# other reasons (e.g. leading zeros, lib index corruption).
if '@' in library_name:
plain_name = library_name.rsplit('@', 1)[0]
version = library_name.rsplit('@', 1)[1]
print(f"Versioned install failed, retrying with plain name: {plain_name}")
def _run_plain():
return subprocess.run(
[self.cli_path, "lib", "install", plain_name],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
result = await asyncio.to_thread(_run_plain)
if result.returncode == 0:
print(f"Successfully installed {plain_name} (fallback to latest)")
return {
"success": True,
"stdout": result.stdout,
"fallback": True,
"requested_version": version,
}
print(f"Failed to install {library_name}: {result.stderr}")
return {"success": False, "error": result.stderr, "stdout": result.stdout}
except Exception as e:
print(f"Exception installing library: {e}")
return {"success": False, "error": str(e)}
async def _install_wokwi_library(self, library_spec: str) -> dict:
"""
Download and install a Wokwi-hosted library.
Wokwi stores custom libraries as projects. The spec format is:
LibName@wokwi:projectHash
and the project ZIP is available at:
https://wokwi.com/api/projects/{projectHash}/zip
The ZIP is extracted into the Arduino user libraries directory so that
arduino-cli can find the headers during compilation.
"""
import json as _json
import urllib.request
import urllib.error
import zipfile
import os
import shutil
parts = library_spec.split('@wokwi:', 1)
lib_name = parts[0].strip()
project_hash = parts[1].strip()
print(f"Installing Wokwi library: {lib_name} (project: {project_hash})")
# ── Locate the Arduino user libraries directory ────────────────────────
try:
def _get_config():
return subprocess.run(
[self.cli_path, "config", "dump", "--format", "json"],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
cfg_result = await asyncio.to_thread(_get_config)
cfg = _json.loads(cfg_result.stdout)
config_dict = cfg.get("config", cfg)
dirs = config_dict.get("directories", {})
user_dir = dirs.get("user", "") or dirs.get("sketchbook", "")
if not user_dir:
return {"success": False, "error": "Could not determine Arduino user directory from config"}
lib_dir = Path(user_dir) / "libraries" / lib_name
except Exception as e:
return {"success": False, "error": f"Failed to read arduino-cli config: {e}"}
# ── Download project ZIP ───────────────────────────────────────────────
url = f"https://wokwi.com/api/projects/{project_hash}/zip"
tmp_path = None
try:
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
def _download():
req = urllib.request.Request(
url,
headers={"User-Agent": "velxio-arduino-emulator/1.0"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp, \
open(tmp_path, 'wb') as out:
out.write(resp.read())
except urllib.error.HTTPError as http_err:
raise RuntimeError(
f"Could not download Wokwi library '{lib_name}' "
f"(HTTP {http_err.code}). "
f"Wokwi-hosted libraries require the Wokwi platform and "
f"cannot be installed automatically in a local environment."
) from http_err
await asyncio.to_thread(_download)
# ── Extract into the libraries directory ───────────────────────────
if lib_dir.exists():
shutil.rmtree(lib_dir)
lib_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(tmp_path, 'r') as zf:
for zi in zf.infolist():
# Skip directories and Wokwi-specific files
if zi.is_dir():
continue
fname = zi.filename
basename = Path(fname).name
if not basename or basename == 'wokwi-project.txt':
continue
# Flatten any subdirectory structure
dest = lib_dir / basename
dest.write_bytes(zf.read(fname))
# Create a minimal library.properties so arduino-cli recognises it
props = lib_dir / "library.properties"
if not props.exists():
props.write_text(
f"name={lib_name}\nversion=1.0.0\nauthor=Wokwi\n"
f"sentence=Wokwi-hosted library\nparagraph=\ncategory=Other\n"
f"url=https://wokwi.com/projects/{project_hash}\n"
f"architectures=*\n"
)
print(f"Installed Wokwi library {lib_name} to {lib_dir}")
return {"success": True, "stdout": f"Installed {lib_name} from Wokwi project {project_hash}"}
except Exception as e:
print(f"Error installing Wokwi library {lib_name}: {e}")
return {"success": False, "error": str(e)}
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
async def list_installed_libraries(self) -> dict:
"""
List all installed Arduino libraries.
P2.1h: when VELXIO_FALLBACK_SKETCHBOOK is set (pro overlay), list the
content-addressed cache (its libraries/ is the cache root) instead of the
shared global volume, so the Library Manager 'Installed' view survives the
global volume's retirement. Unset (OSS) -> arduino-cli's default sketchbook.
"""
try:
list_env = dict(os.environ)
_fb = os.environ.get("VELXIO_FALLBACK_SKETCHBOOK")
if _fb:
list_env["ARDUINO_DIRECTORIES_USER"] = _fb
def _run():
return subprocess.run(
[self.cli_path, "lib", "list", "--format", "json"],
capture_output=True, text=True, encoding='utf-8', errors='replace',
env=list_env,
)
result = await asyncio.to_thread(_run)
stdout, stderr = result.stdout, result.stderr
if result.returncode != 0:
print(f"Error listing libraries: {stderr}")
return {"success": False, "error": stderr}
import json
try:
if not stdout.strip():
return {"success": True, "libraries": []}
results = json.loads(stdout)
# arduino-cli lib list --format json wraps results in "installed_libraries"
if isinstance(results, list):
libraries = results
elif isinstance(results, dict):
libraries = (
results.get("installed_libraries")
or results.get("libraries")
or []
)
else:
libraries = []
return {"success": True, "libraries": libraries}
except json.JSONDecodeError:
return {"success": False, "error": "Invalid output format from arduino-cli"}
except Exception as e:
print(f"Exception listing libraries: {e}")
return {"success": False, "error": str(e)}
async def uninstall_library(self, library_name: str) -> dict:
"""
Uninstall an Arduino library.
"""
try:
print(f"Uninstalling library: {library_name}")
def _run():
return subprocess.run(
[self.cli_path, "lib", "uninstall", library_name],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
result = await asyncio.to_thread(_run)
if result.returncode == 0:
print(f"Successfully uninstalled {library_name}")
return {"success": True, "stdout": result.stdout}
else:
print(f"Failed to uninstall {library_name}: {result.stderr}")
return {"success": False, "error": result.stderr, "stdout": result.stdout}
except Exception as e:
print(f"Exception uninstalling library: {e}")
return {"success": False, "error": str(e)}