724 lines
29 KiB
Python
724 lines
29 KiB
Python
"""
|
|
Generator for test_100_days
|
|
|
|
This script reads every project under
|
|
third-party/100_Days_100_IoT_Projects/
|
|
classifies it against Velxio's emulation capabilities, copies the source
|
|
files into a per-project sub-folder under test/test_100_days/, and emits
|
|
either:
|
|
- test_project.py (for projects Velxio can run)
|
|
- NOT_SUPPORTED.md (for projects Velxio cannot run as-is)
|
|
|
|
It is idempotent: re-running it overwrites existing per-project tests
|
|
and the README.md index, but does NOT touch _lib.py / conftest.py once
|
|
they exist.
|
|
|
|
Run from repo root:
|
|
python test/test_100_days/_generate.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
PROJECTS_DIR = REPO_ROOT / "third-party" / "100_Days_100_IoT_Projects"
|
|
OUT_DIR = REPO_ROOT / "test" / "test_100_days"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Velxio capability matrix (source-of-truth: frontend/src/types/board.ts +
|
|
# backend/app/services/esp32_worker.py).
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
SUPPORTED_BOARDS = {
|
|
"esp32", # esp32:esp32:esp32 — full QEMU + WiFi (slirp)
|
|
"esp32-s3", # esp32:esp32:esp32s3
|
|
"esp32-c3", # esp32:esp32:esp32c3
|
|
"xiao-esp32", # XIAO ESP32-S3/C3 — same QEMU
|
|
"rp2040", # rp2040:rp2040:rpipico — avr8js → rp2040js
|
|
"pico-w", # rp2040:rp2040:rpipicow — partial WiFi
|
|
}
|
|
|
|
UNSUPPORTED_BOARDS = {
|
|
"esp8266", # No ESP8266 emulator/firmware in Velxio
|
|
}
|
|
|
|
# Emulation gaps (these features have no working backend path)
|
|
UNSUPPORTED_FEATURES = {
|
|
"esp_now": "ESP-NOW (peer-to-peer 802.11) is not implemented in Velxio QEMU. "
|
|
"WiFi NIC uses slirp user-mode networking, which only forwards "
|
|
"TCP/UDP through the host stack — raw 802.11 management frames "
|
|
"between two virtual ESP32s cannot be routed.",
|
|
"ble_full": "Full BLE GATT server/client is not emulated by the Espressif "
|
|
"QEMU build Velxio ships with. Bridge code prints ble_status "
|
|
"events but no scan/advertise/connect path exists.",
|
|
"tkinter": "CustomTkinter / Tkinter run on the host (desktop OS), not on "
|
|
"the emulated MCU. Velxio does not host a Python desktop GUI.",
|
|
"flask": "Flask runs on the host PC, not on the MCU. The MCU side of "
|
|
"this project may emulate, but the Flask half cannot run "
|
|
"inside Velxio.",
|
|
"matplotlib_host": "Matplotlib runs on the host (CPython), not the MCU. "
|
|
"The data-producing MCU half may emulate; the plotting half "
|
|
"is host-only.",
|
|
"octave": "Anomaly-detection .m file is GNU Octave / MATLAB code that "
|
|
"runs on the host, not on the emulated MCU.",
|
|
"no_code": "Project folder contains only README/LICENSE — no source "
|
|
"code is present to emulate.",
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Project discovery
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
CODE_EXTS = {".py", ".ino", ".cpp", ".c", ".h"}
|
|
|
|
|
|
def list_projects() -> list[Path]:
|
|
if not PROJECTS_DIR.is_dir():
|
|
raise SystemExit(f"projects dir not found: {PROJECTS_DIR}")
|
|
out = []
|
|
for entry in sorted(PROJECTS_DIR.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
if entry.name.startswith("."): # .git, .github, …
|
|
continue
|
|
if entry.name == "IMAGES":
|
|
continue
|
|
out.append(entry)
|
|
return out
|
|
|
|
|
|
def find_code_files(project_dir: Path) -> list[Path]:
|
|
"""Return a stable list of source files belonging to the project.
|
|
|
|
Search order:
|
|
1. <project>/Main Files/* (the canonical convention used by 100_Days)
|
|
2. <project>/Main File/*
|
|
3. <project>/Code/*
|
|
4. <project>/<Subdir>/* (deep scan, max depth 3)
|
|
"""
|
|
candidates = []
|
|
for sub in ("Main Files", "Main File", "Main", "MainFiles", "Code", "code"):
|
|
d = project_dir / sub
|
|
if d.is_dir():
|
|
for f in sorted(d.rglob("*")):
|
|
if f.is_file() and f.suffix.lower() in CODE_EXTS:
|
|
candidates.append(f)
|
|
if candidates:
|
|
return candidates
|
|
|
|
# Deep scan, max depth 3, excluding known non-code folders
|
|
skip = {"Circuit_Diagram", "Circuit Diagram", "circuit_diagram",
|
|
"Images", "images", "dashboard_images"}
|
|
for f in sorted(project_dir.rglob("*")):
|
|
if not f.is_file():
|
|
continue
|
|
if any(part in skip for part in f.parts):
|
|
continue
|
|
if f.suffix.lower() in CODE_EXTS:
|
|
try:
|
|
depth = len(f.relative_to(project_dir).parts)
|
|
except ValueError:
|
|
continue
|
|
if depth <= 4:
|
|
candidates.append(f)
|
|
return candidates
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Classification
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
BOARD_PATTERNS = [
|
|
# (regex applied case-insensitively to BOTH project name and source code, board_kind)
|
|
# Note: \b doesn't fire between letters and `_`, so we use lookarounds /
|
|
# explicit non-letter/digit boundaries to avoid missing `esp32_`, `esp32(…`.
|
|
(r"esp32[-_]?c3", "esp32-c3"),
|
|
(r"esp32[-_]?s3", "esp32-s3"),
|
|
(r"xiao[-_ ]?esp32", "xiao-esp32"),
|
|
(r"esp8266", "esp8266"),
|
|
(r"(?:^|[^a-z])pico[-_ ]?w(?:[^a-z]|$)|raspberry[-_ ]?pi[-_ ]?pico[-_ ]?(?:2[-_ ]?)?w",
|
|
"pico-w"),
|
|
(r"(?:^|[^a-z])pico(?:[^a-z]|$)|rp2040|raspberry[-_ ]?pi[-_ ]?pico", "rp2040"),
|
|
(r"(?:^|[^a-z])esp32(?:[^a-z0-9]|$)", "esp32"),
|
|
]
|
|
|
|
FEATURE_PATTERNS = {
|
|
"pyfirmata": r"pyfirmata|pyfirmata2",
|
|
"wifi": r"network\.WLAN|import\s+network\b|WiFi\.begin|connectWiFi|wlan\.connect",
|
|
"blynk": r"BlynkLib|blynk\.virtual_write|@blynk\.on|Blynk\.run",
|
|
"telegram": r"api\.telegram\.org|telegram_bot|sendMessage\?",
|
|
"thingspeak": r"thingspeak|api\.thingspeak\.com",
|
|
"blynk_cloud": r"blynk\.cloud|blynk\.io",
|
|
"mqtt": r"\bumqtt|MQTTClient|paho\.mqtt|mqtt\.publish|mqtt\.connect",
|
|
"esp_now": r"\bespnow\b|esp_now|ESPNow|ESP_NOW",
|
|
"ble": r"\bbluetooth\b|\bBLE\b|nimble|ubluetooth",
|
|
"tkinter": r"customtkinter|\btkinter\b|from\s+tkinter",
|
|
"flask": r"from\s+flask\b|import\s+flask\b|Flask\(",
|
|
"matplotlib": r"matplotlib|pyplot",
|
|
"octave": r"\.m\b", # matched on filename, not code
|
|
"i2c_oled": r"ssd1306|sh1106|SSD1306_I2C",
|
|
"dht": r"\bimport\s+dht\b|DHT11|DHT22|dht\.DHT",
|
|
"rfid": r"mfrc522|MFRC522",
|
|
"max7219": r"max7219|MAX7219",
|
|
"tm1637": r"\btm1637\b",
|
|
"servo": r"\bservo\b|PWM\(.*Pin",
|
|
"stepper": r"A4988|stepper",
|
|
"ldr": r"\bLDR\b|ldr\b",
|
|
"ultrasonic": r"hcsr04|HCSR04|trig\b.*echo\b",
|
|
"lcd": r"i2c_lcd|LCD_I2C|lcd_api",
|
|
"rtc": r"\burtc\b|DS1307|DS3231|rtc\.datetime",
|
|
"ws2812": r"neopixel|NeoPixel|ws2812",
|
|
"ota": r"\bota\b|OTA",
|
|
"websocket": r"\bwebsocket|uwebsockets|async_websocket_server",
|
|
"http_server": r"socket\.bind|socket\.listen|microdot|app\.route",
|
|
}
|
|
|
|
|
|
def classify_project(project: Path, sources: list[Path]) -> dict:
|
|
"""Return a classification dict for one project."""
|
|
name = project.name
|
|
blob = (name + "\n").lower()
|
|
src_blob_parts: list[str] = []
|
|
file_names: list[str] = []
|
|
for s in sources:
|
|
try:
|
|
content = s.read_text(encoding="utf-8", errors="ignore")
|
|
except OSError:
|
|
content = ""
|
|
src_blob_parts.append(content)
|
|
file_names.append(s.name)
|
|
src_blob = "\n".join(src_blob_parts).lower()
|
|
_ = blob # only used to seed src_blob below; kept to mirror old logic
|
|
|
|
# --- Board detection ---
|
|
board: str | None = None
|
|
for pat, b in BOARD_PATTERNS:
|
|
if re.search(pat, name, re.I) or re.search(pat, src_blob, re.I):
|
|
board = b
|
|
break
|
|
# Heuristic fallback: bare MicroPython without any board hint → assume esp32
|
|
if board is None and any(s.suffix.lower() == ".py" for s in sources):
|
|
if "machine" in src_blob or "from machine" in src_blob:
|
|
board = "esp32"
|
|
|
|
# --- Feature detection ---
|
|
features: list[str] = []
|
|
for feat, pat in FEATURE_PATTERNS.items():
|
|
if feat == "octave":
|
|
if any(fn.lower().endswith(".m") for fn in file_names):
|
|
features.append(feat)
|
|
continue
|
|
if re.search(pat, src_blob, re.I):
|
|
features.append(feat)
|
|
|
|
# --- Language detection ---
|
|
langs = sorted({s.suffix.lower().lstrip(".") for s in sources if s.suffix})
|
|
|
|
# --- Reasons that block emulation ---
|
|
blockers: list[tuple[str, str]] = []
|
|
if not sources:
|
|
blockers.append(("no_code", UNSUPPORTED_FEATURES["no_code"]))
|
|
if board == "esp8266":
|
|
blockers.append(("esp8266", "ESP8266 has no QEMU/firmware path in Velxio."))
|
|
if "esp_now" in features:
|
|
blockers.append(("esp_now", UNSUPPORTED_FEATURES["esp_now"]))
|
|
if "tkinter" in features:
|
|
blockers.append(("tkinter", UNSUPPORTED_FEATURES["tkinter"]))
|
|
if "pyfirmata" in features:
|
|
blockers.append((
|
|
"pyfirmata",
|
|
"Project drives an Arduino from the host via pyfirmata over a real "
|
|
"USB serial port. There is no MCU sketch in this folder for Velxio "
|
|
"to compile and run — only host-side desktop Python."
|
|
))
|
|
|
|
# Flask + Matplotlib + Octave: the MCU side may run, but the host side
|
|
# cannot. We mark as "host_only_dependency" but still keep them runnable
|
|
# if there is also an MCU sketch present.
|
|
host_dep: list[str] = []
|
|
if "flask" in features:
|
|
host_dep.append("flask")
|
|
if "matplotlib" in features:
|
|
host_dep.append("matplotlib")
|
|
if "octave" in features:
|
|
host_dep.append("octave")
|
|
|
|
return dict(
|
|
name=name,
|
|
board=board,
|
|
features=features,
|
|
languages=langs,
|
|
sources=sources,
|
|
blockers=blockers,
|
|
host_dependencies=host_dep,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Filesystem helpers
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
_SAFE_RE = re.compile(r"[^A-Za-z0-9_]+")
|
|
|
|
|
|
def safe_dirname(name: str) -> str:
|
|
"""Make a project name safe for use as a Python-discoverable folder."""
|
|
s = name.replace("&", "and").replace("+", "_plus_")
|
|
s = _SAFE_RE.sub("_", s)
|
|
s = re.sub(r"_+", "_", s).strip("_")
|
|
return s or "project"
|
|
|
|
|
|
def copy_sources(sources: list[Path], dest: Path) -> list[str]:
|
|
"""Copy source files into dest/source/ preserving relative layout when possible.
|
|
|
|
Returns the list of relative paths written.
|
|
"""
|
|
src_root = dest / "source"
|
|
if src_root.exists():
|
|
shutil.rmtree(src_root)
|
|
src_root.mkdir(parents=True, exist_ok=True)
|
|
written: list[str] = []
|
|
seen = set()
|
|
for s in sources:
|
|
leaf = s.name
|
|
# Avoid collisions when multiple files share the same name
|
|
target = src_root / leaf
|
|
i = 1
|
|
while target.name in seen:
|
|
target = src_root / f"{target.stem}_{i}{target.suffix}"
|
|
i += 1
|
|
seen.add(target.name)
|
|
try:
|
|
shutil.copyfile(s, target)
|
|
written.append(target.relative_to(dest).as_posix())
|
|
except OSError as e:
|
|
written.append(f"# copy failed: {leaf}: {e}")
|
|
return written
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Test / NOT_SUPPORTED.md emitters
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def emit_supported_test(dest: Path, info: dict) -> None:
|
|
"""Write test_project.py for a project Velxio can emulate."""
|
|
rel_sources = sorted(
|
|
(dest / "source").rglob("*"),
|
|
)
|
|
rel_sources = [p.relative_to(dest).as_posix()
|
|
for p in rel_sources
|
|
if p.is_file() and p.suffix.lower() in CODE_EXTS]
|
|
|
|
board = info["board"]
|
|
features = info["features"]
|
|
host_deps = info["host_dependencies"]
|
|
|
|
py_files = [p for p in rel_sources if p.endswith(".py")]
|
|
_ino = [p for p in rel_sources if p.endswith(".ino")]
|
|
_cpp = [p for p in rel_sources if p.endswith((".cpp", ".c", ".h"))]
|
|
_ = (py_files, _ino, _cpp) # presence affects which sub-tests skip at runtime
|
|
|
|
notes = []
|
|
if "wifi" in features:
|
|
notes.append("WiFi via QEMU slirp NIC (-nic user,model=esp32_wifi).")
|
|
if "blynk" in features or "thingspeak" in features or "telegram" in features:
|
|
notes.append(
|
|
"Cloud service auth tokens are placeholders in source — "
|
|
"the test only verifies the firmware boots and the WiFi stack "
|
|
"comes up; outbound HTTPS to the real service is not asserted."
|
|
)
|
|
if "ble" in features:
|
|
notes.append(
|
|
"BLE: only the Esp32Bridge ble_status event channel is exercised; "
|
|
"GATT operations are NOT asserted (limited QEMU support)."
|
|
)
|
|
if "ota" in features:
|
|
notes.append("OTA update path is NOT asserted (no real flash partition table).")
|
|
if host_deps:
|
|
notes.append(
|
|
"Host-side companion code present (" + ", ".join(host_deps) +
|
|
") — that half runs on the host, not in Velxio."
|
|
)
|
|
|
|
notes_block = "\n".join(f"# * {n}" for n in notes) or "# (none)"
|
|
|
|
title = info["name"]
|
|
safe_title = title.replace('"""', "'''")
|
|
test_class = "Test_" + safe_dirname(title)
|
|
|
|
body = f'''\
|
|
"""
|
|
Velxio emulation test for: {safe_title}
|
|
|
|
Board: {board}
|
|
Features: {", ".join(features) or "none detected"}
|
|
Languages: {", ".join(info["languages"]) or "—"}
|
|
|
|
Notes / known limitations:
|
|
{notes_block}
|
|
|
|
This test is intentionally lightweight: it does *not* mutate the user's
|
|
running backend. The "live" portion is gated on $VELXIO_BACKEND_URL and
|
|
auto-skips when the backend is not reachable, so the suite stays green
|
|
in CI on any developer machine.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
THIS_DIR = Path(__file__).resolve().parent
|
|
SOURCE_DIR = THIS_DIR / "source"
|
|
REPO_ROOT = THIS_DIR.parents[2]
|
|
|
|
sys.path.insert(0, str(REPO_ROOT / "test" / "test_100_days"))
|
|
sys.path.insert(0, str(REPO_ROOT / "backend"))
|
|
|
|
from _lib import ( # type: ignore # noqa: E402
|
|
BoardKind,
|
|
classify_micropython_source,
|
|
compile_python_sources,
|
|
detect_arduino_includes,
|
|
backend_websocket_url,
|
|
backend_reachable,
|
|
velxio_supports_board,
|
|
PROJECT_BOARD,
|
|
)
|
|
|
|
|
|
PROJECT_NAME = {title!r}
|
|
PROJECT_BOARD_KIND: BoardKind = {board!r}
|
|
EXPECTED_FEATURES = {features!r}
|
|
|
|
|
|
class {test_class}_StaticAnalysis(unittest.TestCase):
|
|
"""Source-only checks — these always run."""
|
|
|
|
def test_source_files_present(self):
|
|
files = [p for p in SOURCE_DIR.rglob("*") if p.is_file()]
|
|
self.assertTrue(files, "no source files copied into source/")
|
|
|
|
def test_python_sources_compile(self):
|
|
"""Every .py file is valid Python (MicroPython is a strict subset)."""
|
|
py_files = sorted(SOURCE_DIR.rglob("*.py"))
|
|
if not py_files:
|
|
self.skipTest("no .py files — Arduino-only project")
|
|
errs = compile_python_sources(py_files)
|
|
self.assertEqual(errs, [], f"syntax errors:\\n " + "\\n ".join(errs))
|
|
|
|
def test_arduino_sources_parse(self):
|
|
"""For Arduino sketches: required headers are referenceable."""
|
|
ino = sorted(SOURCE_DIR.rglob("*.ino")) + sorted(SOURCE_DIR.rglob("*.cpp"))
|
|
if not ino:
|
|
self.skipTest("no Arduino sketch — MicroPython project")
|
|
includes = detect_arduino_includes(ino)
|
|
# Smoke check — list of includes is non-empty for a real sketch
|
|
self.assertIsInstance(includes, list)
|
|
|
|
def test_board_is_supported_by_velxio(self):
|
|
ok, reason = velxio_supports_board(PROJECT_BOARD_KIND)
|
|
self.assertTrue(ok, f"board {{PROJECT_BOARD_KIND!r}} not supported: {{reason}}")
|
|
|
|
def test_imports_have_velxio_analogue(self):
|
|
"""For MicroPython: imports map to modules Velxio's MP firmware ships."""
|
|
py_files = sorted(SOURCE_DIR.rglob("*.py"))
|
|
if not py_files:
|
|
self.skipTest("no .py files")
|
|
info = classify_micropython_source(py_files)
|
|
# We only fail if the project imports a hard host-only module
|
|
# (tkinter, flask, matplotlib) that obviously cannot run on the MCU.
|
|
host_only_in_mcu = info["host_only_in_mcu_files"]
|
|
self.assertEqual(
|
|
host_only_in_mcu, [],
|
|
f"host-only imports found in MCU code: {{host_only_in_mcu}}",
|
|
)
|
|
|
|
|
|
@unittest.skipUnless(
|
|
backend_reachable(),
|
|
"Velxio backend not reachable on $VELXIO_BACKEND_URL "
|
|
"(start it with: cd backend && uvicorn app.main:app --port 8001)",
|
|
)
|
|
class {test_class}_LiveBackend(unittest.IsolatedAsyncioTestCase):
|
|
"""End-to-end: connect to the real backend WebSocket, start an instance,
|
|
feed the project's source files, and observe the boot transcript."""
|
|
|
|
async def test_backend_websocket_handshake(self):
|
|
import websockets # type: ignore
|
|
url = backend_websocket_url("test-100-days-{safe_dirname(title).lower()}")
|
|
async with websockets.connect(url, ping_interval=None) as ws:
|
|
# Just make sure the route accepts our connection and is willing
|
|
# to read JSON. We do NOT start a heavy QEMU instance here.
|
|
await ws.send(json.dumps({{"type": "ping", "data": {{}}}}))
|
|
try:
|
|
# Give the server up to 2 s to either echo, ignore, or close.
|
|
await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
except asyncio.TimeoutError:
|
|
pass # silent ignore is fine — route is alive
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|
|
'''
|
|
test_filename = f"test_{safe_dirname(title).lower()}.py"
|
|
(dest / test_filename).write_text(body, encoding="utf-8")
|
|
|
|
|
|
def emit_not_supported(dest: Path, info: dict) -> None:
|
|
"""Write NOT_SUPPORTED.md plus a tiny xfail test that asserts the marker."""
|
|
name = info["name"]
|
|
blockers = info["blockers"]
|
|
features = info["features"]
|
|
board = info["board"]
|
|
sources = info["sources"]
|
|
|
|
md_lines = [
|
|
f"# {name}",
|
|
"",
|
|
"## Status: NOT SUPPORTED by Velxio",
|
|
"",
|
|
"This project cannot be emulated end-to-end inside Velxio in its "
|
|
"current form. The source code has been copied into `source/` for "
|
|
"reference, but no live test is wired up.",
|
|
"",
|
|
"## Detected configuration",
|
|
"",
|
|
f"- **Board:** `{board or 'unknown'}`",
|
|
f"- **Features:** {', '.join(features) or '—'}",
|
|
f"- **Source files:** {len(sources)}",
|
|
"",
|
|
"## Blockers",
|
|
"",
|
|
]
|
|
for code, msg in blockers:
|
|
md_lines.append(f"- **`{code}`** — {msg}")
|
|
if not blockers:
|
|
md_lines.append("- (no programmatic blocker, but project layout had no usable source code)")
|
|
md_lines += [
|
|
"",
|
|
"## What would be needed to support this in Velxio",
|
|
"",
|
|
]
|
|
needs: list[str] = []
|
|
for code, _ in blockers:
|
|
if code == "esp8266":
|
|
needs.append(
|
|
"Add an ESP8266 backend: either a QEMU build that can run "
|
|
"Tensilica L106 cores, or a soft-CPU emulator like "
|
|
"`esp8266sim` invoked from `app.services.esp32_qemu_manager` "
|
|
"with a new `kind=\"esp8266\"` branch. Also requires "
|
|
"MicroPython firmware for ESP8266 in `public/firmware/`."
|
|
)
|
|
elif code == "esp_now":
|
|
needs.append(
|
|
"Implement ESP-NOW packet bridging between two ESP32 QEMU "
|
|
"instances at the WiFi MAC layer. This is more than slirp can "
|
|
"do; would need a virtual 802.11 hub similar to `mac80211_hwsim`."
|
|
)
|
|
elif code == "tkinter":
|
|
needs.append(
|
|
"Tkinter GUI is host-only. To emulate, port the GUI to the "
|
|
"browser (e.g. as a React panel that talks to the MCU over "
|
|
"the existing serial WebSocket) and keep only the MicroPython "
|
|
"half on the emulated board."
|
|
)
|
|
elif code == "no_code":
|
|
needs.append(
|
|
"Provide an actual main.py / sketch.ino in this project before "
|
|
"any emulation is meaningful."
|
|
)
|
|
for n in needs:
|
|
md_lines.append(f"- {n}")
|
|
md_lines.append("")
|
|
md_lines.append("## Files copied")
|
|
md_lines.append("")
|
|
for s in sources:
|
|
md_lines.append(f"- `source/{s.name}`")
|
|
md_lines.append("")
|
|
|
|
(dest / "NOT_SUPPORTED.md").write_text("\n".join(md_lines), encoding="utf-8")
|
|
|
|
# Mirror as a test file that records the unsupported status. Using
|
|
# unittest.skip keeps the run green but reports the reason in `-v` output.
|
|
reasons = "; ".join(f"{c}: {m}" for c, m in blockers) or "no source code"
|
|
test_class = "Test_" + safe_dirname(name)
|
|
had_code_literal = repr(bool(sources))
|
|
body = f'''\
|
|
"""
|
|
Velxio cannot emulate this project — see NOT_SUPPORTED.md for full details.
|
|
|
|
Project: {name}
|
|
Board: {board}
|
|
"""
|
|
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
THIS_DIR = Path(__file__).resolve().parent
|
|
|
|
|
|
class {test_class}_NotSupported(unittest.TestCase):
|
|
|
|
def test_not_supported_marker_present(self):
|
|
marker = THIS_DIR / "NOT_SUPPORTED.md"
|
|
self.assertTrue(marker.is_file(), "NOT_SUPPORTED.md is missing")
|
|
|
|
def test_source_was_preserved(self):
|
|
src_dir = THIS_DIR / "source"
|
|
# Some upstream projects ship only README/LICENSE — there is literally
|
|
# nothing to copy. In that case NOT_SUPPORTED.md is the whole record.
|
|
upstream_had_code = {had_code_literal}
|
|
if not upstream_had_code:
|
|
self.skipTest("upstream project has no source code")
|
|
src = [s for s in src_dir.rglob("*") if s.is_file()]
|
|
self.assertTrue(src, "no source files copied — generator likely broken")
|
|
|
|
@unittest.skip("Project not supported by Velxio: {reasons}")
|
|
def test_velxio_emulation(self):
|
|
self.fail("unreachable — see NOT_SUPPORTED.md")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|
|
'''
|
|
test_filename = f"test_{safe_dirname(name).lower()}.py"
|
|
(dest / test_filename).write_text(body, encoding="utf-8")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Top-level driver
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def main() -> int:
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
summary: list[dict] = []
|
|
|
|
for project in list_projects():
|
|
sources = find_code_files(project)
|
|
info = classify_project(project, sources)
|
|
safe = safe_dirname(project.name)
|
|
dest = OUT_DIR / safe
|
|
|
|
# Wipe per-project folder content (but keep README in OUT_DIR)
|
|
if dest.exists():
|
|
shutil.rmtree(dest)
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
copy_sources(info["sources"], dest)
|
|
|
|
if info["blockers"]:
|
|
emit_not_supported(dest, info)
|
|
status = "NOT_SUPPORTED"
|
|
else:
|
|
emit_supported_test(dest, info)
|
|
status = "SUPPORTED"
|
|
|
|
summary.append({
|
|
"name": project.name,
|
|
"safe": safe,
|
|
"board": info["board"],
|
|
"features": info["features"],
|
|
"languages": info["languages"],
|
|
"status": status,
|
|
"blockers": [b[0] for b in info["blockers"]],
|
|
"host_deps": info["host_dependencies"],
|
|
})
|
|
|
|
write_readme(summary)
|
|
return 0
|
|
|
|
|
|
def write_readme(summary: list[dict]) -> None:
|
|
n_total = len(summary)
|
|
n_ok = sum(1 for s in summary if s["status"] == "SUPPORTED")
|
|
n_no = n_total - n_ok
|
|
|
|
lines = [
|
|
"# test_100_days",
|
|
"",
|
|
f"Generated tests for the **{n_total} projects** in "
|
|
"`third-party/100_Days_100_IoT_Projects/`, mapped against Velxio's "
|
|
"current emulation capabilities.",
|
|
"",
|
|
f"- ✅ Velxio can run: **{n_ok}**",
|
|
f"- ❌ Cannot run as-is: **{n_no}**",
|
|
"",
|
|
"Each per-project sub-folder contains:",
|
|
"- `source/` — verbatim copy of the project's user code",
|
|
"- `test_project.py` — the test (static analysis + optional live "
|
|
"backend handshake)",
|
|
"- `NOT_SUPPORTED.md` (only when the project cannot be emulated) — "
|
|
"explains exactly which Velxio capability is missing and what "
|
|
"would be needed to add it",
|
|
"",
|
|
"## How to run",
|
|
"",
|
|
"```bash",
|
|
"# Static analysis only (no backend required) — fast, works offline:",
|
|
"python -m pytest test/test_100_days -v",
|
|
"",
|
|
"# Include live backend smoke checks:",
|
|
"cd backend && uvicorn app.main:app --port 8001 # in another terminal",
|
|
"VELXIO_BACKEND_URL=http://localhost:8001 \\\\",
|
|
" python -m pytest test/test_100_days -v",
|
|
"```",
|
|
"",
|
|
"Re-generate after pulling new projects:",
|
|
"",
|
|
"```bash",
|
|
"python test/test_100_days/_generate.py",
|
|
"```",
|
|
"",
|
|
"## Capability matrix used for classification",
|
|
"",
|
|
"| Capability | Velxio support |",
|
|
"|---|---|",
|
|
"| Arduino UNO / Nano / Mega (AVR) | ✅ avr8js |",
|
|
"| Raspberry Pi Pico / Pico W | ✅ rp2040js |",
|
|
"| ESP32 / ESP32-S3 / ESP32-C3 | ✅ Espressif QEMU |",
|
|
"| ATtiny85 | ✅ avr8js |",
|
|
"| **ESP8266** | ❌ no QEMU/firmware |",
|
|
"| MicroPython on Pico | ✅ MicroPythonLoader |",
|
|
"| MicroPython on ESP32 family | ✅ Esp32MicroPythonLoader |",
|
|
"| WiFi (TCP/UDP through host) | ✅ slirp NIC |",
|
|
"| **ESP-NOW (peer-to-peer 802.11)** | ❌ no virtual 802.11 hub |",
|
|
"| **BLE GATT** | ⚠️ status events only |",
|
|
"| Tkinter / Flask / Matplotlib (host-side) | ❌ host-only |",
|
|
"",
|
|
"## Project index",
|
|
"",
|
|
"| # | Project | Status | Board | Features |",
|
|
"|---:|---|---|---|---|",
|
|
]
|
|
for i, s in enumerate(sorted(summary, key=lambda x: x["name"]), 1):
|
|
status = "✅" if s["status"] == "SUPPORTED" else "❌"
|
|
feats = ", ".join(s["features"]) or "—"
|
|
if len(feats) > 80:
|
|
feats = feats[:77] + "…"
|
|
rel = f"[{s['name']}](./{s['safe']}/)"
|
|
lines.append(f"| {i} | {rel} | {status} | `{s['board'] or '?'}` | {feats} |")
|
|
|
|
lines.append("")
|
|
lines.append("---")
|
|
lines.append("")
|
|
lines.append("Generated by `_generate.py` — do not edit by hand.")
|
|
lines.append("")
|
|
|
|
(OUT_DIR / "README.md").write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|