295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""
|
|
Reads every project in test/test_100_days/ that has a test (i.e. is
|
|
SUPPORTED — no NOT_SUPPORTED.md present) and emits a TypeScript module
|
|
``frontend/src/data/examples-100-days.ts`` containing one
|
|
``ExampleProject`` entry per project.
|
|
|
|
Each emitted entry uses the new MicroPython-aware fields:
|
|
- languageMode: 'micropython'
|
|
- files: [ { name, content }, ... ] ← every .py file in source/
|
|
- code: '' ← legacy field, unused for MP
|
|
|
|
Categories and difficulty are derived heuristically from the project name
|
|
and detected features (the same data we computed in _generate.py).
|
|
|
|
Run from repo root:
|
|
python test/test_100_days/_emit_examples_data.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
TESTS_ROOT = REPO_ROOT / "test" / "test_100_days"
|
|
OUT_FILE = REPO_ROOT / "frontend" / "src" / "data" / "examples-100-days.ts"
|
|
ORIG_PROJECTS = REPO_ROOT / "third-party" / "100_Days_100_IoT_Projects"
|
|
|
|
# ── Board mapping (test_100_days kind → ExampleProject.boardType) ──────────
|
|
BOARD_TO_TYPE = {
|
|
"esp32": "esp32",
|
|
"esp32-s3": "esp32", # Velxio editor still labels these as esp32 in boardType
|
|
"esp32-c3": "esp32-c3",
|
|
"xiao-esp32": "esp32",
|
|
"rp2040": "raspberry-pi-pico",
|
|
"pico-w": "raspberry-pi-pico",
|
|
}
|
|
|
|
# ── Heuristic category / difficulty ───────────────────────────────────────
|
|
def derive_category(name: str, features: set[str]) -> str:
|
|
n = name.lower()
|
|
if {"i2c_oled", "max7219", "tm1637", "lcd"} & features:
|
|
return "displays"
|
|
if {"servo", "stepper"} & features or "robot" in n or "motor" in n:
|
|
return "robotics"
|
|
if {"wifi", "blynk", "telegram", "thingspeak", "mqtt", "websocket",
|
|
"blynk_cloud", "ble", "http_server", "esp_now"} & features:
|
|
return "communication"
|
|
if {"dht", "rfid", "ldr", "ultrasonic", "rtc"} & features or "sensor" in n:
|
|
return "sensors"
|
|
return "basics"
|
|
|
|
|
|
def derive_difficulty(features: set[str]) -> str:
|
|
if {"esp_now", "ota", "websocket", "ble", "blynk_cloud"} & features:
|
|
return "advanced"
|
|
if {"wifi", "thingspeak", "telegram", "mqtt", "rfid", "max7219",
|
|
"i2c_oled", "lcd", "stepper"} & features:
|
|
return "intermediate"
|
|
return "beginner"
|
|
|
|
|
|
# Same regexes as _generate.py — kept in sync intentionally.
|
|
FEATURE_PATTERNS: dict[str, str] = {
|
|
"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",
|
|
"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",
|
|
}
|
|
|
|
BOARD_PATTERNS = [
|
|
(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"),
|
|
]
|
|
|
|
def detect_board(name: str, src_blob: str) -> str | None:
|
|
for pat, b in BOARD_PATTERNS:
|
|
if re.search(pat, name, re.I) or re.search(pat, src_blob, re.I):
|
|
return b
|
|
if "machine" in src_blob: # fallback for bare MicroPython
|
|
return "esp32"
|
|
return None
|
|
|
|
|
|
def detect_features(src_blob: str) -> set[str]:
|
|
out: set[str] = set()
|
|
for feat, pat in FEATURE_PATTERNS.items():
|
|
if re.search(pat, src_blob, re.I):
|
|
out.add(feat)
|
|
return out
|
|
|
|
|
|
def humanize(name: str) -> str:
|
|
"""Turn 'IoT_Smart_Irrigation_System' → 'IoT Smart Irrigation System'."""
|
|
s = name.replace("_", " ")
|
|
s = re.sub(r"\s+", " ", s).strip()
|
|
return s
|
|
|
|
|
|
def short_description(name: str, board: str, features: set[str]) -> str:
|
|
hum = humanize(name)
|
|
feat_list = sorted(features)
|
|
cleaned_feats = []
|
|
LABEL = {
|
|
"wifi": "Wi-Fi", "blynk": "Blynk", "blynk_cloud": "Blynk Cloud",
|
|
"telegram": "Telegram", "thingspeak": "ThingSpeak", "mqtt": "MQTT",
|
|
"esp_now": "ESP-NOW", "ble": "BLE", "i2c_oled": "OLED",
|
|
"dht": "DHT", "rfid": "RFID", "max7219": "MAX7219",
|
|
"tm1637": "TM1637", "servo": "Servo", "stepper": "Stepper",
|
|
"ldr": "LDR", "ultrasonic": "HC-SR04", "lcd": "I²C LCD",
|
|
"rtc": "RTC", "ota": "OTA", "websocket": "WebSocket",
|
|
"http_server": "HTTP server",
|
|
}
|
|
for f in feat_list:
|
|
if f in LABEL:
|
|
cleaned_feats.append(LABEL[f])
|
|
feat_blurb = (" — uses " + ", ".join(cleaned_feats)) if cleaned_feats else ""
|
|
board_label = {
|
|
"esp32": "ESP32", "esp32-c3": "ESP32-C3",
|
|
"raspberry-pi-pico": "Raspberry Pi Pico W",
|
|
}.get(BOARD_TO_TYPE.get(board, ""), "")
|
|
if board_label:
|
|
return f"{hum} on {board_label} (MicroPython){feat_blurb}."
|
|
return f"{hum} (MicroPython){feat_blurb}."
|
|
|
|
|
|
# ── TypeScript escaping ────────────────────────────────────────────────────
|
|
|
|
def ts_string_literal(s: str) -> str:
|
|
"""Emit a TypeScript template-string-safe literal — backtick-delimited."""
|
|
# Escape backticks, backslashes, ${
|
|
s = s.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")
|
|
return "`" + s + "`"
|
|
|
|
|
|
def js_id(s: str) -> str:
|
|
"""Stable, URL-friendly ID."""
|
|
s = s.lower()
|
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
|
s = re.sub(r"-+", "-", s).strip("-")
|
|
return f"100d-{s}" if s else "100d-untitled"
|
|
|
|
|
|
# ── Driver ────────────────────────────────────────────────────────────────
|
|
|
|
def collect_supported() -> list[dict]:
|
|
out = []
|
|
for entry in sorted(TESTS_ROOT.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
if (entry / "NOT_SUPPORTED.md").is_file():
|
|
continue
|
|
# Match against the upstream project name to get the original (with parens etc.)
|
|
# by looking for the corresponding test file's docstring "Velxio emulation
|
|
# test for: <name>"
|
|
test_files = list(entry.glob("test_*.py"))
|
|
if not test_files:
|
|
continue
|
|
upstream_name = entry.name # safe name fallback
|
|
try:
|
|
head = test_files[0].read_text(encoding="utf-8")
|
|
m = re.search(r"Velxio emulation test for: (.+)", head)
|
|
if m:
|
|
upstream_name = m.group(1).strip()
|
|
except OSError:
|
|
pass
|
|
|
|
src_dir = entry / "source"
|
|
if not src_dir.is_dir():
|
|
continue
|
|
py_files = sorted(src_dir.glob("*.py"))
|
|
if not py_files:
|
|
continue
|
|
|
|
files = []
|
|
full_blob = ""
|
|
for f in py_files:
|
|
try:
|
|
content = f.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
content = f.read_bytes().decode("utf-8", errors="replace")
|
|
files.append({"name": f.name, "content": content})
|
|
full_blob += "\n" + content
|
|
|
|
board = detect_board(upstream_name, full_blob) or "esp32"
|
|
# Skip projects whose board can't be expressed in ExampleProject.boardType
|
|
if board not in BOARD_TO_TYPE:
|
|
continue
|
|
features = detect_features(full_blob)
|
|
|
|
out.append({
|
|
"safe": entry.name,
|
|
"name": upstream_name,
|
|
"board_kind": board,
|
|
"board_type": BOARD_TO_TYPE[board],
|
|
"files": files,
|
|
"features": sorted(features),
|
|
"category": derive_category(upstream_name, features),
|
|
"difficulty": derive_difficulty(features),
|
|
"description": short_description(upstream_name, board, features),
|
|
})
|
|
return out
|
|
|
|
|
|
def emit_ts(entries: list[dict]) -> str:
|
|
head = '''\
|
|
/**
|
|
* 100 Days of IoT — example projects from
|
|
* https://github.com/KritishMohapatra/100_Days_100_IoT_Projects
|
|
*
|
|
* AUTO-GENERATED by `test/test_100_days/_emit_examples_data.py`.
|
|
* Do not edit by hand. Re-run the generator after pulling new upstream
|
|
* projects:
|
|
*
|
|
* python test/test_100_days/_emit_examples_data.py
|
|
*
|
|
* Only the projects Velxio can run end-to-end are emitted (49/78 at the
|
|
* time of generation — see test/test_100_days/README.md for the full
|
|
* compatibility matrix). Each example is a MicroPython project; the
|
|
* editor switches into MicroPython mode automatically when one of these
|
|
* is loaded (see utils/loadExample.ts).
|
|
*/
|
|
|
|
import type { ExampleProject } from './examples';
|
|
|
|
export const hundredDaysExamples: ExampleProject[] = [
|
|
'''
|
|
body_parts = []
|
|
for e in entries:
|
|
files_lit = "[\n" + ",\n".join(
|
|
" { name: " + json.dumps(f["name"]) +
|
|
", content: " + ts_string_literal(f["content"]) + " }"
|
|
for f in e["files"]
|
|
) + ",\n ]"
|
|
|
|
# Tags — what we feed the gallery search box.
|
|
tags = sorted(set(e["features"] +
|
|
[e["board_kind"], e["board_type"], "micropython", "100-days"]))
|
|
tags_lit = json.dumps(tags)
|
|
|
|
body_parts.append(
|
|
" {\n"
|
|
f" id: {json.dumps(js_id(e['safe']))},\n"
|
|
f" title: {json.dumps(humanize(e['name']))},\n"
|
|
f" description: {json.dumps(e['description'])},\n"
|
|
f" category: {json.dumps(e['category'])},\n"
|
|
f" difficulty: {json.dumps(e['difficulty'])},\n"
|
|
f" boardType: {json.dumps(e['board_type'])},\n"
|
|
f" languageMode: 'micropython',\n"
|
|
f" files: {files_lit},\n"
|
|
" code: '',\n"
|
|
" components: [],\n"
|
|
" wires: [],\n"
|
|
f" tags: {tags_lit},\n"
|
|
" },"
|
|
)
|
|
return head + "\n".join(body_parts) + "\n];\n"
|
|
|
|
|
|
def main() -> None:
|
|
entries = collect_supported()
|
|
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
OUT_FILE.write_text(emit_ts(entries), encoding="utf-8")
|
|
print(f"wrote {len(entries)} examples -> {OUT_FILE.relative_to(REPO_ROOT)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|