From 10548b1f513ffe4c28482cfa4f8979691250ec5b Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 11 Apr 2026 08:39:57 +0700 Subject: [PATCH] feat: add Arduino lesson support, documentation, and load testing utilities with improved CLI feedback --- .gitignore | 3 + README.md | 122 ++- elemes.sh | 79 +- examples/content/button_input_arduino.md | 206 ++++ examples/content/hello_serial_arduino.md | 138 +++ examples/content/home.md | 6 + examples/content/traffic_light_arduino.md | 231 +++++ load-test/README.md | 57 ++ load-test/content_parser.py | 306 ++++++ load-test/locustfile.py | 482 +++++++++ load-test/requirements.txt | 1 + podman-compose.yml | 3 +- proposal.md | 1131 ++------------------- 13 files changed, 1663 insertions(+), 1102 deletions(-) create mode 100644 examples/content/button_input_arduino.md create mode 100644 examples/content/hello_serial_arduino.md create mode 100644 examples/content/traffic_light_arduino.md create mode 100644 load-test/README.md create mode 100644 load-test/content_parser.py create mode 100644 load-test/locustfile.py create mode 100644 load-test/requirements.txt diff --git a/.gitignore b/.gitignore index bee8a64..d5eecd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__ +test_data.json +load-test/env +load-test/test_data.json \ No newline at end of file diff --git a/README.md b/README.md index bddc686..cf9cb1a 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,88 @@ Untuk materi yang melibatkan simulator rangkaian elektronika: | `---EXPECTED_CIRCUIT_OUTPUT---` | Validasi rangkaian (format JSON) | | `---KEY_TEXT_CIRCUIT---` | Kata kunci rangkaian | +#### Blok Khusus Arduino/Velxio + +Untuk materi yang menggunakan simulator Arduino (Velxio): + +| Blok | Fungsi | +|------|--------| +| `---INITIAL_CODE_ARDUINO---` | Kode Arduino awal di editor Velxio | +| `---VELXIO_CIRCUIT---` | Rangkaian komponen (JSON: board, components, wires) | +| `---EXPECTED_SERIAL_OUTPUT---` | Output serial yang diharapkan (subsequence match) | +| `---EXPECTED_WIRING---` | Wiring yang harus dibuat siswa (JSON, lenient) | +| `---KEY_TEXT---` | Kata kunci yang harus ada di kode siswa | + +Contoh materi Arduino: + +```markdown +---INITIAL_CODE_ARDUINO--- +void setup() { + pinMode(13, OUTPUT); + Serial.begin(9600); +} + +void loop() { + digitalWrite(13, HIGH); + Serial.println("LED ON"); + delay(1000); + digitalWrite(13, LOW); + Serial.println("LED OFF"); + delay(1000); +} +---END_INITIAL_CODE_ARDUINO--- + +---VELXIO_CIRCUIT--- +{ + "board": "arduino:avr:uno", + "components": [ + { "type": "wokwi-led", "id": "led-1", "x": 400, "y": -200, "props": { "color": "red", "pin": 13 } } + ], + "wires": [] +} +---END_VELXIO_CIRCUIT--- + +---EXPECTED_SERIAL_OUTPUT--- +LED ON +LED OFF +---END_EXPECTED_SERIAL_OUTPUT--- + +---EXPECTED_WIRING--- +{ + "wires": [ + { "start": { "componentId": "arduino-uno", "pinName": "13" }, "end": { "componentId": "led-1", "pinName": "A" } }, + { "start": { "componentId": "led-1", "pinName": "C" }, "end": { "componentId": "arduino-uno", "pinName": "GND" } } + ] +} +---END_EXPECTED_WIRING--- + +---KEY_TEXT--- +pinMode +digitalWrite +---END_KEY_TEXT--- +``` + +##### Referensi Nama Pin Komponen Velxio + +| Komponen | Tipe Wokwi | Pin Names | +|----------|------------|----------| +| Arduino Uno | `wokwi-arduino-uno` | `0`-`13`, `A0`-`A5`, `GND`, `5V`, `3.3V` | +| LED | `wokwi-led` | `A` (Anode), `C` (Cathode) | +| Push Button | `wokwi-pushbutton` | `1.l`, `2.l`, `1.r`, `2.r` | +| Resistor | `wokwi-resistor` | `1`, `2` | +| RGB LED | `wokwi-rgb-led` | `R`, `G`, `B`, `COM` | + +> **Penting:** Nama pin harus **persis** seperti tabel di atas. +> `componentId` untuk Arduino Uno selalu `arduino-uno` (huruf kecil, dengan strip). + +##### Evaluasi Arduino + +Sistem mengevaluasi 3 aspek (semua harus lulus): + +1. **Key Text** — kata kunci wajib ada di source code siswa +2. **Serial Output** — baris yang diharapkan harus muncul dalam urutan (subsequence match) +3. **Wiring** — koneksi yang diharapkan harus ada (lenient: extra wires OK, GND.1/GND.2 dinormalisasi) + ### 5. Generate Token Siswa Setelah materi siap, generate file `tokens_siswa.csv`: @@ -244,15 +326,25 @@ Folder `examples/` berisi contoh lengkap yang digunakan oleh `./elemes.sh init`: ``` examples/ ├── content/ -│ ├── home.md # Halaman utama (3 materi) -│ ├── hello_world.md # Materi dasar: Hello World -│ ├── variabel.md # Materi dasar: Variabel -│ └── rangkaian_dasar.md # Materi hybrid: C + Circuit -└── tokens_siswa.csv # Data siswa contoh (1 guru + 3 siswa) +│ ├── home.md # Halaman utama (7 materi) +│ ├── hello_world.md # Materi dasar: Hello World +│ ├── variabel.md # Materi dasar: Variabel +│ ├── rangkaian_dasar.md # Materi hybrid: C + Circuit +│ ├── led_blink_arduino.md # Arduino: LED Blink + wiring +│ ├── hello_serial_arduino.md # Arduino: Serial Monitor (tanpa wiring) +│ ├── button_input_arduino.md # Arduino: Button + LED input/output +│ └── traffic_light_arduino.md # Arduino: Lampu lalu lintas 3 LED +└── tokens_siswa.csv # Data siswa contoh (1 guru + 3 siswa) ``` -File `rangkaian_dasar.md` adalah contoh **materi hybrid** yang menggabungkan -latihan pemrograman C dan simulator rangkaian elektronika dalam satu lesson. +### Jenis Materi + +| Tipe | Contoh | Evaluasi | +|------|--------|----------| +| **C/Python** | `hello_world.md`, `variabel.md` | Output stdout | +| **Hybrid** | `rangkaian_dasar.md` | C output + node voltage | +| **Arduino (Velxio)** | `led_blink_arduino.md` | Key text + serial + wiring | +| **Arduino (tanpa wiring)** | `hello_serial_arduino.md` | Key text + serial | ## FAQ @@ -276,6 +368,22 @@ akan otomatis muncul. Tidak. Tailscale opsional untuk akses remote. Tanpa Tailscale, LMS bisa diakses di jaringan lokal via `http://localhost:3000`. +**Q: Bagaimana membuat materi Arduino baru?** +Buat file `.md` baru di `content/` dengan blok `---INITIAL_CODE_ARDUINO---` +dan `---VELXIO_CIRCUIT---`. Lihat contoh di `led_blink_arduino.md` +atau `button_input_arduino.md`. Pastikan `componentId` dan `pinName` +di `EXPECTED_WIRING` sesuai dengan tabel referensi pin di atas. + +**Q: Materi Arduino bisa tanpa wiring?** +Ya. Jika hanya ingin evaluasi kode + serial output, cukup sertakan +`VELXIO_CIRCUIT` dengan `components: []` kosong dan hilangkan blok +`EXPECTED_WIRING`. Contoh: `hello_serial_arduino.md`. + +**Q: Apakah wiring harus persis sama?** +Tidak harus. Evaluasi bersifat *lenient*: koneksi yang diharapkan harus ada, +tapi siswa boleh menambahkan kabel ekstra. Pin GND juga dinormalisasi +(GND.1 = GND.2 = GND). + ## Persyaratan Sistem - [Podman](https://podman.io/) dan `podman-compose` diff --git a/elemes.sh b/elemes.sh index 59cd9a6..b2a1e42 100755 --- a/elemes.sh +++ b/elemes.sh @@ -7,24 +7,24 @@ PROJECT_NAME="$(basename "$PARENT_DIR")" case "$1" in init) - echo "=== Elemes Quick Start ===" + echo "✨ === Elemes Quick Start === ✨" echo "" # .env if [ -f "$PARENT_DIR/.env" ]; then - echo "[skip] .env sudah ada" + echo "✅ [Skip] .env sudah ada" else cp "$EXAMPLES_DIR/../.env.example" "$PARENT_DIR/.env" - echo "[buat] .env (edit sesuai kebutuhan)" + echo "📝 [Buat] .env (pastikan untuk edit sesuai kebutuhanmu)" fi # content/ if [ -d "$PARENT_DIR/content" ] && [ "$(ls -A "$PARENT_DIR/content" 2>/dev/null)" ]; then - echo "[skip] content/ sudah ada" + echo "✅ [Skip] Folder content/ sudah ada" else mkdir -p "$PARENT_DIR/content" cp -n "$EXAMPLES_DIR/content/"*.md "$PARENT_DIR/content/" - echo "[buat] content/ ($(ls "$PARENT_DIR/content/"*.md 2>/dev/null | wc -l) materi)" + echo "📁 [Buat] Folder content/ ($(ls "$PARENT_DIR/content/"*.md 2>/dev/null | wc -l) materi contoh ditambahkan)" fi # assets/ @@ -32,47 +32,84 @@ init) # tokens if [ -f "$PARENT_DIR/tokens_siswa.csv" ]; then - echo "[skip] tokens_siswa.csv sudah ada" + echo "✅ [Skip] tokens_siswa.csv sudah ada" else cp "$EXAMPLES_DIR/tokens_siswa.csv" "$PARENT_DIR/tokens_siswa.csv" - echo "[buat] tokens_siswa.csv (edit untuk tambah siswa)" + echo "👥 [Buat] tokens_siswa.csv (edit untuk menambah data siswa asli)" fi echo "" - echo "Selesai! Langkah selanjutnya:" - echo " 1. Edit ../.env sesuai kebutuhan" - echo " 2. Edit ../content/home.md untuk daftar materi" - echo " 3. Edit ../tokens_siswa.csv untuk data siswa" - echo " 4. Jalankan: ./elemes.sh runbuild" + echo "🎯 Selesai! Langkah selanjutnya yang direkomendasikan:" + echo " 👉 1. Edit file ../.env sesuai dengan kebutuhan environment-mu" + echo " 👉 2. Edit ../content/home.md untuk menyusun daftar materi" + echo " 👉 3. Edit ../tokens_siswa.csv untuk mendaftarkan akun siswa" + echo " 🚀 4. Jalankan: ./elemes.sh runbuild" + echo "" ;; stop | run | runbuild | runclearbuild) - echo "Stop Container..." + echo "🛑 Menghentikan container yang sedang berjalan..." podman-compose -p "$PROJECT_NAME" --env-file ../.env down ;;& -stop) ;; +stop) + echo "✅ Container berhasil dihentikan." + ;; runclearbuild) - echo "Cleanup dangling images..." + echo "🧹 Membersihkan container dan image (prune)..." podman image prune -f - echo "Build Container (no cache)..." + echo "🏗️ Membangun ulang container dari awal (no-cache)..." podman-compose -p "$PROJECT_NAME" --env-file ../.env build --no-cache ;;& runbuild) - echo "Build Container..." + echo "🏗️ Membangun container..." podman-compose -p "$PROJECT_NAME" --env-file ../.env build ;;& runbuild | runclearbuild) - echo "Run Container..." + echo "🚀 Menjalankan container di background..." podman-compose -p "$PROJECT_NAME" --env-file ../.env up --force-recreate -d + echo "✅ Elemes berhasil dijalankan!" ;; run) - echo "Run Container..." + echo "🚀 Menjalankan container..." podman-compose -p "$PROJECT_NAME" --env-file ../.env up -d + echo "✅ Elemes berhasil dijalankan!" ;; generatetoken) - echo "Generating tokens_siswa.csv from content..." + echo "🔄 Melakukan sinkronisasi tokens_siswa.csv dari materi yang ada..." python3 "$SCRIPT_DIR/generate_tokens.py" ;; +loadtest) + echo "📊 === Elemes Load Testing ===" + cd "$SCRIPT_DIR/load-test" || exit + + if [ ! -d "env" ]; then + echo "⚙️ Membuat Python Virtual Environment (env/)..." + python3 -m venv env + fi + + echo "⚙️ Mengaktifkan environment & menginstall requirements..." + source env/bin/activate + pip install -r requirements.txt > /dev/null 2>&1 + + echo "⚙️ Mempersiapkan Test Data & menginjeksi akun Bot..." + python3 content_parser.py --num-tokens 50 + + echo "" + echo "🚀 Memulai Locust Test Suite..." + echo "👉 Buka http://localhost:8089 di browser web milikmu." + echo "👉 Masukkan URL backend Elemes sebagai Host (contoh: http://localhost:5000)" + echo "👉 Tekan CTRL+C di terminal ini untuk menghentikan test." + echo "" + + locust -f locustfile.py + ;; *) - echo "elemes.sh ( init | run | runbuild | runclearbuild | stop | generatetoken )" + echo "💡 Cara Penggunaan elemes.sh:" + echo " ./elemes.sh init # Inisialisasi konfigurasi, folder, & data tokens" + echo " ./elemes.sh run # Menjalankan container LMS yang sudah ada" + echo " ./elemes.sh runbuild # Build image lalu jalankan container" + echo " ./elemes.sh runclearbuild # Bersihkan cache, Re-build total, lalu jalankan" + echo " ./elemes.sh stop # Menghentikan container yang sedang berjalan" + echo " ./elemes.sh generatetoken # Sinkronisasi kolom tokens CSV sesuai file markdown" + echo " ./elemes.sh loadtest # Menjalankan utilitas simulasi Load Test (Locust)" ;; esac diff --git a/examples/content/button_input_arduino.md b/examples/content/button_input_arduino.md new file mode 100644 index 0000000..b5392c4 --- /dev/null +++ b/examples/content/button_input_arduino.md @@ -0,0 +1,206 @@ +---LESSON_INFO--- +Pelajaran Arduino: Membaca input tombol dan mengontrol LED. + +**Learning Objectives:** +- Memahami fungsi `digitalRead()` untuk membaca input digital +- Menggunakan `INPUT_PULLUP` untuk push button +- Menerapkan logika kondisional (`if-else`) berdasarkan input +- Menghubungkan push button dan LED ke Arduino + +**Prerequisites:** +- Hello, World! +- LED Blink +- Hello Serial +---END_LESSON_INFO--- + +# Button Input — Membaca Tombol + +Setelah belajar **output** (menyalakan LED) dan **serial** (mengirim teks), +sekarang kita belajar **input** — membaca tombol yang ditekan oleh pengguna. + +## Konsep Digital Input + +Setiap pin digital Arduino bisa dikonfigurasi sebagai **INPUT** atau **OUTPUT**: + +``` +pinMode(13, OUTPUT); // Pin 13 = output (LED) +pinMode(2, INPUT_PULLUP); // Pin 2 = input dengan pullup +``` + +### Apa itu INPUT_PULLUP? + +Tanpa resistor, pin input Arduino bisa membaca nilai **acak** (mengambang). +`INPUT_PULLUP` mengaktifkan resistor internal Arduino yang menarik pin ke **HIGH** (5V). + +``` +Tanpa pullup: Pin ──?── (nilai acak: HIGH atau LOW) +Dengan pullup: Pin ──R──► 5V (default HIGH, jadi LOW saat ditekan) +``` + +Saat tombol **tidak ditekan**: `digitalRead()` = `HIGH` (1) +Saat tombol **ditekan**: `digitalRead()` = `LOW` (0) + +> **Perhatikan:** Logikanya terbalik! Tombol ditekan = LOW, tidak ditekan = HIGH. + +## Fungsi digitalRead() + +``` +int nilai = digitalRead(2); // Baca pin 2 +if (nilai == LOW) { + // Tombol sedang ditekan +} +``` + +## Rangkaian + +Hubungkan push button dan LED ke Arduino: + +``` +Pin 2 ──► Button pin 1.l (input) +Button pin 2.l ──► GND (ground) + +Pin 13 ──► LED Anode (A) +LED Cathode (C) ──► GND +``` + +Push button menggunakan `INPUT_PULLUP`, jadi hanya perlu 2 kabel: dari pin Arduino ke tombol, dan dari tombol ke GND. Tidak perlu resistor eksternal! + +## Contoh Program + +``` +const int BUTTON_PIN = 2; +const int LED_PIN = 13; + +void setup() { + pinMode(BUTTON_PIN, INPUT_PULLUP); + pinMode(LED_PIN, OUTPUT); + Serial.begin(9600); +} + +void loop() { + int state = digitalRead(BUTTON_PIN); + + if (state == LOW) { + digitalWrite(LED_PIN, HIGH); + Serial.println("PRESSED"); + } else { + digitalWrite(LED_PIN, LOW); + Serial.println("RELEASED"); + } + + delay(100); +} +``` + +---EXERCISE--- +### Tantangan + +**Kode Arduino:** +Tulis program yang membaca tombol di **pin 2** dan mengontrol LED di **pin 13**: +- Saat tombol ditekan → LED menyala, cetak `PRESSED` ke Serial Monitor +- Saat tombol dilepas → LED mati, cetak `RELEASED` ke Serial Monitor + +Gunakan `INPUT_PULLUP` agar tidak perlu resistor tambahan untuk tombol. + +**Rangkaian:** +Hubungkan komponen berikut di simulator: +- Pin 2 Arduino → Button (pin 1.l) +- Button (pin 2.l) → GND Arduino +- Pin 13 Arduino → LED Anode (A) +- LED Cathode (C) → GND Arduino + +Setelah selesai, tekan **Compile & Run**, lalu **klik tombol** di simulator untuk menguji. +--- + +---INITIAL_CODE_ARDUINO--- +// Button Input - Membaca Tombol +// Tekan tombol untuk menyalakan LED + +const int BUTTON_PIN = 2; +const int LED_PIN = 13; + +void setup() { + // Setup pin mode + pinMode(BUTTON_PIN, INPUT_PULLUP); + pinMode(LED_PIN, OUTPUT); + Serial.begin(9600); +} + +void loop() { + int buttonState = digitalRead(BUTTON_PIN); + + if (buttonState == LOW) { + // Tombol ditekan + digitalWrite(LED_PIN, HIGH); + Serial.println("PRESSED"); + } else { + // Tombol dilepas + digitalWrite(LED_PIN, LOW); + Serial.println("RELEASED"); + } + + delay(100); +} +---END_INITIAL_CODE_ARDUINO--- + +---VELXIO_CIRCUIT--- +{ + "board": "arduino:avr:uno", + "components": [ + { + "type": "wokwi-pushbutton", + "id": "button-1", + "x": 400, + "y": -100, + "rotation": 0, + "props": {} + }, + { + "type": "wokwi-led", + "id": "led-1", + "x": 400, + "y": -250, + "rotation": 0, + "props": { + "color": "green", + "pin": 13 + } + } + ], + "wires": [] +} +---END_VELXIO_CIRCUIT--- + +---EXPECTED_SERIAL_OUTPUT--- +PRESSED +---END_EXPECTED_SERIAL_OUTPUT--- + +---EXPECTED_WIRING--- +{ + "wires": [ + { + "start": { "componentId": "arduino-uno", "pinName": "2" }, + "end": { "componentId": "button-1", "pinName": "1.l" } + }, + { + "start": { "componentId": "button-1", "pinName": "2.l" }, + "end": { "componentId": "arduino-uno", "pinName": "GND" } + }, + { + "start": { "componentId": "arduino-uno", "pinName": "13" }, + "end": { "componentId": "led-1", "pinName": "A" } + }, + { + "start": { "componentId": "led-1", "pinName": "C" }, + "end": { "componentId": "arduino-uno", "pinName": "GND" } + } + ] +} +---END_EXPECTED_WIRING--- + +---KEY_TEXT--- +digitalRead +INPUT_PULLUP +digitalWrite +Serial +---END_KEY_TEXT--- diff --git a/examples/content/hello_serial_arduino.md b/examples/content/hello_serial_arduino.md new file mode 100644 index 0000000..e72f51b --- /dev/null +++ b/examples/content/hello_serial_arduino.md @@ -0,0 +1,138 @@ +---LESSON_INFO--- +Pelajaran Arduino: Komunikasi Serial Monitor dan debugging. + +**Learning Objectives:** +- Memahami fungsi `Serial.begin()` dan baud rate +- Menggunakan `Serial.println()` dan `Serial.print()` untuk mengirim data +- Membedakan `println` (dengan newline) dan `print` (tanpa newline) +- Menggunakan `millis()` untuk tracking waktu + +**Prerequisites:** +- Hello, World! +---END_LESSON_INFO--- + +# Hello Serial — Komunikasi Serial Arduino + +Serial Monitor adalah **alat debugging utama** saat bekerja dengan Arduino. +Dengan Serial Monitor, kamu bisa mengirim teks dari Arduino ke komputer +untuk memantau apa yang sedang terjadi dalam program. + +## Apa itu Komunikasi Serial? + +Arduino berkomunikasi dengan komputer melalui **port serial** (USB). +Data dikirim bit-per-bit dengan kecepatan tertentu yang disebut **baud rate**. + +``` +Arduino ──USB──► Komputer (Serial Monitor) + TX/RX +``` + +## Fungsi Serial Dasar + +### 1. `Serial.begin(baud_rate)` +Memulai komunikasi serial. Harus dipanggil di `setup()`: + +``` +Serial.begin(9600); // Baud rate 9600 (paling umum) +``` + +### 2. `Serial.println(data)` +Mengirim data + **newline** (`\n`) di akhir: + +``` +Serial.println("Hello"); // output: Hello↵ +Serial.println("World"); // output: World↵ +``` + +### 3. `Serial.print(data)` +Mengirim data **tanpa newline**: + +``` +Serial.print("Suhu: "); // output: Suhu: +Serial.println("25 C"); // output: 25 C↵ +``` + +Hasil gabungan: `Suhu: 25 C` + +### 4. `millis()` +Mengembalikan jumlah milidetik sejak Arduino dinyalakan: + +``` +unsigned long waktu = millis(); +Serial.print("Waktu: "); +Serial.print(waktu / 1000); +Serial.println(" detik"); +``` + +## Contoh Program + +``` +void setup() { + Serial.begin(9600); + Serial.println("Arduino Ready!"); + Serial.println("Serial Monitor aktif"); +} + +void loop() { + Serial.print("Uptime: "); + Serial.print(millis() / 1000); + Serial.println(" detik"); + delay(2000); +} +``` + +---EXERCISE--- +### Tantangan + +**Tulis program Arduino** yang melakukan hal berikut saat dinyalakan (`setup`): + +1. Mulai komunikasi serial dengan baud rate `9600` +2. Cetak teks `Hello Arduino` (dengan newline) +3. Cetak teks `Serial Ready` (dengan newline) + +Kemudian di `loop()`: +4. Cetak `Tick` setiap 1 detik + +Setelah selesai menulis kode, tekan **Compile & Run** dan tunggu beberapa detik agar Serial output muncul. + +> **Tips:** Lesson ini tidak memerlukan wiring — cukup tulis kode saja! +--- + +---INITIAL_CODE_ARDUINO--- +// Hello Serial - Belajar Serial Monitor +// Kirim pesan ke Serial Monitor + +void setup() { + // 1. Mulai serial dengan baud rate 9600 + + // 2. Cetak "Hello Arduino" + + // 3. Cetak "Serial Ready" + +} + +void loop() { + // 4. Cetak "Tick" setiap 1 detik + +} +---END_INITIAL_CODE_ARDUINO--- + +---VELXIO_CIRCUIT--- +{ + "board": "arduino:avr:uno", + "components": [], + "wires": [] +} +---END_VELXIO_CIRCUIT--- + +---EXPECTED_SERIAL_OUTPUT--- +Hello Arduino +Serial Ready +Tick +---END_EXPECTED_SERIAL_OUTPUT--- + +---KEY_TEXT--- +Serial.begin +Serial.println +delay +---END_KEY_TEXT--- diff --git a/examples/content/home.md b/examples/content/home.md index cb34ecc..4865ed1 100644 --- a/examples/content/home.md +++ b/examples/content/home.md @@ -21,6 +21,9 @@ Semua materi bisa dikerjakan langsung di browser! ### Arduino (Velxio) 4. [LED Blink](lesson/led_blink_arduino.md) +5. [Hello Serial](lesson/hello_serial_arduino.md) +6. [Button Input](lesson/button_input_arduino.md) +7. [Traffic Light](lesson/traffic_light_arduino.md) ----Available_Lessons---- @@ -28,3 +31,6 @@ Semua materi bisa dikerjakan langsung di browser! 2. [Variabel](lesson/variabel.md) 3. [Rangkaian Dasar](lesson/rangkaian_dasar.md) 4. [LED Blink](lesson/led_blink_arduino.md) +5. [Hello Serial](lesson/hello_serial_arduino.md) +6. [Button Input](lesson/button_input_arduino.md) +7. [Traffic Light](lesson/traffic_light_arduino.md) diff --git a/examples/content/traffic_light_arduino.md b/examples/content/traffic_light_arduino.md new file mode 100644 index 0000000..f48562e --- /dev/null +++ b/examples/content/traffic_light_arduino.md @@ -0,0 +1,231 @@ +---LESSON_INFO--- +Pelajaran Arduino: Membuat simulasi lampu lalu lintas dengan 3 LED. + +**Learning Objectives:** +- Mengontrol beberapa LED secara berurutan +- Menerapkan pola sequence/timing dengan `delay()` +- Merangkai 3 LED ke Arduino +- Menggunakan `const int` untuk nama pin yang mudah dibaca + +**Prerequisites:** +- Hello, World! +- LED Blink +- Hello Serial +- Button Input +---END_LESSON_INFO--- + +# Traffic Light — Lampu Lalu Lintas + +Proyek ini mensimulasikan **lampu lalu lintas** menggunakan 3 LED: +merah, kuning, dan hijau. Kita akan mengontrol urutan nyala-mati +LED persis seperti lampu lalu lintas sungguhan. + +## Urutan Lampu Lalu Lintas + +``` +1. MERAH menyala (3 detik) → Berhenti +2. KUNING menyala (1 detik) → Bersiap +3. HIJAU menyala (3 detik) → Jalan +4. KUNING menyala (1 detik) → Bersiap berhenti +5. Kembali ke MERAH... +``` + +## Menggunakan Konstanta untuk Pin + +Daripada mengingat nomor pin, gunakan **konstanta bernama**: + +``` +const int RED_PIN = 13; +const int YELLOW_PIN = 12; +const int GREEN_PIN = 11; +``` + +Keuntungan: +- Kode lebih mudah dibaca: `digitalWrite(RED_PIN, HIGH)` vs `digitalWrite(13, HIGH)` +- Jika ingin ganti pin, cukup ubah di satu tempat + +## Mengontrol Beberapa LED + +Setiap LED memerlukan pin output sendiri: + +``` +void setup() { + pinMode(RED_PIN, OUTPUT); + pinMode(YELLOW_PIN, OUTPUT); + pinMode(GREEN_PIN, OUTPUT); +} +``` + +Pastikan hanya **satu LED menyala** pada satu waktu: + +``` +// Nyalakan merah, matikan yang lain +digitalWrite(RED_PIN, HIGH); +digitalWrite(YELLOW_PIN, LOW); +digitalWrite(GREEN_PIN, LOW); +``` + +## Serial Monitor untuk Debugging + +Cetak status lampu ke Serial Monitor agar mudah di-debug: + +``` +Serial.println("RED"); +delay(3000); // Merah menyala 3 detik +``` + +---EXERCISE--- +### Tantangan + +**Kode Arduino:** +Buat program yang mensimulasikan lampu lalu lintas: +1. **Merah** di pin 13 menyala selama 3 detik, cetak `RED` ke Serial +2. **Kuning** di pin 12 menyala selama 1 detik, cetak `YELLOW` ke Serial +3. **Hijau** di pin 11 menyala selama 3 detik, cetak `GREEN` ke Serial +4. **Kuning** lagi selama 1 detik, cetak `YELLOW` ke Serial +5. Ulangi dari merah + +Pastikan hanya satu LED yang menyala pada satu waktu! + +**Rangkaian:** +Hubungkan 3 LED ke Arduino: +- Pin 13 → LED Merah (Anode A), Cathode C → GND +- Pin 12 → LED Kuning (Anode A), Cathode C → GND +- Pin 11 → LED Hijau (Anode A), Cathode C → GND + +Setelah selesai, tekan **Compile & Run** dan perhatikan LED menyala bergantian. +--- + +---INITIAL_CODE_ARDUINO--- +// Traffic Light - Lampu Lalu Lintas +// Simulasi lampu merah, kuning, hijau + +const int RED_PIN = 13; +const int YELLOW_PIN = 12; +const int GREEN_PIN = 11; + +void setup() { + pinMode(RED_PIN, OUTPUT); + pinMode(YELLOW_PIN, OUTPUT); + pinMode(GREEN_PIN, OUTPUT); + Serial.begin(9600); +} + +void loop() { + // Lampu MERAH + digitalWrite(RED_PIN, HIGH); + digitalWrite(YELLOW_PIN, LOW); + digitalWrite(GREEN_PIN, LOW); + Serial.println("RED"); + delay(3000); + + // Lampu KUNING + digitalWrite(RED_PIN, LOW); + digitalWrite(YELLOW_PIN, HIGH); + digitalWrite(GREEN_PIN, LOW); + Serial.println("YELLOW"); + delay(1000); + + // Lampu HIJAU + digitalWrite(RED_PIN, LOW); + digitalWrite(YELLOW_PIN, LOW); + digitalWrite(GREEN_PIN, HIGH); + Serial.println("GREEN"); + delay(3000); + + // Lampu KUNING lagi + digitalWrite(RED_PIN, LOW); + digitalWrite(YELLOW_PIN, HIGH); + digitalWrite(GREEN_PIN, LOW); + Serial.println("YELLOW"); + delay(1000); +} +---END_INITIAL_CODE_ARDUINO--- + +---VELXIO_CIRCUIT--- +{ + "board": "arduino:avr:uno", + "components": [ + { + "type": "wokwi-led", + "id": "led-red", + "x": 370, + "y": -300, + "rotation": 0, + "props": { + "color": "red", + "pin": 13 + } + }, + { + "type": "wokwi-led", + "id": "led-yellow", + "x": 370, + "y": -200, + "rotation": 0, + "props": { + "color": "yellow", + "pin": 12 + } + }, + { + "type": "wokwi-led", + "id": "led-green", + "x": 370, + "y": -100, + "rotation": 0, + "props": { + "color": "green", + "pin": 11 + } + } + ], + "wires": [] +} +---END_VELXIO_CIRCUIT--- + +---EXPECTED_SERIAL_OUTPUT--- +RED +YELLOW +GREEN +YELLOW +---END_EXPECTED_SERIAL_OUTPUT--- + +---EXPECTED_WIRING--- +{ + "wires": [ + { + "start": { "componentId": "arduino-uno", "pinName": "13" }, + "end": { "componentId": "led-red", "pinName": "A" } + }, + { + "start": { "componentId": "led-red", "pinName": "C" }, + "end": { "componentId": "arduino-uno", "pinName": "GND" } + }, + { + "start": { "componentId": "arduino-uno", "pinName": "12" }, + "end": { "componentId": "led-yellow", "pinName": "A" } + }, + { + "start": { "componentId": "led-yellow", "pinName": "C" }, + "end": { "componentId": "arduino-uno", "pinName": "GND" } + }, + { + "start": { "componentId": "arduino-uno", "pinName": "11" }, + "end": { "componentId": "led-green", "pinName": "A" } + }, + { + "start": { "componentId": "led-green", "pinName": "C" }, + "end": { "componentId": "arduino-uno", "pinName": "GND" } + } + ] +} +---END_EXPECTED_WIRING--- + +---KEY_TEXT--- +pinMode +digitalWrite +const int +Serial +delay +---END_KEY_TEXT--- diff --git a/load-test/README.md b/load-test/README.md new file mode 100644 index 0000000..c9335fa --- /dev/null +++ b/load-test/README.md @@ -0,0 +1,57 @@ +# Elemes Load Test + +Load test E2E menggunakan [Locust](https://locust.io/) yang otomatis di-generate dari folder `content/`. + +## Cara Pakai + +```bash +cd elemes/load-test + +# 0. Virtual Environment +python3 -m venv ./env +source ./env/bin/activate + +# 1. Install dependency +pip install -r requirements.txt + +# 2. Generate test data dari content/ +python content_parser.py + +# 3. Jalankan Locust (opsional: set VELXIO_HOST jika Velxio bukan di localhost:8001) +export VELXIO_HOST=http://localhost:8001 +locust -f locustfile.py +``` + +Buka **http://localhost:8089**, masukkan URL backend Elemes (misalnya `http://localhost:5000`), lalu mulai test. + +## File + +| File | Fungsi | +|------|--------| +| `content_parser.py` | Parse `content/*.md` → `test_data.json` + inject token test ke CSV | +| `locustfile.py` | Locust script (7 task weighted) yang baca `test_data.json` | +| `test_data.json` | Auto-generated, **jangan di-commit** | +| `requirements.txt` | Dependency (`locust`) | + +## Test Scenarios (8 Tasks) + +| # | Task | Weight | Target | Deskripsi | +|---|------|--------|--------|----------| +| 1 | Browse Lessons | 3 | Elemes | GET `/lessons`, validasi jumlah lesson | +| 2 | View Detail | 5 | Elemes | GET `/lesson/{slug}.json`, validasi field per tipe | +| 3 | Compile C | 4 | Elemes | POST `/compile`, validasi output vs expected | +| 4 | Compile Python | 3 | Elemes | POST `/compile`, validasi output vs expected | +| 5 | Verify Arduino | 2 | Elemes | GET lesson Arduino, validasi JSON structure | +| 6 | Complete Flow | 2 | Both | fetch → compile → track-progress | +| 7 | Progress Report | 1 | Elemes | Login guru → GET `/progress-report.json` | +| 8 | **Compile Arduino** | **3** | **Velxio** | POST `/api/compile`, validasi hex_content | + +## Re-generate Setelah Tambah Lesson Baru + +Setiap kali ada lesson baru di `content/`, cukup jalankan ulang: + +```bash +python content_parser.py +``` + +`test_data.json` akan di-update otomatis dan Locust langsung test lesson baru. diff --git a/load-test/content_parser.py b/load-test/content_parser.py new file mode 100644 index 0000000..b4b0b1b --- /dev/null +++ b/load-test/content_parser.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Content Parser for Locust E2E Test Generation. + +Scans the content/ directory, parses lesson markdown files, +extracts test-relevant data, and writes test_data.json. + +Also injects a LOCUST_TEST token into tokens_siswa.csv if not present. + +Usage (from elemes/load-test/): + python content_parser.py + python content_parser.py --content-dir ../../content --tokens-file ../../tokens_siswa.csv --num-tokens 50 +""" + +import argparse +import csv +import json +import os +import re +import sys +import uuid + + +# --------------------------------------------------------------------------- +# Marker extraction (mirrors lesson_service.py logic) +# --------------------------------------------------------------------------- + +def extract_section(content: str, start_marker: str, end_marker: str) -> tuple[str, str]: + """Extract text between markers. Returns (extracted, remaining).""" + if start_marker not in content or end_marker not in content: + return "", content + + start_idx = content.find(start_marker) + end_idx = content.find(end_marker) + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: + return "", content + + extracted = content[start_idx + len(start_marker):end_idx].strip() + remaining = content[:start_idx] + content[end_idx + len(end_marker):] + return extracted, remaining + + +def detect_lesson_type(content: str) -> str: + """Detect lesson type from markers present in content.""" + has_arduino = '---INITIAL_CODE_ARDUINO---' in content + has_c = '---INITIAL_CODE---' in content + has_python = '---INITIAL_PYTHON---' in content + has_circuit = '---INITIAL_CIRCUIT---' in content + has_quiz = '---INITIAL_QUIZ---' in content + has_velxio = '---VELXIO_CIRCUIT---' in content + + if has_arduino or (has_velxio and not has_c and not has_python): + return 'arduino' + if has_quiz: + return 'quiz' + if has_c and has_circuit: + return 'hybrid' + if has_circuit and not has_c and not has_python: + return 'circuit' + if has_python and not has_c: + return 'python' + return 'c' + + +def parse_lesson(filepath: str) -> dict: + """Parse a single lesson markdown file and extract test data.""" + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + slug = os.path.basename(filepath).replace('.md', '') + lesson_type = detect_lesson_type(content) + + # Extract all relevant sections + initial_code_c, _ = extract_section(content, '---INITIAL_CODE---', '---END_INITIAL_CODE---') + initial_python, _ = extract_section(content, '---INITIAL_PYTHON---', '---END_INITIAL_PYTHON---') + initial_code_arduino, _ = extract_section(content, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---') + velxio_circuit, _ = extract_section(content, '---VELXIO_CIRCUIT---', '---END_VELXIO_CIRCUIT---') + expected_output, _ = extract_section(content, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---') + expected_output_python, _ = extract_section(content, '---EXPECTED_OUTPUT_PYTHON---', '---END_EXPECTED_OUTPUT_PYTHON---') + expected_serial, _ = extract_section(content, '---EXPECTED_SERIAL_OUTPUT---', '---END_EXPECTED_SERIAL_OUTPUT---') + expected_wiring, _ = extract_section(content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---') + key_text, _ = extract_section(content, '---KEY_TEXT---', '---END_KEY_TEXT---') + solution_code, _ = extract_section(content, '---SOLUTION_CODE---', '---END_SOLUTION_CODE---') + solution_python, _ = extract_section(content, '---SOLUTION_PYTHON---', '---END_SOLUTION_PYTHON---') + + # A lesson is compilable if it has solution code or Arduino initial code + is_compilable = bool(solution_code or solution_python or initial_code_arduino) + + data = { + 'slug': slug, + 'type': lesson_type, + 'has_c': bool(initial_code_c), + 'has_python': bool(initial_python), + 'has_circuit': '---INITIAL_CIRCUIT---' in content, + 'has_arduino': bool(initial_code_arduino), + 'has_velxio': bool(velxio_circuit), + 'compilable': is_compilable, + 'key_text': key_text, + } + + # Add type-specific fields + if initial_code_c: + data['initial_code_c'] = initial_code_c + if solution_code: + data['solution_code'] = solution_code + if initial_python: + data['initial_python'] = initial_python + if solution_python: + data['solution_python'] = solution_python + if expected_output: + data['expected_output'] = expected_output + if expected_output_python: + data['expected_output_python'] = expected_output_python + if initial_code_arduino: + data['initial_code_arduino'] = initial_code_arduino + if velxio_circuit: + data['velxio_circuit'] = velxio_circuit + if expected_serial: + data['expected_serial'] = expected_serial + if expected_wiring: + data['expected_wiring'] = expected_wiring + + return data + + +def get_ordered_slugs(content_dir: str) -> list[str]: + """Get lesson slugs in order from home.md's Available_Lessons section.""" + home_path = os.path.join(content_dir, 'home.md') + if not os.path.exists(home_path): + return [] + + with open(home_path, 'r', encoding='utf-8') as f: + home_content = f.read() + + parts = home_content.split('----Available_Lessons----') + if len(parts) <= 1: + # Try alternate separator + parts = home_content.split('---Available_Lessons---') + if len(parts) <= 1: + return [] + + links = re.findall(r'\[([^\]]+)\]\((?:lesson/)?([^\)]+)\)', parts[1]) + return [fn.replace('.md', '') for _, fn in links] + + +# --------------------------------------------------------------------------- +# Token injection +# --------------------------------------------------------------------------- + +def ensure_test_tokens(tokens_file: str, lesson_slugs: list[str], num_tokens: int) -> list[str]: + """ + Ensure a specified number of test tokens exist in tokens_siswa.csv. + Returns a list of token strings. + """ + existing_tokens = [] + rows = [] + fieldnames = [] + + if os.path.exists(tokens_file): + with open(tokens_file, 'r', newline='', encoding='utf-8') as f: + reader = csv.DictReader(f, delimiter=';') + fieldnames = reader.fieldnames or [] + rows = list(reader) + + for row in rows: + if row.get('token', '').startswith('LOCUST_TEST_'): + existing_tokens.append(row['token']) + + if not fieldnames: + fieldnames = ['token', 'nama_siswa'] + lesson_slugs + + tokens_to_return = existing_tokens[:num_tokens] + + if len(tokens_to_return) < num_tokens: + needed = num_tokens - len(tokens_to_return) + new_tokens = [] + for i in range(needed): + token = f"LOCUST_TEST_{uuid.uuid4().hex[:8]}" + new_tokens.append(token) + + new_row = {'token': token, 'nama_siswa': f"Locust Bot {len(tokens_to_return) + i + 1}"} + for slug in lesson_slugs: + if slug not in fieldnames: + fieldnames.append(slug) + new_row[slug] = 'not_started' + rows.append(new_row) + + with open(tokens_file, 'w', newline='', encoding='utf-8') as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=';') + writer.writeheader() + writer.writerows(rows) + + print(f" ✚ Injected {len(new_tokens)} new test tokens.") + tokens_to_return.extend(new_tokens) + else: + print(f" ♻ Reusing {num_tokens} existing test tokens.") + + return tokens_to_return + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def _get_teacher_token(tokens_file: str) -> str: + """Get the teacher token (first data row).""" + if not os.path.exists(tokens_file): + return "" + with open(tokens_file, 'r', newline='', encoding='utf-8') as f: + reader = csv.DictReader(f, delimiter=';') + for row in reader: + return row.get('token', '') + return "" + + +def main(): + parser = argparse.ArgumentParser(description='Parse content/ for Locust test generation') + parser.add_argument('--content-dir', default='../../content', + help='Path to content directory (default: ../../content)') + parser.add_argument('--tokens-file', default='../../tokens_siswa.csv', + help='Path to tokens CSV (default: ../../tokens_siswa.csv)') + parser.add_argument('--num-tokens', type=int, default=50, + help='Number of test tokens to generate (default: 50)') + parser.add_argument('--output', default='test_data.json', + help='Output JSON file (default: test_data.json)') + args = parser.parse_args() + + content_dir = os.path.abspath(args.content_dir) + tokens_file = os.path.abspath(args.tokens_file) + + print(f"\n{'='*60}") + print(f" Elemes Content Parser for Locust E2E Testing") + print(f"{'='*60}") + print(f" Content dir : {content_dir}") + print(f" Tokens file : {tokens_file}") + print(f" Num tokens : {args.num_tokens}") + print(f" Output : {args.output}") + print() + + # 1. Get ordered lesson slugs + ordered_slugs = get_ordered_slugs(content_dir) + if not ordered_slugs: + # Fallback: scan directory + ordered_slugs = [ + f.replace('.md', '') + for f in sorted(os.listdir(content_dir)) + if f.endswith('.md') and f != 'home.md' + ] + + print(f" 📚 Found {len(ordered_slugs)} lessons:") + + # 2. Parse each lesson + lessons = [] + for slug in ordered_slugs: + filepath = os.path.join(content_dir, f'{slug}.md') + if not os.path.exists(filepath): + print(f" ⚠ {slug}.md not found, skipping") + continue + + lesson = parse_lesson(filepath) + lessons.append(lesson) + + # Summary icon per type + icons = { + 'c': '🔧', 'python': '🐍', 'hybrid': '🔀', + 'circuit': '⚡', 'arduino': '🤖', 'quiz': '❓' + } + icon = icons.get(lesson['type'], '📄') + compilable = '✓ compile' if lesson.get('compilable') else '✗ compile' + print(f" {icon} {slug} [{lesson['type']}] {compilable}") + + # 3. Inject test tokens + print() + tokens = ensure_test_tokens(tokens_file, ordered_slugs, args.num_tokens) + + # 4. Build output + test_data = { + 'generated_by': 'content_parser.py', + 'tokens': tokens, + 'teacher_token': _get_teacher_token(tokens_file), + 'lessons': lessons, + 'stats': { + 'total': len(lessons), + 'c': sum(1 for l in lessons if l['type'] == 'c'), + 'python': sum(1 for l in lessons if l['type'] == 'python'), + 'hybrid': sum(1 for l in lessons if l['type'] == 'hybrid'), + 'circuit': sum(1 for l in lessons if l['type'] == 'circuit'), + 'arduino': sum(1 for l in lessons if l['type'] == 'arduino'), + 'quiz': sum(1 for l in lessons if l['type'] == 'quiz'), + 'compilable': sum(1 for l in lessons if l.get('compilable')), + } + } + + # 5. Write output + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(test_data, f, indent=2, ensure_ascii=False) + + print(f"\n ✅ Wrote {args.output}") + print(f" {test_data['stats']['total']} lessons " + f"({test_data['stats']['compilable']} compilable)") + print(f"\n Next: locust -f locustfile.py") + print(f"{'='*60}\n") + + +if __name__ == '__main__': + main() diff --git a/load-test/locustfile.py b/load-test/locustfile.py new file mode 100644 index 0000000..c7c7307 --- /dev/null +++ b/load-test/locustfile.py @@ -0,0 +1,482 @@ +""" +Locust E2E Test Suite for Elemes LMS. + +Auto-generated test scenarios from content/ folder via content_parser.py. +Reads test_data.json to know which lessons exist and their types. + +Usage (from elemes/load-test/): + 1. python content_parser.py # generate test_data.json + 2. locust -f locustfile.py # open web UI at http://localhost:8089 + → Set host to your Elemes backend URL in the web UI + +Environment variables: + VELXIO_HOST — Velxio backend URL for Arduino compilation + (default: http://localhost:8001) + +All API paths hit the Flask backend directly (no /api prefix needed +when targeting Flask at :5000). Arduino compile hits Velxio backend. +""" + +import json +import logging +import os +import random +import time + +import requests +from locust import HttpUser, task, between, events + +# --------------------------------------------------------------------------- +# Load test data +# --------------------------------------------------------------------------- + +TEST_DATA_FILE = os.path.join(os.path.dirname(__file__), 'test_data.json') + +def load_test_data() -> dict: + """Load test_data.json generated by content_parser.py.""" + if not os.path.exists(TEST_DATA_FILE): + raise FileNotFoundError( + f"{TEST_DATA_FILE} not found. " + f"Run 'python content_parser.py' first." + ) + with open(TEST_DATA_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + + +try: + TEST_DATA = load_test_data() +except FileNotFoundError as e: + logging.error(str(e)) + TEST_DATA = {'token': '', 'teacher_token': '', 'lessons': []} + +TOKENS = TEST_DATA.get('tokens', []) +TEACHER_TOKEN = TEST_DATA.get('teacher_token', '') +ALL_LESSONS = TEST_DATA.get('lessons', []) + +# Velxio backend URL for Arduino compilation +VELXIO_HOST = os.environ.get('VELXIO_HOST', 'http://localhost:8001') + +# Pre-filter lessons by type for task assignment +C_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'c' and l.get('solution_code')] +PYTHON_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'python' and l.get('solution_python')] +HYBRID_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'hybrid'] +ARDUINO_LESSONS = [l for l in ALL_LESSONS if l['type'] == 'arduino'] +COMPILABLE_LESSONS = C_LESSONS + PYTHON_LESSONS + [ + l for l in HYBRID_LESSONS if l.get('solution_code') or l.get('solution_python') +] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def check_key_text(code: str, key_text: str) -> bool: + """Client-side key_text check (mirrors exercise.ts logic).""" + if not key_text.strip(): + return True + keys = [k.strip() for k in key_text.split('\n') if k.strip()] + return all(k in code for k in keys) + + +# --------------------------------------------------------------------------- +# Locust User +# --------------------------------------------------------------------------- + +class ElemesStudent(HttpUser): + """ + Simulates a student interacting with the Elemes LMS. + + Each user logs in once (on_start), then performs weighted tasks + that mirror real student behavior. + """ + + wait_time = between(1, 5) + + def on_start(self): + """Login once when user spawns.""" + self.token = random.choice(TOKENS) if TOKENS else '' + resp = self.client.post( + '/login', + json={'token': self.token}, + name='/login' + ) + data = resp.json() + if not data.get('success'): + logging.warning(f"Login failed: {data.get('message')}") + + # ── Task 1: Browse Lessons (weight=3) ────────────────────────────── + + @task(3) + def browse_lessons(self): + """Fetch lesson list — simulates landing on home page.""" + with self.client.get('/lessons', name='/lessons', catch_response=True) as resp: + data = resp.json() + lessons = data.get('lessons', []) + if len(lessons) == 0: + resp.failure("No lessons returned") + elif len(lessons) != len(ALL_LESSONS): + resp.failure( + f"Expected {len(ALL_LESSONS)} lessons, got {len(lessons)}" + ) + + # ── Task 2: View Lesson Detail (weight=5) ────────────────────────── + + @task(5) + def view_lesson_detail(self): + """Fetch a random lesson's detail JSON.""" + if not ALL_LESSONS: + return + + lesson = random.choice(ALL_LESSONS) + slug = lesson['slug'] + + with self.client.get( + f'/lesson/{slug}.json', + name='/lesson/[slug].json', + catch_response=True + ) as resp: + data = resp.json() + + if 'error' in data: + resp.failure(f"Lesson {slug} not found") + return + + # Validate fields based on type + if lesson['type'] == 'arduino': + if not data.get('initial_code_arduino'): + resp.failure(f"{slug}: missing initial_code_arduino") + if not data.get('velxio_circuit'): + resp.failure(f"{slug}: missing velxio_circuit") + # Validate velxio_circuit is valid JSON + try: + json.loads(data['velxio_circuit']) + except (json.JSONDecodeError, TypeError): + resp.failure(f"{slug}: velxio_circuit is not valid JSON") + elif lesson['type'] in ('c', 'hybrid'): + if not data.get('initial_code'): + resp.failure(f"{slug}: missing initial_code") + elif lesson['type'] == 'python': + if not data.get('initial_python') and not data.get('initial_code'): + resp.failure(f"{slug}: missing initial code") + + # ── Task 3: Compile C Lesson (weight=4) ──────────────────────────── + + @task(4) + def compile_c_lesson(self): + """Compile a C lesson's solution and validate output.""" + if not C_LESSONS: + return + + lesson = random.choice(C_LESSONS) + code = lesson.get('solution_code', lesson.get('initial_code_c', '')) + expected = lesson.get('expected_output', '') + + if not code: + return + + with self.client.post( + '/compile', + json={'code': code, 'language': 'c'}, + name='/compile [C]', + catch_response=True + ) as resp: + data = resp.json() + + if not data.get('success'): + resp.failure(f"Compile failed: {data.get('error', 'unknown')}") + return + + output = data.get('output', '') + + # Validate expected output + if expected and expected.strip() not in output: + resp.failure( + f"{lesson['slug']}: expected '{expected.strip()[:50]}' " + f"not in output '{output[:50]}'" + ) + return + + # Validate key_text + key_text = lesson.get('key_text', '') + if key_text and not check_key_text(code, key_text): + resp.failure(f"{lesson['slug']}: key_text check failed") + + # ── Task 4: Compile Python Lesson (weight=3) ─────────────────────── + + @task(3) + def compile_python_lesson(self): + """Compile a Python lesson's solution and validate output.""" + if not PYTHON_LESSONS: + return + + lesson = random.choice(PYTHON_LESSONS) + code = lesson.get('solution_python', lesson.get('initial_python', '')) + expected = lesson.get('expected_output_python', lesson.get('expected_output', '')) + + if not code: + return + + with self.client.post( + '/compile', + json={'code': code, 'language': 'python'}, + name='/compile [Python]', + catch_response=True + ) as resp: + data = resp.json() + + if not data.get('success'): + resp.failure(f"Compile failed: {data.get('error', 'unknown')}") + return + + output = data.get('output', '') + if expected and expected.strip() not in output: + resp.failure( + f"{lesson['slug']}: expected '{expected.strip()[:50]}' " + f"not in output '{output[:50]}'" + ) + + # ── Task 5: Verify Arduino Lesson Structure (weight=2) ───────────── + + @task(2) + def verify_arduino_lesson(self): + """ + Verify an Arduino lesson's data structure. + Validates JSON integrity of velxio_circuit and expected_wiring. + """ + if not ARDUINO_LESSONS: + return + + lesson = random.choice(ARDUINO_LESSONS) + slug = lesson['slug'] + + with self.client.get( + f'/lesson/{slug}.json', + name='/lesson/[arduino].json', + catch_response=True + ) as resp: + data = resp.json() + + errors = [] + + # Check required fields + if not data.get('initial_code_arduino'): + errors.append('missing initial_code_arduino') + + vc = data.get('velxio_circuit', '') + if vc: + try: + circuit = json.loads(vc) + if 'board' not in circuit: + errors.append('velxio_circuit missing board field') + except (json.JSONDecodeError, TypeError): + errors.append('velxio_circuit invalid JSON') + else: + errors.append('missing velxio_circuit') + + # Check expected_wiring JSON validity if present + ew = data.get('expected_wiring', '') + if ew: + try: + wiring = json.loads(ew) + if 'wires' not in wiring: + errors.append('expected_wiring missing wires array') + except (json.JSONDecodeError, TypeError): + errors.append('expected_wiring invalid JSON') + + # Key text check against initial code + key_text = lesson.get('key_text', '') + initial_code = data.get('initial_code_arduino', '') + if key_text and initial_code and not check_key_text(initial_code, key_text): + errors.append('key_text not found in initial_code_arduino') + + if errors: + resp.failure(f"{slug}: {'; '.join(errors)}") + + # ── Task 8: Compile Arduino via Velxio (weight=3) ────────────────── + + @task(3) + def compile_arduino_lesson(self): + """ + Compile an Arduino lesson's code via Velxio backend. + Hits Velxio's POST /api/compile endpoint. + Validates: compile success + hex_content returned. + Note: serial output can only be validated in-browser (avr8js sim). + """ + if not ARDUINO_LESSONS: + return + + lesson = random.choice(ARDUINO_LESSONS) + code = lesson.get('initial_code_arduino', '') + if not code: + return + + try: + resp = requests.post( + f'{VELXIO_HOST}/api/compile', + json={'code': code, 'board_fqbn': 'arduino:avr:uno'}, + timeout=30 + ) + data = resp.json() + + if not data.get('success'): + # Report as Locust failure for stats tracking + self.environment.events.request.fire( + request_type='POST', + name='/api/compile [Arduino]', + response_time=resp.elapsed.total_seconds() * 1000, + response_length=len(resp.content), + exception=Exception( + f"{lesson['slug']}: compile failed — " + f"{data.get('error', data.get('stderr', 'unknown'))}" + ), + ) + return + + hex_content = data.get('hex_content', '') + if not hex_content: + self.environment.events.request.fire( + request_type='POST', + name='/api/compile [Arduino]', + response_time=resp.elapsed.total_seconds() * 1000, + response_length=len(resp.content), + exception=Exception(f"{lesson['slug']}: no hex_content"), + ) + return + + # Success + self.environment.events.request.fire( + request_type='POST', + name='/api/compile [Arduino]', + response_time=resp.elapsed.total_seconds() * 1000, + response_length=len(resp.content), + exception=None, + ) + + except requests.RequestException as e: + self.environment.events.request.fire( + request_type='POST', + name='/api/compile [Arduino]', + response_time=0, + response_length=0, + exception=e, + ) + + # ── Task 6: Complete Lesson Flow (weight=2) ──────────────────────── + + @task(2) + def complete_lesson_flow(self): + """ + Full flow: fetch lesson → compile (if possible) → track progress. + Simulates a student completing a lesson. + """ + if not ALL_LESSONS: + return + + lesson = random.choice(ALL_LESSONS) + slug = lesson['slug'] + + # 1. Fetch lesson detail + resp = self.client.get( + f'/lesson/{slug}.json', + name='/lesson/[slug].json (flow)' + ) + if resp.status_code != 200: + return + + # 2. Compile if it's a C or Python lesson with solution + if lesson['type'] in ('c', 'hybrid') and lesson.get('solution_code'): + self.client.post( + '/compile', + json={'code': lesson['solution_code'], 'language': 'c'}, + name='/compile (flow)' + ) + time.sleep(0.5) # Simulate student reading output + + elif lesson['type'] == 'python' and lesson.get('solution_python'): + self.client.post( + '/compile', + json={'code': lesson['solution_python'], 'language': 'python'}, + name='/compile (flow)' + ) + time.sleep(0.5) + + elif lesson['type'] == 'arduino' and lesson.get('initial_code_arduino'): + try: + requests.post( + f'{VELXIO_HOST}/api/compile', + json={'code': lesson['initial_code_arduino'], + 'board_fqbn': 'arduino:avr:uno'}, + timeout=30 + ) + except requests.RequestException: + pass # Non-critical in flow + time.sleep(0.5) + + # 3. Track progress + with self.client.post( + '/track-progress', + json={ + 'token': self.token, + 'lesson_name': slug, + 'status': 'completed' + }, + name='/track-progress', + catch_response=True + ) as resp: + data = resp.json() + if not data.get('success'): + resp.failure(f"Track progress failed: {data.get('message')}") + + # ── Task 7: Progress Report (weight=1) ───────────────────────────── + + @task(1) + def view_progress_report(self): + """Fetch progress report (teacher perspective).""" + if not TEACHER_TOKEN: + return + + # Login as teacher + self.client.post( + '/login', + json={'token': TEACHER_TOKEN}, + name='/login [teacher]' + ) + + with self.client.get( + '/progress-report.json', + name='/progress-report.json', + catch_response=True + ) as resp: + data = resp.json() + students = data.get('students', []) + lessons = data.get('lessons', []) + + if not lessons: + resp.failure("No lessons in progress report") + + # Re-login as student for subsequent tasks + self.client.post( + '/login', + json={'token': self.token}, + name='/login [re-auth]' + ) + + +# --------------------------------------------------------------------------- +# Event hooks +# --------------------------------------------------------------------------- + +@events.test_start.add_listener +def on_test_start(environment, **kwargs): + """Log test configuration at start.""" + stats = TEST_DATA.get('stats', {}) + logging.info("=" * 60) + logging.info(" Elemes LMS — Locust E2E Test") + logging.info("=" * 60) + logging.info(f" Lessons: {stats.get('total', 0)} total") + logging.info(f" C: {stats.get('c', 0)}, Python: {stats.get('python', 0)}, " + f"Hybrid: {stats.get('hybrid', 0)}, Arduino: {stats.get('arduino', 0)}") + logging.info(f" Compilable: {stats.get('compilable', 0)}") + logging.info(f" Tokens: {len(TOKENS)} loaded") + logging.info(f" Velxio: {VELXIO_HOST}") + logging.info("=" * 60) diff --git a/load-test/requirements.txt b/load-test/requirements.txt new file mode 100644 index 0000000..8eaebfd --- /dev/null +++ b/load-test/requirements.txt @@ -0,0 +1 @@ +locust diff --git a/podman-compose.yml b/podman-compose.yml index 532fd41..c8a80a8 100644 --- a/podman-compose.yml +++ b/podman-compose.yml @@ -73,5 +73,4 @@ volumes: networks: main_network: - drive: bridge - network_mode: service:elemes-ts + driver: bridge diff --git a/proposal.md b/proposal.md index 3212af9..4dcbc94 100644 --- a/proposal.md +++ b/proposal.md @@ -1,1072 +1,59 @@ -# Proposal: Integrasi Velxio sebagai Simulator Mikrokontroler di Elemes LMS - -## Daftar Isi - -1. [Latar Belakang](#1-latar-belakang) -2. [Keputusan Arsitektur](#2-keputusan-arsitektur) -3. [Arsitektur Integrasi](#3-arsitektur-integrasi) -4. [Format Lesson Markdown](#4-format-lesson-markdown) -5. [PostMessage Bridge Protocol](#5-postmessage-bridge-protocol) -6. [Sistem Evaluasi](#6-sistem-evaluasi) -7. [Modifikasi Velxio (Fork)](#7-modifikasi-velxio-fork) -8. [Modifikasi Elemes](#8-modifikasi-elemes) -9. [Workflow Guru](#9-workflow-guru) -10. [Deployment](#10-deployment) -11. [Lisensi](#11-lisensi) -12. [Roadmap](#12-roadmap) -13. [Pertanyaan Terbuka](#13-pertanyaan-terbuka) - ---- - -## 1. Latar Belakang - -### 1.1 Elemes Saat Ini - -Elemes adalah LMS untuk mengajar pemrograman dan elektronika. Saat ini mendukung: - -- **Mode C** — editor + gcc backend + evaluasi stdout -- **Mode Python** — editor + python exec + evaluasi stdout -- **Mode Circuit** — CircuitJS1 iframe + evaluasi node voltage - -Setiap mode menggunakan tab system (info | exercise | editor | circuit | output) dan evaluasi berbasis: -- `expected_output` — stdout matching -- `expected_circuit_output` — node voltage JSON -- `key_text` — keyword wajib di source code - -### 1.2 Kebutuhan Baru - -Materi Arduino/mikrokontroler membutuhkan kemampuan: -- Menulis dan compile kode Arduino C++ -- Simulasi CPU (AVR8) di browser -- Komponen visual interaktif (LED, LCD, sensor, dll) -- Serial Monitor -- Wiring interaktif (siswa merangkai sendiri) - -### 1.3 Mengapa Velxio - -[Velxio](https://github.com/davidmonterocrespo24/velxio) adalah simulator Arduino open-source yang sudah menyediakan semua kebutuhan di atas: - -- 19 board (Arduino Uno/Mega/Nano, ESP32, Pico, dll) -- 48+ komponen visual (wokwi-elements) -- CPU emulation nyata (avr8js, rp2040js) -- Wire system dengan drag-and-drop -- Serial Monitor -- Compile via arduino-cli -- Self-hosted via Docker - -Membangun semua ini dari nol membutuhkan ~2-3 minggu. Integrasi Velxio via iframe + postMessage bridge membutuhkan ~3-5 hari. - ---- - -## 2. Keputusan Arsitektur - -### 2.1 Velxio Mengambil Alih Workspace (Exclusive Mode) - -Ketika lesson mengandung `---INITIAL_CODE_ARDUINO---`, Velxio di-embed sebagai iframe dan **menggantikan** seluruh workspace Elemes. Marker `---INITIAL_CODE---` (C), `---INITIAL_CODE_PYTHON---`, dan tab system yang lama **diabaikan** untuk lesson tersebut. - -**Alasan:** Velxio sudah bundle editor + simulator + serial monitor + wire system. Memecahnya ke tab Elemes akan merusak UX dan mempersulit integrasi. - -``` -Lesson C/Python (existing): -┌────────┬──────────┬──────────┐ -│ Info │ Editor │ Output │ -│ │ (Elemes) │ (stdout) │ -└────────┴──────────┴──────────┘ - -Lesson Arduino (baru): -┌────────┬───────────────────────────────┐ -│ Info │ Velxio │ -│ │ (editor+sim+serial+wiring) │ -│Exercise│ │ -│ │ [iframe] │ -└────────┴───────────────────────────────┘ -``` - -### 2.2 Initial Circuit via Markdown JSON - -Guru mendesain rangkaian di Velxio standalone, export sebagai JSON, lalu paste ke blok `---VELXIO_CIRCUIT---` di lesson markdown. Saat lesson dimuat, Elemes mengirim JSON ini ke Velxio iframe via postMessage. - -**Alasan:** Velxio menyimpan project ke database (karena pakai akun), tapi Elemes tidak perlu database — cukup JSON di markdown yang di-serve saat lesson load. - -### 2.3 Evaluasi: Serial Output + Key Text + Wiring - -Tiga jenis evaluasi yang disepakati: - -| Tipe Evaluasi | Objek | Metode | -|---------------|-------|--------| -| **Program** | Serial output (USART) | String matching: actual vs `expected_serial_output` | -| **Program** | Source code | Keyword check: `key_text` ada di source code | -| **Circuit** | Wiring topology | Graph comparison: student wires vs `expected_wiring` | - -**Keputusan penting:** IO state (pin HIGH/LOW) **tidak** dievaluasi secara otomatis. LED nyala, servo berputar, dll adalah **feedback visual** bagi siswa — bukan objek evaluasi. Alasannya: - -- IO state berubah terhadap waktu (non-deterministic snapshot) -- Evaluasi time-based memerlukan recording + timing CPU cycle (effort tinggi) -- Timing avr8js ≠ wall clock (simulasi bisa lebih cepat dari real-time) -- Guru harus paham timing mikrokontroler untuk menulis expected IO pattern -- Serial output sudah cukup membuktikan program berjalan benar untuk konteks edukasi - -### 2.4 Component ID Fixed dari Initial Circuit - -Saat guru menyediakan `VELXIO_CIRCUIT`, component ID sudah ditetapkan (`led1`, `r1`, dll). Siswa tidak perlu drag komponen baru — komponen sudah ada di canvas, siswa hanya perlu wiring. - -**Alasan:** Evaluasi wiring bergantung pada component ID. Kalau siswa drag komponen sendiri, ID-nya auto-generated dan tidak cocok dengan expected wiring. Dengan ID fixed, evaluasi wiring menjadi deterministik. - ---- - -## 3. Arsitektur Integrasi - -### 3.1 Overview - -``` -┌─────────────────── Browser ───────────────────────┐ -│ │ -│ ┌─── Elemes (host page) ────────────────────┐ │ -│ │ │ │ -│ │ Lesson Info + Exercise │ │ -│ │ VelxioBridge.js ◄──── postMessage ──────┐│ │ -│ │ Evaluate button ││ │ -│ │ ││ │ -│ │ ┌─── Velxio (iframe) ─────────────────┐ ││ │ -│ │ │ │ ││ │ -│ │ │ EmbedBridge.ts ───── postMessage ──┘│ │ │ -│ │ │ Monaco Editor (source code) │ │ │ -│ │ │ Simulator Canvas (komponen+wiring) │ │ │ -│ │ │ Serial Monitor (USART output) │ │ │ -│ │ │ Toolbar (Compile/Run/Stop) │ │ │ -│ │ │ │ │ │ -│ │ └─────────────────────────────────────┘ │ │ -│ └────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────┘ - -┌─────────────────── Docker ────────────────────────┐ -│ Elemes container (Flask, port 3000) │ -│ Velxio container (FastAPI + arduino-cli, port 3080│ -└────────────────────────────────────────────────────┘ -``` - -### 3.2 Data Flow - -``` -LESSON LOAD: - Elemes parse markdown - → extract INITIAL_CODE_ARDUINO - → extract VELXIO_CIRCUIT JSON - → render iframe src="velxio:3080/editor?embed=true" - → on velxio:ready → send elemes:load_code + elemes:load_circuit - -SISWA BEKERJA: - Siswa edit code di Velxio editor - Siswa wiring komponen di Velxio canvas - Siswa klik Run → Velxio compile + simulate internally - Serial output tampil di Velxio Serial Monitor - LED nyala, LCD tampil → feedback visual - -EVALUASI (klik Submit di Elemes): - Elemes send elemes:get_source_code → Velxio respond velxio:source_code - Elemes send elemes:get_serial_log → Velxio respond velxio:serial_log - Elemes send elemes:get_wires → Velxio respond velxio:wires - Elemes evaluate: - 1. serial_log vs expected_serial_output - 2. source_code vs key_text - 3. wires vs expected_wiring - → pass/fail -``` - ---- - -## 4. Format Lesson Markdown - -### 4.1 Lesson Arduino Murni (Code Only) - -```markdown ----LESSON_INFO--- -**Learning Objectives:** -- Memahami fungsi setup() dan loop() -- Menggunakan Serial.println() - -**Prerequisites:** -- Dasar pemrograman C ----END_LESSON_INFO--- - -# Serial Hello World - -Program pertama Arduino: mencetak teks ke Serial Monitor. - ----EXERCISE--- -Buat program Arduino yang mencetak "Hello, World!" ke Serial Monitor. ---- - ----INITIAL_CODE_ARDUINO--- -void setup() { - // Inisialisasi Serial dengan baud rate 9600 - -} - -void loop() { - // Cetak "Hello, World!" lalu delay 1 detik - -} ----END_INITIAL_CODE_ARDUINO--- - ----EXPECTED_SERIAL_OUTPUT--- -Hello, World! ----END_EXPECTED_SERIAL_OUTPUT--- - ----KEY_TEXT--- -Serial.begin -Serial.println ----END_KEY_TEXT--- -``` - -### 4.2 Lesson Arduino + Circuit (Hybrid) - -```markdown ----LESSON_INFO--- -**Learning Objectives:** -- Mengontrol LED dengan digitalWrite -- Merangkai LED + resistor ke Arduino - -**Prerequisites:** -- Serial Hello World ----END_LESSON_INFO--- - -# Blink LED - -Menyalakan dan mematikan LED di pin 13 setiap 500ms. - ----EXERCISE--- -### Bagian 1: Wiring -Hubungkan komponen sesuai skema: -- Pin 13 → Resistor 220Ω → LED (Anoda) → GND - -### Bagian 2: Kode -Buat program yang toggle LED dan cetak status ke Serial Monitor. ---- - ----INITIAL_CODE_ARDUINO--- -void setup() { - // Inisialisasi pin 13 sebagai OUTPUT - // Inisialisasi Serial -} - -void loop() { - // Nyalakan LED, cetak "ON", delay 500ms - // Matikan LED, cetak "OFF", delay 500ms -} ----END_INITIAL_CODE_ARDUINO--- - ----VELXIO_CIRCUIT--- -{ - "board": "arduino:avr:uno", - "components": [ - { - "type": "wokwi-led", - "id": "led1", - "x": 350, "y": 180, - "rotation": 0, - "props": { "color": "red" } - }, - { - "type": "wokwi-resistor", - "id": "r1", - "x": 280, "y": 180, - "rotation": 0, - "props": { "value": "220" } - } - ], - "wires": [] -} ----END_VELXIO_CIRCUIT--- - ----EXPECTED_SERIAL_OUTPUT--- -ON -OFF -ON -OFF ----END_EXPECTED_SERIAL_OUTPUT--- - ----EXPECTED_WIRING--- -[ - ["board:13", "r1:1"], - ["r1:2", "led1:A"], - ["led1:C", "board:GND"] -] ----END_EXPECTED_WIRING--- - ----KEY_TEXT--- -digitalWrite -delay -Serial.println -pinMode ----END_KEY_TEXT--- -``` - -### 4.3 Lesson Circuit Only (Wiring Tanpa Kode) - -```markdown ----LESSON_INFO--- -**Learning Objectives:** -- Memahami rangkaian seri LED + resistor -- Memahami fungsi resistor sebagai pembatas arus ----END_LESSON_INFO--- - -# Rangkaian Dasar LED - -Pelajari cara merangkai LED dengan resistor pembatas arus. - ----EXERCISE--- -Hubungkan rangkaian berikut: -- 5V → Resistor 220Ω → LED → GND ---- - ----VELXIO_CIRCUIT--- -{ - "board": "arduino:avr:uno", - "components": [ - { - "type": "wokwi-led", - "id": "led1", - "x": 350, "y": 180, - "props": { "color": "green" } - }, - { - "type": "wokwi-resistor", - "id": "r1", - "x": 280, "y": 180, - "props": { "value": "220" } - } - ], - "wires": [] -} ----END_VELXIO_CIRCUIT--- - ----EXPECTED_WIRING--- -[ - ["board:5V", "r1:1"], - ["r1:2", "led1:A"], - ["led1:C", "board:GND"] -] ----END_EXPECTED_WIRING--- -``` - -Catatan: tidak ada `INITIAL_CODE_ARDUINO` → Velxio di-embed dengan `hideEditor=true`, hanya menampilkan canvas simulator. - -### 4.4 Deteksi Mode (Backward Compatible) - -```python -# Prioritas mode detection: -# 1. INITIAL_CODE_ARDUINO ada → mode velxio (abaikan INITIAL_CODE, INITIAL_CODE_PYTHON) -# 2. INITIAL_CODE_PYTHON ada → mode python (existing) -# 3. INITIAL_CODE ada → mode c (existing) -# 4. INITIAL_CIRCUIT saja → mode circuit (CircuitJS, existing) -# -# VELXIO_CIRCUIT hanya di-parse jika mode = velxio -# INITIAL_CIRCUIT (CircuitJS format) diabaikan jika mode = velxio -``` - -### 4.5 Ringkasan Semua Marker - -| Marker | Mode | Status | -|--------|------|--------| -| `---INITIAL_CODE---` | C | Existing | -| `---INITIAL_CODE_PYTHON---` | Python | Existing | -| `---INITIAL_CIRCUIT---` | Circuit (CircuitJS) | Existing | -| `---INITIAL_CODE_ARDUINO---` | **Velxio (baru)** | Baru | -| `---VELXIO_CIRCUIT---` | **Velxio (baru)** | Baru | -| `---EXPECTED_OUTPUT---` | C/Python | Existing | -| `---EXPECTED_SERIAL_OUTPUT---` | **Velxio (baru)** | Baru | -| `---EXPECTED_WIRING---` | **Velxio (baru)** | Baru | -| `---EXPECTED_CIRCUIT_OUTPUT---` | Circuit (CircuitJS) | Existing | -| `---KEY_TEXT---` | Semua mode | Existing | -| `---LESSON_INFO---` | Semua mode | Existing | -| `---EXERCISE---` | Semua mode | Existing | - ---- - -## 5. PostMessage Bridge Protocol - -### 5.1 Elemes → Velxio (Commands) - -| Message Type | Payload | Kapan Dikirim | -|-------------|---------|---------------| -| `elemes:load_code` | `{ files: [{ name, content }] }` | Saat lesson load | -| `elemes:load_circuit` | `{ board, components, wires }` | Saat lesson load | -| `elemes:compile_and_run` | `{}` | Opsional: trigger dari Elemes | -| `elemes:stop` | `{}` | Opsional: stop dari Elemes | -| `elemes:reset` | `{}` | Opsional: reset simulation | -| `elemes:get_source_code` | `{}` | Saat evaluasi (submit) | -| `elemes:get_serial_log` | `{}` | Saat evaluasi (submit) | -| `elemes:get_wires` | `{}` | Saat evaluasi (submit) | -| `elemes:set_embed_mode` | `{ hideEditor?, hideAuth?, hideToolbar? }` | Saat iframe load | - -### 5.2 Velxio → Elemes (Events/Responses) - -| Message Type | Payload | Kapan Dikirim | -|-------------|---------|---------------| -| `velxio:ready` | `{ version }` | Saat Velxio iframe selesai load | -| `velxio:source_code` | `{ files: [{ name, content }] }` | Response ke `get_source_code` | -| `velxio:serial_log` | `{ log: string }` | Response ke `get_serial_log` | -| `velxio:wires` | `{ wires: [{ start, end, signalType }] }` | Response ke `get_wires` | -| `velxio:compile_result` | `{ success, errors? }` | Setelah compile selesai | -| `velxio:sim_start` | `{}` | Saat simulation mulai | -| `velxio:sim_stop` | `{}` | Saat simulation berhenti | - -### 5.3 Sequence Diagram: Load Lesson - -``` -Elemes Velxio (iframe) - │ │ - │ render iframe │ - │ src="velxio/editor?embed=true" │ - │ ────────────────────────────────>│ - │ │ - │ velxio:ready │ - │ <────────────────────────────────│ - │ │ - │ elemes:set_embed_mode │ - │ {hideAuth:true} │ - │ ────────────────────────────────>│ - │ │ - │ elemes:load_circuit │ - │ {board, components, wires:[]} │ - │ ────────────────────────────────>│ → inject ke useSimulatorStore - │ │ - │ elemes:load_code │ - │ {files:[{name:"sketch.ino", │ - │ content:"void setup()..."}]} │ - │ ────────────────────────────────>│ → inject ke useEditorStore - │ │ - │ (siswa bekerja...) │ -``` - -### 5.4 Sequence Diagram: Evaluasi - -``` -Elemes Velxio (iframe) - │ │ - │ [Siswa klik Submit] │ - │ │ - │ elemes:get_source_code │ - │ ────────────────────────────────>│ - │ velxio:source_code │ - │ <────────────────────────────────│ - │ │ - │ elemes:get_serial_log │ - │ ────────────────────────────────>│ - │ velxio:serial_log │ - │ <────────────────────────────────│ - │ │ - │ elemes:get_wires │ - │ ────────────────────────────────>│ - │ velxio:wires │ - │ <────────────────────────────────│ - │ │ - │ [Elemes evaluasi lokal] │ - │ ✓ serial_log vs expected │ - │ ✓ source_code vs key_text │ - │ ✓ wires vs expected_wiring │ - │ → PASS / FAIL │ -``` - ---- - -## 6. Sistem Evaluasi - -### 6.1 Serial Output Matching - -Membandingkan akumulasi output USART selama simulasi berjalan dengan `expected_serial_output`. - -```python -# Pseudocode evaluasi -def evaluate_serial(actual_log: str, expected: str) -> bool: - actual_lines = actual_log.strip().splitlines() - expected_lines = expected.strip().splitlines() - - # Cek: expected lines harus muncul di actual (in order) - # Actual boleh lebih panjang (program terus loop) - j = 0 - for line in actual_lines: - if j < len(expected_lines) and line.strip() == expected_lines[j].strip(): - j += 1 - if j == len(expected_lines): - return True - return j == len(expected_lines) -``` - -**Catatan:** Evaluasi subsequence, bukan exact match — karena program Arduino biasanya loop terus, serial output bisa lebih panjang dari expected. - -### 6.2 Key Text Check - -Sama dengan sistem yang sudah ada di Elemes — cek apakah keyword wajib ada di source code. - -```python -def evaluate_key_text(source_code: str, keywords: list[str]) -> bool: - return all(kw in source_code for kw in keywords) -``` - -### 6.3 Wiring Topology Check - -Membandingkan koneksi wire siswa vs expected wiring sebagai **undirected graph edges**. - -```javascript -function evaluateWiring(studentWires, expectedConnections) { - // Normalize setiap wire menjadi sorted pair string - const normalize = (a, b) => [a, b].sort().join('↔'); - - const studentEdges = new Set( - studentWires.map(w => normalize( - `${w.start.componentId}:${w.start.pinName}`, - `${w.end.componentId}:${w.end.pinName}` - )) - ); - - const expectedEdges = expectedConnections.map( - ([a, b]) => normalize(a, b) - ); - - // Semua expected edge harus ada di student wiring - const allPresent = expectedEdges.every(e => studentEdges.has(e)); - - // Opsional: tidak boleh ada wire berlebih (strict mode) - // const noExtra = studentEdges.size === expectedEdges.length; - - return allPresent; -} -``` - -**Keputusan:** Evaluasi hanya cek expected edges **ada** di student wiring. Wire berlebih (misalnya siswa tambah wire extra) **tidak** menyebabkan fail. Ini lebih forgiving untuk konteks edukasi. - -### 6.4 Kombinasi Evaluasi Per Tipe Lesson - -| Tipe Lesson | Serial | Key Text | Wiring | Lulus jika | -|-------------|--------|----------|--------|-----------| -| Code only | ✓ | ✓ | — | Serial + Key Text pass | -| Circuit only | — | — | ✓ | Wiring pass | -| Hybrid | ✓ | ✓ | ✓ | Semua pass | - ---- - -## 7. Modifikasi Velxio (Fork) - -### 7.1 File Baru - -| File | Deskripsi | LOC estimasi | -|------|-----------|-------------| -| `frontend/src/services/EmbedBridge.ts` | PostMessage bridge terpusat: kirim event, terima command, dispatch ke store | ~150 | - -### 7.2 File yang Dimodifikasi - -| # | File | Modifikasi | LOC estimasi | -|---|------|-----------|-------------| -| 1 | `frontend/src/simulation/AVRSimulator.ts` | Akumulasi serial output ke buffer, expose `getSerialLog()` | ~15 | -| 2 | `frontend/src/store/useSimulatorStore.ts` | (a) Tambah action `loadCircuit(data)` untuk inject components+wires dari JSON. (b) Tambah action `getWires()` return current wires. (c) Init EmbedBridge saat store create. | ~40 | -| 3 | `frontend/src/store/useEditorStore.ts` | Tambah action `loadFiles(files)` untuk inject code dari parent (sudah ada `loadFiles` — tinggal expose ke bridge) | ~10 | -| 4 | `frontend/src/pages/EditorPage.tsx` | Parse query param `?embed=true`, hide AppHeader, hide auth UI, hide save/login modal, opsional hide editor/sidebar | ~30 | -| 5 | `frontend/src/components/editor/EditorToolbar.tsx` | Tambah tombol "Export Circuit JSON" (copy to clipboard). Hanya tampil di non-embed mode (untuk workflow guru). | ~15 | -| 6 | `frontend/vite.config.ts` | Opsional: konfigurasi CSP header untuk iframe embedding | ~5 | - -**Total modifikasi: ~265 LOC** - -### 7.3 Detail: EmbedBridge.ts - -```typescript -// frontend/src/services/EmbedBridge.ts - -import { useSimulatorStore } from '../store/useSimulatorStore'; -import { useEditorStore } from '../store/useEditorStore'; - -class EmbedBridge { - private isEmbedded: boolean; - - constructor() { - this.isEmbedded = window.parent !== window; - if (this.isEmbedded) { - window.addEventListener('message', this.onMessage.bind(this)); - } - } - - // Dipanggil setelah Velxio fully loaded - notifyReady() { - this.send('velxio:ready', { version: '1.0' }); - } - - private send(type: string, payload: any = {}) { - if (!this.isEmbedded) return; - window.parent.postMessage({ type, ...payload }, '*'); - } - - private onMessage(event: MessageEvent) { - const { type } = event.data || {}; - if (!type?.startsWith('elemes:')) return; - - switch (type) { - case 'elemes:load_code': - useEditorStore.getState().loadFiles( - event.data.files.map((f: any) => ({ - id: f.name, - name: f.name, - content: f.content, - modified: false - })) - ); - break; - - case 'elemes:load_circuit': - useSimulatorStore.getState().loadCircuit(event.data); - break; - - case 'elemes:get_source_code': - const files = useEditorStore.getState().files; - this.send('velxio:source_code', { - files: files.map(f => ({ name: f.name, content: f.content })) - }); - break; - - case 'elemes:get_serial_log': - const log = useSimulatorStore.getState().getSerialLog(); - this.send('velxio:serial_log', { log }); - break; - - case 'elemes:get_wires': - const wires = useSimulatorStore.getState().wires; - this.send('velxio:wires', { wires }); - break; - - case 'elemes:set_embed_mode': - // Dispatch ke EditorPage via global state atau event - window.dispatchEvent( - new CustomEvent('velxio-embed-mode', { detail: event.data }) - ); - break; - - case 'elemes:compile_and_run': - // Trigger compile + run programmatically - break; - - case 'elemes:stop': - useSimulatorStore.getState().stopSimulation(); - break; - } - } -} - -export const embedBridge = new EmbedBridge(); -``` - -### 7.4 Detail: Export Circuit JSON (Workflow Guru) - -```typescript -// Tambah di EditorToolbar.tsx - -function exportCircuitJSON() { - const { components, wires } = useSimulatorStore.getState(); - const board = /* current selected board FQBN */; - - const circuitData = { - board, - components: components.map(c => ({ - type: c.type, - id: c.id, - x: c.x, - y: c.y, - rotation: c.rotation || 0, - props: c.props || {} - })), - wires: wires.map(w => ({ - start: { componentId: w.start.componentId, pinName: w.start.pinName }, - end: { componentId: w.end.componentId, pinName: w.end.pinName }, - signalType: w.signalType - })) - }; - - const json = JSON.stringify(circuitData, null, 2); - navigator.clipboard.writeText(json); - alert('Circuit JSON copied to clipboard!'); -} -``` - -Guru juga bisa export expected wiring: - -```typescript -function exportExpectedWiring() { - const { wires } = useSimulatorStore.getState(); - const wiringArray = wires.map(w => [ - `${w.start.componentId}:${w.start.pinName}`, - `${w.end.componentId}:${w.end.pinName}` - ]); - const json = JSON.stringify(wiringArray, null, 2); - navigator.clipboard.writeText(json); - alert('Expected wiring JSON copied to clipboard!'); -} -``` - ---- - -## 8. Modifikasi Elemes - -### 8.1 File Baru - -| File | Deskripsi | -|------|-----------| -| `frontend/src/lib/services/velxio-bridge.js` | PostMessage bridge sisi Elemes: kirim command, terima response, evaluasi | - -### 8.2 File yang Dimodifikasi - -| # | File | Modifikasi | -|---|------|-----------| -| 1 | `services/lesson_service.py` | Parse marker baru: `INITIAL_CODE_ARDUINO`, `VELXIO_CIRCUIT`, `EXPECTED_SERIAL_OUTPUT`, `EXPECTED_WIRING` | -| 2 | `routes/lessons.py` | Serve field Arduino/Velxio ke frontend JSON response | -| 3 | `frontend/src/routes/lesson/[slug]/+page.svelte` | Conditional render: jika mode=velxio → render iframe + VelxioBridge, else → existing UI | -| 4 | `podman-compose.yml` | Tambah Velxio service | - -### 8.3 Detail: VelxioBridge.js - -```javascript -// frontend/src/lib/services/velxio-bridge.js - -export class VelxioBridge { - constructor(iframe) { - this.iframe = iframe; - this.pending = {}; // pending request-response - this.serialLog = ''; - this._onReady = null; - - window.addEventListener('message', this._onMessage.bind(this)); - } - - // === Lifecycle === - - onReady(callback) { this._onReady = callback; } - - destroy() { - window.removeEventListener('message', this._onMessage.bind(this)); - } - - // === Commands === - - loadCode(files) { - this._send('elemes:load_code', { files }); - } - - loadCircuit(circuitData) { - this._send('elemes:load_circuit', circuitData); - } - - setEmbedMode(options) { - this._send('elemes:set_embed_mode', options); - } - - // === Evaluation === - - async evaluate(expected) { - const results = {}; - - // 1. Source code → key text - if (expected.key_text) { - const { files } = await this._request('elemes:get_source_code', 'velxio:source_code'); - const allCode = files.map(f => f.content).join('\n'); - results.key_text = expected.key_text.every(kw => allCode.includes(kw)); - } - - // 2. Serial output - if (expected.serial_output) { - const { log } = await this._request('elemes:get_serial_log', 'velxio:serial_log'); - results.serial = this._matchSerial(log, expected.serial_output); - } - - // 3. Wiring - if (expected.wiring) { - const { wires } = await this._request('elemes:get_wires', 'velxio:wires'); - results.wiring = this._matchWiring(wires, expected.wiring); - } - - results.pass = Object.values(results) - .filter(v => typeof v === 'boolean') - .every(Boolean); - - return results; - } - - // === Internal === - - _send(type, payload = {}) { - this.iframe.contentWindow.postMessage({ type, ...payload }, '*'); - } - - _request(sendType, expectType) { - return new Promise((resolve) => { - this.pending[expectType] = resolve; - this._send(sendType); - // Timeout safety - setTimeout(() => { - if (this.pending[expectType]) { - delete this.pending[expectType]; - resolve(null); - } - }, 3000); - }); - } - - _onMessage(event) { - const { type } = event.data || {}; - if (!type?.startsWith('velxio:')) return; - - if (type === 'velxio:ready' && this._onReady) { - this._onReady(); - } - - if (this.pending[type]) { - this.pending[type](event.data); - delete this.pending[type]; - } - } - - _matchSerial(actual, expected) { - const actualLines = actual.trim().split('\n').map(l => l.trim()); - const expectedLines = expected.trim().split('\n').map(l => l.trim()); - let j = 0; - for (const line of actualLines) { - if (j < expectedLines.length && line === expectedLines[j]) j++; - if (j === expectedLines.length) return true; - } - return j === expectedLines.length; - } - - _matchWiring(studentWires, expectedPairs) { - const norm = (a, b) => [a, b].sort().join('↔'); - const studentEdges = new Set( - studentWires.map(w => - norm( - `${w.start.componentId}:${w.start.pinName}`, - `${w.end.componentId}:${w.end.pinName}` - ) - ) - ); - return expectedPairs.every(([a, b]) => studentEdges.has(norm(a, b))); - } -} -``` - ---- - -## 9. Workflow Guru - -### 9.1 Membuat Lesson Arduino + Circuit - -``` -1. Buka Velxio standalone (http://localhost:3080) - -2. Pilih board (Arduino Uno) - -3. Tambah komponen dari Component Picker - - Drag LED ke canvas - - Drag Resistor ke canvas - - Atur posisi - -4. Wiring rangkaian (sebagai contoh jawaban) - - Pin 13 → Resistor → LED → GND - -5. Klik "Export Circuit JSON" (tombol baru di fork) - → JSON component+wires ter-copy ke clipboard - → Ini jadi isi blok ---VELXIO_CIRCUIT--- - -6. Klik "Export Expected Wiring" (tombol baru di fork) - → JSON edges ter-copy ke clipboard - → Ini jadi isi blok ---EXPECTED_WIRING--- - -7. Hapus semua wires (biarkan komponen saja) - -8. Klik "Export Circuit JSON" lagi - → JSON tanpa wires ter-copy ke clipboard - → Ini jadi isi blok ---VELXIO_CIRCUIT--- yang sebenarnya - (komponen ada, wires kosong — siswa yang harus wiring) - -9. Buka file lesson.md - - Paste VELXIO_CIRCUIT (tanpa wires) - - Paste EXPECTED_WIRING (dari langkah 6) - - Tulis INITIAL_CODE_ARDUINO - - Tulis EXPECTED_SERIAL_OUTPUT - - Tulis KEY_TEXT - -10. Jalankan ./elemes.sh generatetoken → update CSV - -11. Test lesson sebagai siswa -``` - -### 9.2 Membuat Lesson Code Only (Tanpa Wiring) - -``` -1. Buka file lesson.md -2. Tulis INITIAL_CODE_ARDUINO (tanpa VELXIO_CIRCUIT) -3. Tulis EXPECTED_SERIAL_OUTPUT -4. Tulis KEY_TEXT -5. Selesai — Velxio akan embed tanpa circuit canvas -``` - ---- - -## 10. Deployment - -### 10.1 Docker Compose - -```yaml -# podman-compose.yml -version: '3.8' - -services: - elemes: - build: ./elemes - ports: - - "3000:3000" - volumes: - - ../content:/app/content:ro - - ../assets:/app/assets:ro - - ../tokens_siswa.csv:/app/tokens.csv - environment: - - VELXIO_URL=http://localhost:3080 - - velxio: - image: ghcr.io/a2nr/velxio-elemes:latest - # atau: build dari fork - # build: ./velxio-fork - ports: - - "3080:80" - environment: - - SECRET_KEY=embed-only-no-auth-needed -``` - -### 10.2 Nginx/Caddy Reverse Proxy (Opsional) - -Jika mau serve kedua service di satu domain: - -``` -https://lms.sekolah.id/ → Elemes -https://lms.sekolah.id/velxio/ → Velxio (iframe src) -``` - -### 10.3 Image Size - -| Container | Estimasi Size | -|-----------|---------------| -| Elemes | ~200MB (Python + gcc) | -| Velxio | ~1.5GB (Node + Python + arduino-cli + avr core) | -| Total | ~1.7GB | - -Velxio image besar karena arduino-cli + platform cores. Ini satu kali download, compile berjalan lokal. - ---- - -## 11. Lisensi - -### 11.1 Velxio: AGPLv3 - -Velxio menggunakan dual licensing: -- AGPLv3 untuk personal/educational/open-source -- Commercial license untuk proprietary/SaaS - -**Implikasi untuk Elemes:** - -Elemes di-deploy secara self-hosted (Podman + Tailscale) untuk lingkungan sekolah, bukan sebagai SaaS publik. Dalam konteks ini: - -- AGPLv3 mengharuskan: jika Velxio dimodifikasi dan di-deploy sebagai network service, source code modifikasi harus tersedia. -- Solusi: **Fork publik** di Gitea/GitHub. Semua modifikasi EmbedBridge sudah open source. - -### 11.2 Library Inti: MIT - -| Library | Lisensi | Dipakai oleh | -|---------|---------|-------------| -| avr8js | MIT | Velxio (internal) | -| wokwi-elements | MIT | Velxio (internal) | -| rp2040js | MIT | Velxio (internal) | - -Tidak ada masalah lisensi tambahan dari library inti. - ---- - -## 12. Roadmap - -### Fase 1: Velxio Fork + Embed Mode (3 hari) - -- [ ] Fork Velxio repository -- [ ] Implementasi `EmbedBridge.ts` -- [ ] Modifikasi `EditorPage.tsx` — embed mode (hide auth/header) -- [ ] Modifikasi `useSimulatorStore.ts` — `loadCircuit()` action -- [ ] Akumulasi serial log di `AVRSimulator.ts` -- [ ] Tombol "Export Circuit JSON" + "Export Expected Wiring" di toolbar -- [ ] Build Docker image fork -- [ ] Test: load circuit via postMessage, ambil serial log, ambil wires - -### Fase 2: Elemes Integration (2 hari) - -- [ ] `VelxioBridge.js` — bridge sisi Elemes -- [ ] `lesson_service.py` — parse marker baru -- [ ] `routes/lessons.py` — serve field Arduino -- [ ] `lesson/[slug]/+page.svelte` — conditional render iframe -- [ ] Evaluasi: serial + key_text + wiring -- [ ] Update `podman-compose.yml` -- [ ] Test end-to-end: lesson load → siswa wiring + coding → submit → evaluate - -### Fase 3: Content + Polish (2 hari) - -- [ ] Buat 3 contoh lesson Arduino (Hello Serial, Blink LED, Button Input) -- [ ] Buat 2 contoh lesson circuit-only (LED circuit, Voltage divider) -- [ ] Update `examples/` folder -- [ ] Update `elemes.sh init` untuk include contoh Arduino -- [ ] Update README guru -- [ ] Test sebagai "guru" dan "siswa" - -### Fase 4: Enhancement (Opsional, Nanti) - -- [ ] Velxio embed: preselect board dari URL param -- [ ] Velxio embed: lock komponen (siswa tidak bisa drag/delete) -- [ ] Velxio embed: disable component picker (siswa tidak bisa tambah komponen baru) -- [ ] Lesson review mode: tampilkan solution wiring overlay -- [ ] Multiple board support per lesson -- [ ] Library management dari Elemes (install Arduino library) - ---- - -## 13. Keputusan (Resolved) - -### 13.1 Pin Naming Convention - -**Keputusan:** Ditentukan setelah Velxio fork berjalan. Buat rangkaian test, wiring manual, inspect `wire.start.pinName` / `wire.end.pinName` di console, lalu tulis konvensi berdasarkan observasi. - -### 13.2 Strict vs Lenient Wiring Evaluation - -**Keputusan: Lenient.** Expected wires harus ada di student wiring, wire berlebih OK. Tanpa `FORBIDDEN_WIRING` untuk tahap awal — bisa ditambah di Fase 4 jika dibutuhkan. - -### 13.3 Embed URL: Same Origin via Tailscale Serve - -**Keputusan: Same-origin.** Velxio di-proxy lewat Tailscale Serve di path `/velxio/`, sehingga satu domain dengan Elemes. Tidak perlu origin validation atau konfigurasi CSP. - -Tambah handler di `config/sinau-c-tail.json`: -```json -"/velxio/": { - "Proxy": "http://velxio:3080/" -} -``` - -Iframe src di Elemes: `