diff --git a/backend/app/api/routes/compile.py b/backend/app/api/routes/compile.py index 7440f449..9fca1efd 100644 --- a/backend/app/api/routes/compile.py +++ b/backend/app/api/routes/compile.py @@ -62,6 +62,7 @@ def _job_key( spiffs_files: list[dict] | None = None, libraries: list[str] | None = None, owner_id: str | None = None, + language: str | None = None, ) -> str: """Stable content hash of (files, board, options, spiffs, libraries, owner) used as the deduplication key. @@ -111,6 +112,13 @@ def _job_key( h.update(b"owner:") h.update(owner_id.encode()) h.update(b"\0") + if language and language != "arduino": + # Pure ESP-IDF mode produces a different binary from the same bytes — + # never dedup across language modes. Guarded so 'arduino' (explicit or + # omitted) keeps the historical key. + h.update(b"lang:") + h.update(language.encode()) + h.update(b"\0") return h.hexdigest() @@ -168,6 +176,11 @@ class CompileRequest(BaseModel): # merged only if it's declared here, so a sketch never picks up an unrelated # library from the shared dir. None / omitted = legacy scan-all (unchanged). libraries: list[str] | None = None + # Pure ESP-IDF language mode (issue #139). 'espidf' compiles the files as + # a pure ESP-IDF project: the user provides app_main() and IDF APIs, and + # the arduino-esp32 component is left out of the build entirely. None / + # 'arduino' = classic Arduino sketch compile. ESP32 boards only. + language: str | None = None class CompileResponse(BaseModel): @@ -298,8 +311,27 @@ async def _run_compile( else: allowed_libraries, owner_id = scope + pure_idf = request.language == "espidf" + if pure_idf and not request.board_fqbn.startswith("esp32:"): + return CompileResponse( + success=False, + stdout="", + stderr="", + error="ESP-IDF language mode is only supported on ESP32 boards.", + ) + if pure_idf and not espidf_compiler.available: + return CompileResponse( + success=False, + stdout="", + stderr="", + error="ESP-IDF toolchain is not available on this server.", + ) + if request.board_fqbn.startswith("esp32:") and espidf_compiler.available: - logger.info(f"[compile] Using ESP-IDF for {request.board_fqbn}") + logger.info( + f"[compile] Using ESP-IDF for {request.board_fqbn}" + + (" (pure ESP-IDF mode)" if pure_idf else "") + ) spiffs_dicts = ( [f.model_dump() for f in request.spiffs_files] if request.spiffs_files else None @@ -311,6 +343,7 @@ async def _run_compile( spiffs_files=spiffs_dicts, allowed_libraries=allowed_libraries, owner_id=owner_id, + pure_idf=pure_idf, ) return CompileResponse( success=result["success"], @@ -608,6 +641,7 @@ async def compile_start( files, request.board_fqbn, request.board_options, spiffs_dicts, sorted(allowed_libraries) if allowed_libraries else None, owner_id if allowed_libraries else None, + language=request.language, ) existing_id = JOB_BY_KEY.get(key) if existing_id is not None: diff --git a/backend/app/services/esp-idf-template/main/CMakeLists.txt b/backend/app/services/esp-idf-template/main/CMakeLists.txt index 355cbcac..881ba246 100644 --- a/backend/app/services/esp-idf-template/main/CMakeLists.txt +++ b/backend/app/services/esp-idf-template/main/CMakeLists.txt @@ -1,7 +1,37 @@ +# Pure ESP-IDF language mode (issue #139): the user's files are the main +# component sources — they provide app_main() and use IDF APIs directly. +# espidf_compiler sets VELXIO_PURE_SKETCH (and drops ARDUINO_ESP32_PATH from +# the build env) when the frontend requests language='espidf'. As the `main` +# component, ESP-IDF gives these sources an implicit dependency on every +# other component in the build, so driver/, esp_wifi, esp_http_server etc. +# resolve without an explicit REQUIRES. +if(DEFINED ENV{VELXIO_PURE_SKETCH}) + # No CONFIGURE_DEPENDS — see the comment on the Arduino glob below. + file(GLOB _user_srcs + "${CMAKE_CURRENT_LIST_DIR}/*.cpp" + "${CMAKE_CURRENT_LIST_DIR}/*.c") + + idf_component_register( + SRCS ${_user_srcs} + INCLUDE_DIRS "." + ) + + # Same -Werror relaxations as the Arduino branch: IDF's defaults are + # ruthless for user code pasted from tutorials. + target_compile_options(${COMPONENT_LIB} PRIVATE + -Wno-error=comment + -Wno-error=parentheses + -Wno-error=sign-compare + -Wno-error=narrowing + -Wno-error=write-strings + -Wno-error=missing-field-initializers + -Wno-error=reorder + -Wno-error=unused-variable + -Wno-error=unused-but-set-variable) # If Arduino component is available, use C++ main and link Arduino. # The component name matches the directory basename of ARDUINO_ESP32_PATH # (e.g. "arduino-esp32" when cloned from GitHub). -if(DEFINED ENV{ARDUINO_ESP32_PATH}) +elseif(DEFINED ENV{ARDUINO_ESP32_PATH}) get_filename_component(_arduino_comp_name $ENV{ARDUINO_ESP32_PATH} NAME) # arduino-esp32 ships esp32-camera as a precompiled static lib but does diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 0a33f45f..86fbbf8c 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -502,6 +502,36 @@ class ESPIDFCompiler: """Check if sketch uses WiFi.""" return bool(re.search(r'#include\s*[<"]WiFi\.h[">]|WiFi\.begin\(', code)) + def _detect_idf_wifi_usage(self, code: str) -> bool: + """Pure ESP-IDF mode: does the project use the esp_wifi stack?""" + return bool(re.search(r'#include\s*[<"]esp_wifi\.h[">]|esp_wifi_init\s*\(', code)) + + def _normalize_wifi_for_qemu_idf(self, code: str) -> str: + """ + Pure ESP-IDF variant of _normalize_wifi_for_qemu. IDF projects set + credentials via `#define WIFI_SSID "..."` or wifi_config_t designated + initializers (`.ssid = "..."`, `.password = "..."`). Rewrite the + common literal forms so the firmware associates with the open AP the + QEMU fork broadcasts (_QEMU_WIFI_SSID); anything more dynamic + (strcpy into the struct at runtime) is left alone and simply won't + connect. + """ + code = re.sub( + r'(#define\s+\w*SSID\w*\s+)"[^"]*"', + rf'\1"{_QEMU_WIFI_SSID}"', + code, + flags=re.IGNORECASE, + ) + code = re.sub( + r'(#define\s+\w*(?:PASS|PASSWORD|PSK)\w*\s+)"[^"]*"', + r'\1""', + code, + flags=re.IGNORECASE, + ) + code = re.sub(r'(\.ssid\s*=\s*)"[^"]*"', rf'\1"{_QEMU_WIFI_SSID}"', code) + code = re.sub(r'(\.password\s*=\s*)"[^"]*"', r'\1""', code) + return code + def _detect_webserver_usage(self, code: str) -> bool: """Check if sketch uses WebServer.""" return bool(re.search( @@ -1531,6 +1561,7 @@ class ESPIDFCompiler: idf_target: str, use_idf5: Optional[bool] = None, arduino_mode: Optional[bool] = None, + pure_idf: bool = False, ) -> dict: """Build environment dict for ESP-IDF subprocess. @@ -1539,7 +1570,21 @@ class ESPIDFCompiler: builds fall back to the pinned v4.4.7 install their 2.x core is built for. Everything derived from the IDF root — tools root, python venv, toolchain PATH — follows that choice. + + ``pure_idf`` is the user's LANGUAGE mode (issue #139), a different + axis from ``arduino_mode``: it raises VELXIO_PURE_SKETCH so + main/CMakeLists.txt compiles the user's own app_main() sources + instead of the Arduino sketch wrapper. Both drop + ARDUINO_ESP32_PATH, but a target that merely lacks an arduino-esp32 + core (arduino_mode False) still hands a SKETCH to the legacy + translator — it must not take the pure glob. """ + # A pure-IDF build is never Arduino-as-component, whatever the target + # supports: the template CMake pulls the arduino-esp32 component in as + # soon as ARDUINO_ESP32_PATH exists, so leaving it set would compile + # the Arduino core into a build that has no sketch at all. + if pure_idf: + arduino_mode = False if arduino_mode is None: arduino_mode = self._arduino_supports_target(idf_target) if use_idf5 is None: @@ -1565,6 +1610,10 @@ class ESPIDFCompiler: # can't drag a pure-IDF build (or an unsupported target such as # esp32c6 with no 3.x core) into that mode. env.pop('ARDUINO_ESP32_PATH', None) + if pure_idf: + # Only the explicit language mode takes the pure glob; see the + # docstring for why arduino_mode alone must not. + env['VELXIO_PURE_SKETCH'] = '1' # On Windows, ESP-IDF uses its own Python venv and out-of-PATH tools. # We self-configure so a backend launched from a plain shell (no @@ -2353,6 +2402,7 @@ class ESPIDFCompiler: spiffs_files: list[dict] | None = None, allowed_libraries: set[str] | None = None, owner_id: str | None = None, + pure_idf: bool = False, ) -> dict: """ Compile Arduino sketch using ESP-IDF. @@ -2385,6 +2435,12 @@ class ESPIDFCompiler: only sdkconfig-affecting options are — because the SPIFFS image is rebuilt on every compile anyway and folding it in would burn the C/C++ ninja cache on every file edit. + + pure_idf: pure ESP-IDF language mode (issue #139). The user's files + ARE the IDF main component sources (they provide app_main()); the + arduino-esp32 component is left out of the build entirely and + Arduino library resolution is skipped. Gets its own persistent + build-dir variant via the eff_hash fold below. """ if not self.available: return { @@ -2459,6 +2515,9 @@ class ESPIDFCompiler: arduino_mode = ( self._arduino_supports_target(idf_target) and not self._contains_app_main(_primary_src) + # The explicit ESP-IDF language mode (#139) is the user SAYING + # their code is a pure IDF app; it outranks the app_main sniff. + and not pure_idf ) use_idf5 = self._use_idf5(idf_target, arduino_mode) logger.info( @@ -2487,6 +2546,10 @@ class ESPIDFCompiler: ('m:' + ','.join(sorted(allowed)) + ('|s:' + scope_token if scope_token else '')) if allowed is not None else 'scanall' ) + # Pure ESP-IDF mode gets its own build-dir variant: same bytes + # compiled with vs without the arduino-esp32 component produce + # entirely different cmake graphs + objects. + _lang_token = '|lang:pure' if pure_idf else '' eff_hash = hashlib.sha256( ( options_hash + '|' + _libs_token + '|i:' + _ext_inc_token @@ -2494,6 +2557,7 @@ class ESPIDFCompiler: # never share a configured build/ — the cmake cache and # every object file are tied to the IDF tree. + f'|idf:{5 if use_idf5 else 4}|ard:{int(arduino_mode)}' + + _lang_token ).encode() ).hexdigest()[:12] try: @@ -2505,6 +2569,7 @@ class ESPIDFCompiler: progress_callback, normalized_opts, spiffs_files, allowed_libraries=allowed, libraries_dir=scope_dir, arduino_mode=arduino_mode, use_idf5=use_idf5, + pure_idf=pure_idf, ) with tempfile.TemporaryDirectory(prefix='espidf_') as temp_dir: project_dir = Path(temp_dir) / 'project' @@ -2515,6 +2580,7 @@ class ESPIDFCompiler: progress_callback, normalized_opts, spiffs_files, allowed_libraries=allowed, libraries_dir=scope_dir, arduino_mode=arduino_mode, use_idf5=use_idf5, + pure_idf=pure_idf, ) finally: if scope_dir is not None: @@ -2535,7 +2601,10 @@ class ESPIDFCompiler: return r2 return r - result = await _attempt_safe(allowed_libraries) + # Pure ESP-IDF mode never resolves Arduino libraries — force the + # no-manifest path (the manifest names Arduino libs, which don't + # exist in a pure IDF build). + result = await _attempt_safe(None if pure_idf else allowed_libraries) # Graceful fallback (P2). A manifest-scoped compile that fails because a # header isn't in the manifest (an undeclared / transitive dependency) @@ -2544,7 +2613,7 @@ class ESPIDFCompiler: # manifest can be auto-completed (P2.4) or the user prompted to add the # missing library. The caller holds the per-target lock for this whole # method, so the retry safely reuses the same build dir. - if allowed_libraries is not None and not result.get('success'): + if allowed_libraries is not None and not pure_idf and not result.get('success'): missing = self._missing_library_headers(result) if missing: logger.warning( @@ -2573,6 +2642,7 @@ class ESPIDFCompiler: libraries_dir: Path | None = None, arduino_mode: Optional[bool] = None, use_idf5: Optional[bool] = None, + pure_idf: bool = False, ) -> dict: """Inner compile body: writes sketch + libs into `project_dir`, runs cmake + ninja, merges binaries. Caller is responsible for @@ -2581,13 +2651,18 @@ class ESPIDFCompiler: ``arduino_mode`` / ``use_idf5`` are decided by compile(); the defensive defaults below only serve direct test callers. + + pure_idf: the user's files are the IDF main component sources + (app_main entry point) — no Arduino wrap, no Arduino libraries. + The template's main/CMakeLists.txt picks its pure branch via the + VELXIO_PURE_SKETCH env var set in _build_env. """ # board_options is already normalised by compile() — defensive in # case _compile_in_dir is called directly from a test path. if board_options is None: board_options = self._normalize_options(None, idf_target) if arduino_mode is None: - arduino_mode = self._arduino_supports_target(idf_target) + arduino_mode = self._arduino_supports_target(idf_target) and not pure_idf if use_idf5 is None: use_idf5 = self._use_idf5(idf_target, arduino_mode) @@ -2599,6 +2674,15 @@ class ESPIDFCompiler: board_options, _TEMPLATE_DIR, idf_target, use_idf5=use_idf5, arduino_mode=arduino_mode, ) + if pure_idf: + # Without the arduino-esp32 component the CONFIG_ARDUINO_* / + # CONFIG_AUTOSTART_ARDUINO symbols don't exist. kconfig only + # warns on unknown symbols, but strip them so the generated + # sdkconfig stays honest about what the build contains. + rendered_sdkconfig = '\n'.join( + line for line in rendered_sdkconfig.splitlines() + if not line.startswith(('CONFIG_ARDUINO', 'CONFIG_AUTOSTART_ARDUINO')) + ) + '\n' defaults_path = project_dir / 'sdkconfig.defaults' prev_defaults = ( defaults_path.read_text(encoding='utf-8') if defaults_path.exists() else None @@ -2633,8 +2717,12 @@ class ESPIDFCompiler: # We normalize ANY user SSID → "Velxio-GUEST", enforce channel 6, # and use open auth (empty password) so the connection always works. # Detect WiFi BEFORE normalization so the flag reflects the original sketch. - has_wifi = self._detect_wifi_usage(main_content) - main_content = self._normalize_wifi_for_qemu(main_content) + if pure_idf: + _all_text = '\n'.join(f.get('content', '') for f in files) + has_wifi = self._detect_idf_wifi_usage(_all_text) + else: + has_wifi = self._detect_wifi_usage(main_content) + main_content = self._normalize_wifi_for_qemu(main_content) # Arduino-as-component only when compile() decided so: the core must # support the target (esp32c6 needs an arduino-esp32 3.x core the @@ -2643,7 +2731,37 @@ class ESPIDFCompiler: # Which arduino core this build uses (3.x on v5, 2.x on v4.4). arduino_core_path = self._arduino_path_for(use_idf5) if arduino_mode else '' - if arduino_mode: + if pure_idf: + # Pure ESP-IDF mode (issue #139): the user's files ARE the main + # component sources — app_main() entry point, IDF APIs, compiled + # by the template CMake's VELXIO_PURE_SKETCH glob branch. No + # Arduino wrap, no velxio_compat.h, no Arduino libraries. + main_dir = project_dir / 'main' + # Template / other-mode leftovers must not reach the pure glob + # (main.cpp would drag in setup()/loop() references; a stale + # sketch.ino.cpp would redefine symbols). Deleted BEFORE writing + # so a user file with the same name wins. + for leftover in ('main.c', 'main.cpp', 'sketch.ino.cpp', 'sketch_translated.c'): + (main_dir / leftover).unlink(missing_ok=True) + wrote_any = False + for f in files: + # basename() the client-supplied name so it can't escape main/ + name = PurePosixPath(str(f.get('name') or '').replace('\\', '/')).name + if not name: + continue + content = f.get('content', '') + if has_wifi: + content = self._normalize_wifi_for_qemu_idf(content) + (main_dir / name).write_text(content, encoding='utf-8') + wrote_any = True + if not wrote_any: + return { + 'success': False, + 'error': 'No source files provided.', + 'stdout': '', + 'stderr': '', + } + elif arduino_mode: # Arduino-as-component mode: copy sketch as .cpp sketch_cpp = project_dir / 'main' / 'sketch.ino.cpp' # Prepend Arduino.h, and — ONLY on the 2.x core — velxio_compat.h, @@ -2796,7 +2914,8 @@ class ESPIDFCompiler: build_dir.mkdir(exist_ok=True) env = self._build_env( - idf_target, use_idf5=use_idf5, arduino_mode=arduino_mode + idf_target, use_idf5=use_idf5, arduino_mode=arduino_mode, + pure_idf=pure_idf, ) # Step 1: cmake configure diff --git a/frontend/public/estacion-meteorologica-esp32.gif b/frontend/public/estacion-meteorologica-esp32.gif new file mode 100644 index 00000000..d6a44d8c Binary files /dev/null and b/frontend/public/estacion-meteorologica-esp32.gif differ diff --git a/frontend/public/examples-thumbs/esp32-idf-blink.webp b/frontend/public/examples-thumbs/esp32-idf-blink.webp new file mode 100644 index 00000000..818ab67c Binary files /dev/null and b/frontend/public/examples-thumbs/esp32-idf-blink.webp differ diff --git a/frontend/src/__tests__/breadboard-occupancy.test.ts b/frontend/src/__tests__/breadboard-occupancy.test.ts new file mode 100644 index 00000000..8b7c48ce --- /dev/null +++ b/frontend/src/__tests__/breadboard-occupancy.test.ts @@ -0,0 +1,121 @@ +/** + * Breadboard hole-occupancy rules ("1 pin = 1 cable"): + * - clicking an occupied hole selects the occupying wire (findWireAtHole); + * - a new wire end landing in an occupied hole shifts to the nearest free + * hole of the same 5-hole strip / power rail (resolveFreeHole); + * - seated component legs (invisible bb wires) count as occupancy but are + * never selectable. + * Plus the jumper color policy: rails are red/black, strips draw from the + * jumper palette deterministically per wire id. + */ +import { describe, it, expect } from 'vitest'; +import { + findWireAtHole, + holeIsOccupied, + resolveFreeHole, +} from '../utils/breadboardOccupancy'; +import { + railWireColor, + jumperColorForId, + WIRE_JUMPER_PALETTE, +} from '../utils/wireUtils'; +import type { Wire } from '../types/wire'; + +const wire = ( + id: string, + s: [string, string], + e: [string, string], + bb = false, +): Wire => + ({ + id, + start: { componentId: s[0], pinName: s[1], x: 0, y: 0 }, + end: { componentId: e[0], pinName: e[1], x: 0, y: 0 }, + waypoints: [], + color: '#22c55e', + ...(bb ? { bb: true } : {}), + }) as unknown as Wire; + +// Column 21, top bank: holes a-e — one strip. +const STRIP = ['21t.a', '21t.b', '21t.c', '21t.d', '21t.e']; +const ALL = [...STRIP, '22t.a', '22t.b', 'bp.1', 'bp.2', 'bn.1']; + +describe('findWireAtHole', () => { + it('returns the visible wire whose end sits in the hole', () => { + const w = wire('w1', ['bb1', '21t.a'], ['bb1', '33t.b']); + expect(findWireAtHole([w], 'bb1', '21t.a')?.id).toBe('w1'); + expect(findWireAtHole([w], 'bb1', '33t.b')?.id).toBe('w1'); + expect(findWireAtHole([w], 'bb1', '21t.b')).toBeNull(); + }); + + it('never returns invisible seating wires', () => { + const seat = wire('s1', ['comp1', 'A'], ['bb1', '21t.a'], true); + expect(findWireAtHole([seat], 'bb1', '21t.a')).toBeNull(); + }); + + it('topmost wire wins when stacked (legacy stacked circuits)', () => { + const w1 = wire('w1', ['bb1', '21t.a'], ['bb1', '30t.a']); + const w2 = wire('w2', ['bb1', '21t.a'], ['bb1', '31t.a']); + expect(findWireAtHole([w1, w2], 'bb1', '21t.a')?.id).toBe('w2'); + }); +}); + +describe('holeIsOccupied', () => { + it('counts seated legs (bb wires) as occupancy', () => { + const seat = wire('s1', ['comp1', 'A'], ['bb1', '21t.a'], true); + expect(holeIsOccupied([seat], 'bb1', '21t.a')).toBe(true); + expect(holeIsOccupied([seat], 'bb1', '21t.b')).toBe(false); + }); +}); + +describe('resolveFreeHole', () => { + it('keeps a free hole as-is', () => { + expect(resolveFreeHole('breadboard', 'bb1', '21t.c', [], ALL)).toBe('21t.c'); + }); + + it('shifts to the nearest free hole in the same strip', () => { + const seat = wire('s1', ['comp1', 'A'], ['bb1', '21t.a'], true); + expect(resolveFreeHole('breadboard', 'bb1', '21t.a', [seat], ALL)).toBe('21t.b'); + }); + + it('never shifts across strips', () => { + // Whole 21t strip occupied → falls back to the clicked hole, NOT 22t. + const wires = STRIP.map((p, i) => wire(`w${i}`, ['bb1', p], ['bb1', '40t.a'])); + expect(resolveFreeHole('breadboard', 'bb1', '21t.a', wires, ALL)).toBe('21t.a'); + }); + + it('shifts within a power rail too', () => { + const w = wire('w1', ['bb1', 'bp.1'], ['esp32_1', '3V3']); + expect(resolveFreeHole('breadboard', 'bb1', 'bp.1', [w], ALL)).toBe('bp.2'); + }); + + it('non-breadboard pins pass through', () => { + const w = wire('w1', ['led1', 'A'], ['bb1', '21t.a']); + expect(resolveFreeHole('wokwi-led', 'led1', 'A', [w], ['A', 'C'])).toBe('A'); + }); +}); + +describe('wire color policy', () => { + it('rails are red (+) / black (−)', () => { + expect(railWireColor('tp.5')).toBe('#cc0000'); + expect(railWireColor('bp.12')).toBe('#cc0000'); + expect(railWireColor('tn.5')).toBe('#000000'); + expect(railWireColor('bn.1')).toBe('#000000'); + expect(railWireColor('21t.a')).toBeNull(); + expect(railWireColor('GND')).toBeNull(); + }); + + it('jumper colors are deterministic per id and inside the palette', () => { + const c = jumperColorForId('wire_42'); + expect(jumperColorForId('wire_42')).toBe(c); + expect(WIRE_JUMPER_PALETTE).toContain(c); + // Different ids spread — at least two distinct colors among a handful. + const set = new Set(['a', 'b', 'c', 'd', 'e', 'f'].map(jumperColorForId)); + expect(set.size).toBeGreaterThan(1); + }); + + it('palette reserves red and black for rails', () => { + expect(WIRE_JUMPER_PALETTE).not.toContain('#cc0000'); + expect(WIRE_JUMPER_PALETTE).not.toContain('#000000'); + }); +}); diff --git a/frontend/src/__tests__/esp32-dht22-flow.test.ts b/frontend/src/__tests__/esp32-dht22-flow.test.ts index c0e760ca..cf0abf77 100644 --- a/frontend/src/__tests__/esp32-dht22-flow.test.ts +++ b/frontend/src/__tests__/esp32-dht22-flow.test.ts @@ -337,6 +337,24 @@ describe('Esp32Bridge — sensor WebSocket protocol', () => { ]); }); + it('connect() on a lingering (non-CLOSED) socket force-reconnects instead of no-op', () => { + // Regression: the agent leaves the board running (socket OPEN); the user's + // Run → startBoard → connect() must boot a FRESH session, not silently + // return because a socket already exists. Reproduces "run after the agent + // did nothing until I reloaded". + const firstWs = (bridge as any).socket as MockWebSocket; + expect(firstWs.readyState).toBe(MockWebSocket.OPEN); + + bridge.connect(); // second connect while the first socket is still OPEN + const secondWs = (bridge as any).socket as MockWebSocket; + + expect(firstWs.readyState).toBe(MockWebSocket.CLOSED); // old zombie torn down + expect(secondWs).not.toBe(firstWs); // a brand-new socket + secondWs.open(); + const startMsg = secondWs.messages.find((m) => m.type === 'start_esp32'); + expect(startMsg).toBeDefined(); // fresh boot actually happened + }); + it('sendSensorUpdate sends esp32_sensor_update message', () => { bridge.sendSensorUpdate(4, { temperature: 35, humidity: 70 }); expect(ws.messages).toEqual([ diff --git a/frontend/src/__tests__/issue-139-espidf-mode.test.ts b/frontend/src/__tests__/issue-139-espidf-mode.test.ts new file mode 100644 index 00000000..22bc01a4 --- /dev/null +++ b/frontend/src/__tests__/issue-139-espidf-mode.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for GitHub issue #139 — pure ESP-IDF language mode + * https://github.com/davidmonterocrespo24/velxio/issues/139 + * + * The ESP32 family gains a third entry in the language selector next to + * Arduino C++ and MicroPython: "ESP-IDF". In that mode the user writes a + * plain IDF project (app_main() entry point, FreeRTOS + driver APIs) and + * the backend compiles it through the same ESP-IDF toolchain it already + * uses for Arduino sketches — just without the arduino-esp32 component. + * + * Pure Vitest unit tests — no QEMU, no network, no DOM. + */ + +import { describe, it, expect } from 'vitest'; +import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON } from '../types/board'; +import type { BoardKind } from '../types/board'; +import { useEditorStore } from '../store/useEditorStore'; +import { exampleProjects } from '../data/examples'; + +describe('issue #139 — BOARD_SUPPORTS_ESPIDF', () => { + const ESP32_KINDS: BoardKind[] = [ + 'esp32', + 'esp32-devkit-c-v4', + 'esp32-cam', + 'wemos-lolin32-lite', + 'esp32-s3', + 'xiao-esp32-s3', + 'arduino-nano-esp32', + 'esp32-c3', + 'xiao-esp32-c3', + 'aitewinrobot-esp32c3-supermini', + ]; + + for (const kind of ESP32_KINDS) { + it(`${kind} supports ESP-IDF mode`, () => { + expect(BOARD_SUPPORTS_ESPIDF.has(kind)).toBe(true); + }); + } + + it('non-ESP32 boards do NOT support ESP-IDF mode', () => { + const NON_ESP32: BoardKind[] = [ + 'arduino-uno', + 'raspberry-pi-pico', + 'pi-pico-w', + 'raspberry-pi-3', + 'stm32-bluepill', + 'attiny85', + ]; + for (const kind of NON_ESP32) { + expect(BOARD_SUPPORTS_ESPIDF.has(kind)).toBe(false); + } + }); + + it('every ESP-IDF board also offers the language selector (MicroPython set)', () => { + // The toolbar renders the selector when BOARD_SUPPORTS_MICROPYTHON + // matches and adds the ESP-IDF option when BOARD_SUPPORTS_ESPIDF also + // matches — so every espidf-capable board must be in the outer set or + // the option would be unreachable. + for (const kind of BOARD_SUPPORTS_ESPIDF) { + expect(BOARD_SUPPORTS_MICROPYTHON.has(kind)).toBe(true); + } + }); +}); + +describe('issue #139 — espidf file group defaults', () => { + it("createFileGroup(groupId, 'espidf') seeds main.c with app_main()", () => { + const groupId = 'group-esp32-espidf-test'; + useEditorStore.getState().createFileGroup(groupId, 'espidf'); + const files = useEditorStore.getState().getGroupFiles(groupId); + expect(files).toHaveLength(1); + expect(files[0].name).toBe('main.c'); + expect(files[0].content).toContain('app_main'); + expect(files[0].content).toContain('freertos/FreeRTOS.h'); + expect(files[0].content).toContain('driver/gpio.h'); + // Must NOT be an Arduino sketch — that's the whole point of the mode. + expect(files[0].content).not.toContain('setup()'); + expect(files[0].content).not.toContain('Arduino.h'); + useEditorStore.getState().deleteFileGroup(groupId); + }); + + it("createFileGroup(groupId, 'arduino') still seeds sketch.ino (regression)", () => { + const groupId = 'group-esp32-arduino-test'; + useEditorStore.getState().createFileGroup(groupId, 'arduino'); + const files = useEditorStore.getState().getGroupFiles(groupId); + expect(files[0].name).toBe('sketch.ino'); + useEditorStore.getState().deleteFileGroup(groupId); + }); +}); + +describe('issue #139 — esp32-idf-blink gallery example', () => { + const example = exampleProjects.find((e) => e.id === 'esp32-idf-blink'); + + it('exists and targets an ESP32 board in espidf mode', () => { + expect(example).toBeDefined(); + expect(example?.boardType).toBe('esp32'); + expect(example?.languageMode).toBe('espidf'); + }); + + it('ships a main.c with an app_main() entry point', () => { + const mainFile = example?.files?.find((f) => f.name === 'main.c'); + expect(mainFile).toBeDefined(); + expect(mainFile?.content).toContain('void app_main(void)'); + expect(mainFile?.content).not.toContain('Arduino.h'); + }); +}); diff --git a/frontend/src/__tests__/mcu-edge-pin-name-mapping.test.ts b/frontend/src/__tests__/mcu-edge-pin-name-mapping.test.ts new file mode 100644 index 00000000..4e01ded9 --- /dev/null +++ b/frontend/src/__tests__/mcu-edge-pin-name-mapping.test.ts @@ -0,0 +1,49 @@ +/** + * Regression test for the MCU-edge listener pin-name mismatch. + * + * connectMcuEdgesToService subscribes its per-pin listeners from the + * netlist's pinNetMap, whose keys are the WIRE pin names ('2', 'A0', + * 'GP4'…). It must map those names to PinManager pin numbers with the + * SAME function the netlist collector (collectPinStates) uses. The old + * code reverse-mapped pin NUMBERS to names instead — producing 'GPIO2' + * on ESP32, which never matched the wire's '2' — so any mid-run + * resubscription (triggered e.g. by the gpio_pull that pure ESP-IDF's + * gpio_reset_pin reports) silently detached every listener and froze + * LEDs while the firmware kept toggling the pin. + */ + +import { describe, it, expect } from 'vitest'; +import { pinNameToArduinoPin } from '../simulation/spice/collectPinStates'; + +describe('pinNameToArduinoPin — netlist name to PinManager pin', () => { + it('maps plain numeric ESP32 wire names (the esp32-idf-blink case)', () => { + expect(pinNameToArduinoPin('2', 'esp32')).toBe(2); + expect(pinNameToArduinoPin('4', 'esp32')).toBe(4); + expect(pinNameToArduinoPin('21', 'esp32')).toBe(21); + }); + + it('maps GPIO-prefixed names', () => { + expect(pinNameToArduinoPin('GPIO32', 'esp32')).toBe(32); + expect(pinNameToArduinoPin('GPIO2', 'esp32')).toBe(2); + }); + + it('maps Pico GP names', () => { + expect(pinNameToArduinoPin('GP4', 'raspberry-pi-pico')).toBe(4); + }); + + it('maps Uno analog names', () => { + expect(pinNameToArduinoPin('A0', 'arduino-uno')).toBe(14); + expect(pinNameToArduinoPin('A5', 'arduino-uno')).toBe(19); + }); + + it('maps ATtiny85 port names', () => { + expect(pinNameToArduinoPin('PB3', 'attiny85')).toBe(3); + }); + + it('rejects power pins so no listener attaches to rails', () => { + expect(pinNameToArduinoPin('GND', 'esp32')).toBe(-1); + expect(pinNameToArduinoPin('3V3', 'esp32')).toBe(-1); + expect(pinNameToArduinoPin('GND', 'arduino-uno')).toBe(-1); + expect(pinNameToArduinoPin('5V', 'arduino-uno')).toBe(-1); + }); +}); diff --git a/frontend/src/__tests__/simulation-parts.test.ts b/frontend/src/__tests__/simulation-parts.test.ts index 15d9edc8..45aa3341 100644 --- a/frontend/src/__tests__/simulation-parts.test.ts +++ b/frontend/src/__tests__/simulation-parts.test.ts @@ -557,6 +557,50 @@ describe('7segment — attachEvents', () => { expect(unsubMock).toHaveBeenCalledTimes(8); }); + it('re-attach after digits changes 1→4 subscribes DIG pins (agent build order)', () => { + // The agent adds the display with default digits=1, THEN sets digits=4, + // then wires, compiles (hexEpoch bump → re-attach) and runs. The cached + // per-element state must rebuild for the new digit count, or the + // re-attach keeps subscribing COM.1/COM.2 and the display stays blank + // until a page reload. + const logic = PartSimulationRegistry.get('7segment')!; + const el = makeElement({ values: new Array(8).fill(0), digits: 1 }); + const sim = makeSimulator(); + const subscribed: number[] = []; + sim.pinManager.onPinChange.mockImplementation((pin: number) => { + subscribed.push(pin); + return () => {}; + }); + + // First attach while digits=1 (mid-build): subscribes COM pins only. + const pins1 = pinMap({ 'COM.1': 30, 'COM.2': 31, DIG1: 10, DIG2: 11, DIG3: 12, DIG4: 13, A: 2 }); + const cleanup1 = logic.attachEvents!(el, sim as any, pins1); + expect(subscribed).toContain(30); // COM.1 (single-digit mode) + expect(subscribed).not.toContain(10); // DIG1 not subscribed yet + cleanup1(); + + // Property set later: digits=4. Re-attach (compile/run does this). + (el as any).digits = 4; + subscribed.length = 0; + const callbacks = new Map void>(); + sim.pinManager.onPinChange.mockImplementation( + (pin: number, cb: (pin: number, state: boolean) => void) => { + subscribed.push(pin); + callbacks.set(pin, cb); + return () => {}; + }, + ); + logic.attachEvents!(el, sim as any, pins1); + expect(subscribed).toEqual(expect.arrayContaining([10, 11, 12, 13])); // DIG1..4 + + // And the display actually lights: segment A high + DIG1 enabled. + callbacks.get(2)!(2, true); // segment A high + callbacks.get(10)!(10, true); // DIG1 on → latches segments + const values = (el as any).values as number[]; + expect(values.length).toBe(32); // 4 digits × 8 segments + expect(values[0]).toBe(1); // digit 0, segment A lit + }); + it('CLN pin drives colon + colonValue (attachEvents and onPinStateChange)', () => { const logic = PartSimulationRegistry.get('7segment')!; diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index e9ece5c6..759c38cb 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -7,7 +7,7 @@ import { type VerificationResult } from '../../simulation/verify/circuitVerifier import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore'; import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal'; import type { BoardKind, LanguageMode } from '../../types/board'; -import { BOARD_KIND_FQBN, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board'; +import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board'; import { compileCode } from '../../services/compilation'; import { compileRom, @@ -588,6 +588,9 @@ export const EditorToolbar = ({ // P2.4 — THIS board's declared manifest (compile scope). Per-board so // two boards can use different libraries without clashing. libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null, + // Pure ESP-IDF mode (issue #139): tell the backend to compile the + // user's app_main() sources without the arduino-esp32 component. + language: activeBoard?.languageMode === 'espidf' ? 'espidf' : undefined, }, ); @@ -811,6 +814,19 @@ export const EditorToolbar = ({ // QEMU boards: auto-compile if no firmware available yet if (isQemuBoard) { console.log('[handleRun] QEMU path'); + // Clean restart when the board is already running. Esp32Bridge.connect() + // is a no-op while the socket is non-CLOSED, so startBoard() on a live + // session does NOTHING — and if the backend QEMU session has since died + // but the frontend socket is still zombie (CONNECTING/OPEN/CLOSING), the + // user sees a dead sim that only a page reload fixes. This is the exact + // "el agente terminó, di Run y no funcionó; recargué y sí" report: the + // agent's run_simulation left the board running, so the user's Run + // no-op'd. Stop first (closes the WS), let it settle, then boot fresh — + // mirrors what the MicroPython branch above already does. + if (board?.running) { + stopBoard(activeBoardId); + await new Promise((resolve) => setTimeout(resolve, 300)); + } if (!board?.compiledProgram || codeChangedSinceLastCompile) { console.log('[handleRun] auto-compile + run'); autoRunAfterCompile.current = true; @@ -1037,7 +1053,7 @@ export const EditorToolbar = ({ })), ]); }, - { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null }, + { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined }, ); const resultLogs = parseCompileResult(result, label, boardTarget); @@ -1341,9 +1357,11 @@ export const EditorToolbar = ({ <>
- {/* MicroPython language selector — only when active board supports it. - The board context pill that used to live here was removed: it - duplicated the BoardSelector dropdown elsewhere in the toolbar. */} + {/* Language selector — only when active board supports an + alternative to Arduino C++ (MicroPython on Pico/ESP32 boards, + pure ESP-IDF on the ESP32 family — issue #139). The board + context pill that used to live here was removed: it duplicated + the BoardSelector dropdown elsewhere in the toolbar. */} {activeBoard && BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && ( )} diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 223cf696..614b9234 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -31,7 +31,14 @@ import { SeatedPinMarkers } from './SeatedPinMarkers'; import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import { isBreadboard } from '../../utils/breadboardNets'; -import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../utils/wireUtils'; +import { + autoWireColor, + railWireColor, + WIRE_JUMPER_PALETTE, + WIRE_KEY_COLORS, + expandOrthogonalPoints, +} from '../../utils/wireUtils'; +import { findWireAtHole, resolveFreeHole } from '../../utils/breadboardOccupancy'; import { isAutoVerticalPart, isOverBreadboard, @@ -77,6 +84,16 @@ import './SimulatorCanvas.css'; /** World-units of tolerance for alignment snap (scales with zoom). */ const ALIGN_SNAP_PX = 6; +/** + * Large-bodied display parts whose face should occlude wires when the part is + * seated on a breadboard — the flat canvas has no depth, so bridge wires + * routed to holes UNDER the body would otherwise paint over the readout. + * Matches both the metadata ids (`7segment`, `ssd1306`, …) and the `wokwi-`/ + * `velxio-` element-name variants. Thin two-pin parts are deliberately absent. + */ +const DISPLAY_BODY_METADATA_RE = + /(7segment|led-matrix|max7219|neopixel-matrix|ssd1306|oled|ili9341|lcd1602|lcd2004|led-ring)/i; + /** Long-press duration for touch context menu (ms). */ const LONG_PRESS_MS = 500; @@ -389,6 +406,10 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const [alignmentGuides, setAlignmentGuides] = useState([]); /** Set to true during mouseup if a segment/waypoint drag committed, so onClick can skip selection. */ const segmentDragJustCommittedRef = useRef(false); + // Set when the mouse-up handler already resolved this click into a wire + // selection (click on the breadboard body over a wire) — the bubbled + // canvas onClick must not re-toggle that selection. + const wireSelectJustHandledRef = useRef(false); const wiresRef = useRef(wires); wiresRef.current = wires; @@ -1717,6 +1738,32 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { } else if (component.metadataId === 'custom-chip') { // Custom Chips have their own designer (C editor + chip.json + Compile). setCustomChipComponentId(draggedComponentId); + } else if ( + isBreadboard(component.metadataId) && + (() => { + // A wire crossing the breadboard body sits visually ON TOP of + // it — clicking the wire must select the wire, not bury it + // under the breadboard's full-pin-list property dialog + // (reported: bb→bb wires were unselectable, the hole list + // popped over everything instead). + const world = toWorld(e.clientX, e.clientY); + const nearWire = findWireNearPoint( + wiresRef.current, + world.x, + world.y, + 8 / zoomRef.current, + ); + if (nearWire) { + setSelectedWire(nearWire.id); + // The subsequent canvas onClick would re-hit the same wire + // and toggle it back OFF — suppress that one click. + wireSelectJustHandledRef.current = true; + return true; + } + return false; + })() + ) { + // handled — wire selected instead of opening the dialog } else { setPropertyDialogComponentId(draggedComponentId); setPropertyDialogPosition({ @@ -1911,6 +1958,46 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { setShowPropertyDialog(false); } + // ── Breadboard hole rules: one hole, one wire ────────────────────── + // A hole already holding a visible wire end can't start another wire — + // clicking it SELECTS that wire instead. (Without this, a wire whose + // endpoints sit in holes is impossible to select: the hole overlays + // swallow every click and silently start a new wire.) When a new end + // does land in an occupied hole (seated leg or another wire), it + // shifts to the nearest free hole of the same 5-hole strip / rail — + // electrically identical, visually untangled. + const bbComp = componentsRef.current.find((c) => c.id === componentId); + const isBBHole = !!bbComp && isBreadboard(bbComp.metadataId); + if (isBBHole) { + if (!wireInProgress) { + const occupying = findWireAtHole(wiresRef.current, componentId, pinName); + if (occupying) { + setSelectedWire(occupying.id); + return; + } + } + const el = document.getElementById(componentId) as + | (HTMLElement & { pinInfo?: Array<{ name: string }> }) + | null; + const allNames = (el?.pinInfo ?? []).map((p) => p.name); + const free = resolveFreeHole( + bbComp.metadataId, + componentId, + pinName, + wiresRef.current, + allNames, + ); + if (free !== pinName) { + const rot = Number(bbComp.properties?.rotation) || 0; + const pos = calculatePinPosition(componentId, free, bbComp.x + 6, bbComp.y + 6, rot); + if (pos) { + pinName = free; + x = pos.x; + y = pos.y; + } + } + } + if (wireInProgress) { // Finish wire: the store atomically appends the new wire and clears // `wireInProgress`. Once that's done, we look up the wire it just @@ -1931,8 +2018,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { ); } } else { - // Start wire: auto-detect color from pin name - startWireCreation({ componentId, pinName, x, y }, autoWireColor(pinName)); + // Start wire: rails mandate red (+) / black (−); other breadboard + // holes pick a random jumper color (like a real jumper kit — a board + // full of identical green wires is unreadable); component pins keep + // the name-based auto color (GND → black, VCC → red, else green). + const color = isBBHole + ? (railWireColor(pinName) ?? + WIRE_JUMPER_PALETTE[Math.floor(Math.random() * WIRE_JUMPER_PALETTE.length)]) + : autoWireColor(pinName); + startWireCreation({ componentId, pinName, x, y }, color); } }; @@ -2187,7 +2281,26 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // them — so they always sit at the very back: below boards (z 0), other // components (z 1/2) and wires (z 35). Selection doesn't raise them. const isBreadboard = String(component.metadataId).startsWith('breadboard'); - const groupZIndex = isBreadboard ? -1 : isSelected ? 2 : 1; + // A large-bodied display seated on a breadboard physically sits ON the + // board, so wires routed to holes UNDERNEATH it pass behind its body in + // real life. On the flat canvas those bridge wires (segment strips whose + // holes are literally under the display) otherwise paint OVER the digits + // — the reported "los cables se ven superpuestos y casi ni se ven los + // dígitos". Raise a seated display above the wire layer (z 35) so its + // face occludes the wires crossing it, exactly as the real part would. + // Scoped to occluding display bodies + only when actually seated, so + // thin parts (resistors, LEDs) and free-floating displays are untouched. + const isSeated = (seatedPinsByComponent.get(component.id)?.length ?? 0) > 0; + const occludesWhenSeated = DISPLAY_BODY_METADATA_RE.test(String(component.metadataId)); + const groupZIndex = isBreadboard + ? -1 + : isSeated && occludesWhenSeated + ? isSelected + ? 37 + : 36 + : isSelected + ? 2 + : 1; return ( @@ -2668,6 +2781,12 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { segmentDragJustCommittedRef.current = false; return; } + // Mouse-up already selected a wire under this click (breadboard + // body case) — don't let this bubbled click toggle it back off. + if (wireSelectJustHandledRef.current) { + wireSelectJustHandledRef.current = false; + return; + } // While the simulation runs the canvas is interact-only: a click on // a button must press it (its own handler), not select the wire // underneath it for editing. diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 76f7c0bd..e54ab327 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -64,10 +64,11 @@ export interface ExampleProject { /** Code for single-board examples (ignored when boards[] is set, or when files[] is provided). */ code: string; /** - * Optional language mode for the active board. When 'micropython', loadExample - * switches the board into MicroPython mode before populating files. + * Optional language mode for the active board. When 'micropython' or + * 'espidf', loadExample switches the board into that mode before + * populating files. */ - languageMode?: 'arduino' | 'micropython'; + languageMode?: 'arduino' | 'micropython' | 'espidf'; /** * Optional multi-file payload for single-board examples. When present it * overrides ``code`` — every entry is loaded into the active file group as-is. @@ -217,6 +218,61 @@ void loop() { { id: 'w-scl', start: { componentId: 'esp32', pinName: '22' }, end: { componentId: 'oled', pinName: 'SCL' }, color: '#ff8800' }, ], }, + // ── Pure ESP-IDF (issue #139): user app_main(), no Arduino core. ── + { + id: 'esp32-idf-blink', + title: 'ESP32: Pure ESP-IDF Blink', + description: + 'Blink an LED from a pure ESP-IDF project — app_main(), FreeRTOS delays and the GPIO driver, no Arduino core. Pick "ESP-IDF" in the language selector to write more projects like this.', + category: 'basics', + difficulty: 'intermediate', + boardType: 'esp32', + languageMode: 'espidf', + tags: ['esp32', 'esp-idf', 'freertos', 'gpio'], + code: '', + files: [ + { + name: 'main.c', + content: `// Pure ESP-IDF blink — no Arduino core, just IDF APIs. +// app_main() is the ESP-IDF entry point (instead of setup()/loop()). +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "esp_log.h" + +#define LED_PIN GPIO_NUM_2 + +static const char *TAG = "blink"; + +void app_main(void) +{ + ESP_LOGI(TAG, "ESP-IDF blink starting"); + gpio_reset_pin(LED_PIN); + gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT); + + int level = 0; + while (1) { + level = !level; + gpio_set_level(LED_PIN, level); + ESP_LOGI(TAG, "LED %s", level ? "on" : "off"); + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} +`, + }, + ], + components: [ + // 220R keeps the LED under its 20 mA rating on a 3.3 V GPIO. + { type: 'wokwi-resistor', id: 'r-led', x: 340, y: 120, properties: { value: '220' } }, + { type: 'wokwi-led', id: 'led-ext', x: 470, y: 100, properties: { color: 'red' } }, + ], + wires: [ + { id: 'w-gpio2-r', start: { componentId: 'esp32', pinName: '2' }, end: { componentId: 'r-led', pinName: '1' }, color: '#e74c3c' }, + { id: 'w-r-led', start: { componentId: 'r-led', pinName: '2' }, end: { componentId: 'led-ext', pinName: 'A' }, color: '#e74c3c' }, + { id: 'w-gnd', start: { componentId: 'led-ext', pinName: 'C' }, end: { componentId: 'esp32', pinName: 'GND' }, color: '#2c3e50' }, + ], + }, { id: 'pico-oled-4pin-i2c', title: 'Raspberry Pi Pico: SSD1306 OLED (4-pin I2C)', diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css index 6138ffa3..2deeed57 100644 --- a/frontend/src/pages/LandingPage.css +++ b/frontend/src/pages/LandingPage.css @@ -418,8 +418,12 @@ inset: 0; width: 100%; height: 100%; - object-fit: cover; + /* The hero is an animated GIF of a running circuit — contain (not + cover) so the whole circuit stays visible; the background matches + the GIF's canvas color so the letterbox bands blend in. */ + object-fit: contain; object-position: center; + background: #181618; } /* ── Sections ─────────────────────────────────────────── */ diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index cc4c7e2f..c81ec5ca 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -710,17 +710,13 @@ export const LandingPage: React.FC = () => {

{t('landing.hero.trustLine')}

- - - - {t('landing.hero.imageAlt')} - + {t('landing.hero.imageAlt')}
diff --git a/frontend/src/services/ComponentRegistry.ts b/frontend/src/services/ComponentRegistry.ts index 035411c3..c8552aa4 100644 --- a/frontend/src/services/ComponentRegistry.ts +++ b/frontend/src/services/ComponentRegistry.ts @@ -302,7 +302,15 @@ export class ComponentRegistry { * Get component by ID */ getById(id: string): ComponentMetadata | undefined { - return this.metadata.get(id); + const hit = this.metadata.get(id); + if (hit) return hit; + // Brand-prefix fallback: gallery templates and older agent-loaded + // projects store element tag names ("wokwi-lcd2004") where the registry + // keys by the bare id ("lcd2004"). Without this, such a component sits + // in the store, gets saved and wired — but never renders, so the user + // sees a circuit whose display simply does not exist on screen. + const bare = id.replace(/^(wokwi|velxio)-/, ''); + return bare !== id ? this.metadata.get(bare) : undefined; } /** diff --git a/frontend/src/services/compilation.ts b/frontend/src/services/compilation.ts index fcc445bd..e4458403 100644 --- a/frontend/src/services/compilation.ts +++ b/frontend/src/services/compilation.ts @@ -20,6 +20,10 @@ export interface CompileExtras { // Sent as the ESP-IDF resolution SCOPE; null/omitted = legacy scan-all. // Ignored by the backend for non-ESP32 (arduino-cli) boards. libraries?: string[] | null; + // Pure ESP-IDF mode (issue #139): 'espidf' compiles the files as a pure + // ESP-IDF project (user app_main, no arduino-esp32 component). Omitted / + // undefined = classic Arduino sketch compile. ESP32 boards only. + language?: 'espidf'; } export interface CompileResult { @@ -113,6 +117,7 @@ export async function compileCode( board_options, spiffs_files, libraries, + language: extras?.language ?? null, }, { withCredentials: true, timeout: 30000 }, ); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 75920542..ccf65c04 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -303,7 +303,31 @@ export class Esp32Bridge { } connect(): void { - if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return; + // Force a clean reconnect. The old guard here was + // if (this.socket && readyState !== CLOSED) return; + // which made connect() a SILENT NO-OP whenever a socket lingered in any + // non-CLOSED state (CONNECTING / OPEN / CLOSING). That's exactly the + // "el agente terminó, di Run y no funcionó; recargué y sí" bug: the + // agent's run_simulation left a live/half-dead socket, the backend QEMU + // session had ended, and the user's Run → startBoard → connect() returned + // without doing anything. A page reload worked only because it built a + // fresh bridge. Tearing the zombie socket down and opening a new one to + // the same session key is exactly what that reload does — the backend + // already handles a new WS replacing an existing session (that's why + // reload works), so it's safe to do it without the reload. + if (this.socket) { + try { + this.socket.onopen = null; + this.socket.onmessage = null; + this.socket.onclose = null; + this.socket.onerror = null; + this.socket.close(); + } catch { + /* already closing/closed */ + } + this.socket = null; + this._connected = false; + } const base = API_BASE(); const wsProtocol = base.startsWith('https') ? 'wss:' : 'ws:'; diff --git a/frontend/src/simulation/parts/ChipParts.ts b/frontend/src/simulation/parts/ChipParts.ts index 42ec58a4..639000b9 100644 --- a/frontend/src/simulation/parts/ChipParts.ts +++ b/frontend/src/simulation/parts/ChipParts.ts @@ -94,8 +94,20 @@ function getDigitsCount(element: HTMLElement): number { function get7SegState(element: HTMLElement): SevenSegState { let s = sevenSegState.get(element); + const digits = getDigitsCount(element); + // Rebuild when the digit count CHANGED since the state was created. The + // agent adds the display with the default digits=1 and only then sets + // digits=4 — the stale 1-digit state made every later attachEvents + // subscribe COM.1/COM.2 (which don't exist on a 4-digit part) instead of + // DIG1..DIG4, and since those resolvers "attached", the all-digits-on + // fallback never kicked in either: the display stayed permanently blank + // in-session and only a page reload (fresh element, digits already 4) + // fixed it. + if (s && s.digits !== digits) { + if (s.flushTimer !== null) clearTimeout(s.flushTimer); + s = undefined; + } if (!s) { - const digits = getDigitsCount(element); s = { digits, segments: [0, 0, 0, 0, 0, 0, 0, 0], diff --git a/frontend/src/simulation/spice/collectPinStates.ts b/frontend/src/simulation/spice/collectPinStates.ts index 6981463f..e6f55303 100644 --- a/frontend/src/simulation/spice/collectPinStates.ts +++ b/frontend/src/simulation/spice/collectPinStates.ts @@ -18,18 +18,25 @@ import { BOARD_PIN_GROUPS } from './boardPinGroups'; * Convert a board pin name (e.g. "9", "A0", "GP26", "GPIO32") to the * Arduino-style pin number that PinManager uses internally. * Returns -1 if the name doesn't map to a GPIO pin. + * + * Exported so connectMcuEdgesToService can subscribe its MCU-edge + * listeners under the SAME name→pin mapping the netlist collector uses — + * a reverse mapping that disagrees (e.g. "GPIO2" vs the wire's "2") + * detaches every listener on resubscription and freezes LEDs mid-run. */ -function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number { +export function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number { const group = BOARD_PIN_GROUPS[boardKind] ?? BOARD_PIN_GROUPS.default; if (group.gnd.includes(pinName) || group.vcc_pins.includes(pinName)) return -1; - if (pinName.startsWith('GP')) { - const n = parseInt(pinName.slice(2), 10); - return Number.isFinite(n) ? n : -1; - } + // 'GPIO' must be tested BEFORE 'GP': the GP branch used to shadow it, + // turning 'GPIO32' into parseInt('IO32') = NaN → -1 (dead branch). if (pinName.startsWith('GPIO')) { const n = parseInt(pinName.slice(4), 10); return Number.isFinite(n) ? n : -1; } + if (pinName.startsWith('GP')) { + const n = parseInt(pinName.slice(2), 10); + return Number.isFinite(n) ? n : -1; + } if (/^A\d+$/.test(pinName)) { return 14 + parseInt(pinName.slice(1), 10); } diff --git a/frontend/src/simulation/spice/connectMcuEdgesToService.ts b/frontend/src/simulation/spice/connectMcuEdgesToService.ts index 6a6cc721..9ec9ac6e 100644 --- a/frontend/src/simulation/spice/connectMcuEdgesToService.ts +++ b/frontend/src/simulation/spice/connectMcuEdgesToService.ts @@ -27,10 +27,12 @@ import { useSimulatorStore, getBoardPinManager, } from '../../store/useSimulatorStore'; -import { stm32LinearToPinName } from '../Stm32Bridge'; +import { stm32LinearToPinName, stm32PinNameToLinear } from '../Stm32Bridge'; import { isStm32BoardKind, isPiBoardKind } from '../../types/board'; +import type { BoardKind } from '../../types/board'; import { useElectricalStore } from '../../store/useElectricalStore'; import { BOARD_PIN_GROUPS } from './boardPinGroups'; +import { pinNameToArduinoPin } from './collectPinStates'; import type { CircuitSimulationService } from './CircuitSimulationService'; /** How long edges per pin coalesce. 16 ms ≈ 60 fps, well below any @@ -143,14 +145,41 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): () const wanted = pinsInCircuit(boardId); - // Sweep 0..63 but only attach a listener when the pin name maps - // to one of the wires in the current netlist. Re-subscription - // when the canvas changes happens via `syncBoardSubscriptions` on - // store-level board diffs and on `pinNetMap` updates below. - for (let pin = 0; pin < 64; pin++) { - const pinName = arduinoPinToName(pin, boardKind); - if (!pinName) continue; - if (wanted.size > 0 && !wanted.has(pinName)) continue; + // Resolve which (pin number, pin name) pairs to listen on. + // + // When the netlist has been solved at least once, `wanted` holds the + // EXACT pin names the wires reference ('2', 'A0', 'GP4', 'PC13', …) — + // the same names collectPinStates keyed the V-sources on. Map each of + // those through the SAME name→number function so the listener fires + // on the right PinManager pin AND `handleMcuEdge` receives the name + // whose `v__` source actually exists (fast alterSource + // path, no per-edge rebuild). The previous approach reversed pin + // NUMBERS to names instead ('GPIO2' on ESP32) which never matched the + // wire names, so every resubscription after a mid-run pinNetMap + // change (e.g. a gpio_pull reported by pure ESP-IDF's gpio_reset_pin) + // silently detached all MCU-edge listeners and froze LEDs. + // + // Before the first solve (`wanted` empty) fall back to the historical + // 0..63 sweep with the reverse-mapped names. + const listenPins: Array<{ pin: number; pinName: string }> = []; + if (wanted.size > 0) { + const isStm32 = isStm32BoardKind(boardKind); + for (const pinName of wanted) { + const pin = isStm32 + ? stm32PinNameToLinear(pinName) + : pinNameToArduinoPin(pinName, boardKind as BoardKind); + if (pin < 0) continue; + listenPins.push({ pin, pinName }); + } + } else { + for (let pin = 0; pin < 64; pin++) { + const pinName = arduinoPinToName(pin, boardKind); + if (!pinName) continue; + listenPins.push({ pin, pinName }); + } + } + + for (const { pin, pinName } of listenPins) { const unsub = pm.onPinChange(pin, (_p, state) => { // Suppress digital edges when the pin has active PWM. The OCR-based // PWM duty is converted to a DC-averaged voltage in NetlistBuilder diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index 48d98e62..a43f7603 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -50,6 +50,31 @@ while True: time.sleep(1) `; +// Pure ESP-IDF mode (issue #139): the user's own app_main(), compiled by the +// backend's ESP-IDF toolchain WITHOUT the arduino-esp32 component. GPIO 2 is +// the built-in LED on most ESP32 dev boards. +const DEFAULT_ESPIDF_CONTENT = `// ESP-IDF Blink Example +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" + +#define LED_PIN GPIO_NUM_2 + +void app_main(void) +{ + gpio_reset_pin(LED_PIN); + gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT); + + while (1) { + gpio_set_level(LED_PIN, 1); + vTaskDelay(pdMS_TO_TICKS(1000)); + gpio_set_level(LED_PIN, 0); + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} +`; + const DEFAULT_PY_CONTENT = `import RPi.GPIO as GPIO import time @@ -369,7 +394,10 @@ export const useEditorStore = create((set, get) => ({ let fileName: string; let content: string; const isEsp32 = groupId.includes('esp32'); - if (isMicroPython && isEsp32) { + if (languageMode === 'espidf') { + fileName = 'main.c'; + content = DEFAULT_ESPIDF_CONTENT; + } else if (isMicroPython && isEsp32) { fileName = 'main.py'; content = DEFAULT_ESP32_MICROPYTHON_CONTENT; } else if (isMicroPython) { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index d39fdbe6..c29e19d1 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -19,7 +19,7 @@ import type { I2CDevice } from '../simulation/I2CBusManager'; import type { RP2040I2CDevice } from '../simulation/RP2040Simulator'; import type { Wire, WireInProgress, WireEndpoint } from '../types/wire'; import type { BoardKind, BoardInstance, LanguageMode, WifiStatus } from '../types/board'; -import { BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, isStm32BoardKind } from '../types/board'; +import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, isStm32BoardKind } from '../types/board'; import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../lib/proBoardGate'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; import { useOscilloscopeStore } from './useOscilloscopeStore'; @@ -1780,8 +1780,9 @@ export const useSimulatorStore = create((set, get) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; - // Only allow MicroPython for supported boards + // Only allow MicroPython / ESP-IDF for supported boards if (mode === 'micropython' && !BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return; + if (mode === 'espidf' && !BOARD_SUPPORTS_ESPIDF.has(board.boardKind)) return; // Stop any running simulation if (board.running) get().stopBoard(boardId); diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 96ef65bb..dc69f6d1 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -30,7 +30,7 @@ export type BoardKind = | 'stm32-netduino2' // Netduino 2 (F205, Cortex-M3), QEMU (serial until F205 GPIO wired) | 'attiny85'; // AVR ATtiny85, browser emulation (avr8js) -export type LanguageMode = 'arduino' | 'micropython'; +export type LanguageMode = 'arduino' | 'micropython' | 'espidf'; /** True for every Raspberry Pi backed by the QEMU bridge (Zero, 1, 2, 3, 4, 5). * Excludes the Pico boards (RP2040, browser emulation). */ @@ -63,6 +63,26 @@ export const BOARD_SUPPORTS_MICROPYTHON = new Set([ 'aitewinrobot-esp32c3-supermini', ]); +/** Boards that can run pure ESP-IDF projects (app_main entry point, IDF + * APIs only — no Arduino core). The backend compiles them through the same + * ESP-IDF toolchain it already uses for ESP32 Arduino sketches, just + * without the arduino-esp32 component. ESP32 family only (issue #139). */ +export const BOARD_SUPPORTS_ESPIDF = new Set([ + // ESP32 Xtensa (QEMU bridge) + 'esp32', + 'esp32-devkit-c-v4', + 'esp32-cam', + 'wemos-lolin32-lite', + // ESP32-S3 Xtensa (QEMU bridge) + 'esp32-s3', + 'xiao-esp32-s3', + 'arduino-nano-esp32', + // ESP32-C3 RISC-V (QEMU bridge) + 'esp32-c3', + 'xiao-esp32-c3', + 'aitewinrobot-esp32c3-supermini', +]); + export interface WifiStatus { status: string; // 'initializing' | 'connected' | 'got_ip' | 'disconnected' ssid?: string; @@ -94,7 +114,7 @@ export interface BoardInstance { serialBaudRate: number; serialMonitorOpen: boolean; activeFileGroupId: string; - languageMode: LanguageMode; // 'arduino' (default) or 'micropython' + languageMode: LanguageMode; // 'arduino' (default), 'micropython' or 'espidf' hasWifi?: boolean; // set by compiler — true when sketch uses WiFi wifiStatus?: WifiStatus; bleStatus?: BleStatus; diff --git a/frontend/src/utils/breadboardOccupancy.ts b/frontend/src/utils/breadboardOccupancy.ts new file mode 100644 index 00000000..6d986ec9 --- /dev/null +++ b/frontend/src/utils/breadboardOccupancy.ts @@ -0,0 +1,90 @@ +/** + * Breadboard hole-occupancy rules ("vocabulario"): + * + * - each HOLE (pin) holds at most ONE wire end — a seated component leg + * counts as occupying its hole; + * - a terminal-strip ROW (5 holes) is one net, so two wires landing in + * different holes of the same row are connected — that's the legal way + * to fan out; + * - clicking a hole occupied by a (visible) wire selects that wire + * instead of starting a new one — otherwise wires whose whole length + * lies over hole overlays are impossible to select. + * + * These helpers are pure over the wires list + pin names so they're + * unit-testable without a DOM. + */ + +import type { Wire } from '../types/wire'; +import { breadboardGroupKey } from './breadboardNets'; + +/** + * The topmost VISIBLE wire with an endpoint exactly on (componentId, pinName), + * or null. Invisible `bb` seating wires are never returned — they're not + * user-interactive — but see `holeIsOccupied` for occupancy checks. + */ +export function findWireAtHole( + wires: Wire[], + componentId: string, + pinName: string, +): Wire | null { + for (let i = wires.length - 1; i >= 0; i--) { + const w = wires[i]; + if (w.bb) continue; + if ( + (w.start.componentId === componentId && w.start.pinName === pinName) || + (w.end.componentId === componentId && w.end.pinName === pinName) + ) { + return w; + } + } + return null; +} + +/** + * True when any wire end — including an invisible seating wire (a seated + * component leg) — already lives in this hole. + */ +export function holeIsOccupied(wires: Wire[], componentId: string, pinName: string): boolean { + return wires.some( + (w) => + (w.start.componentId === componentId && w.start.pinName === pinName) || + (w.end.componentId === componentId && w.end.pinName === pinName), + ); +} + +/** + * Resolve the hole a NEW wire end should land in, honouring one-wire-per-hole: + * returns `pinName` itself when free, else the nearest FREE hole in the same + * internal group (5-hole strip / power rail) — electrically identical, so the + * connection intent is preserved. Falls back to the original hole when the + * whole group is occupied (stacking is still electrically traced). + * + * `allPinNames` is the breadboard element's full pin list (from pinInfo); + * "nearest" is by index distance within the group, which matches physical + * adjacency for both strips and rails. + */ +export function resolveFreeHole( + metadataId: string, + componentId: string, + pinName: string, + wires: Wire[], + allPinNames: string[], +): string { + if (!holeIsOccupied(wires, componentId, pinName)) return pinName; + const group = breadboardGroupKey(metadataId, pinName); + if (!group) return pinName; + const clickedIdx = allPinNames.indexOf(pinName); + const candidates = allPinNames + .filter( + (name) => + name !== pinName && + breadboardGroupKey(metadataId, name) === group && + !holeIsOccupied(wires, componentId, name), + ) + .sort( + (a, b) => + Math.abs(allPinNames.indexOf(a) - clickedIdx) - + Math.abs(allPinNames.indexOf(b) - clickedIdx), + ); + return candidates[0] ?? pinName; +} diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index c62ba038..86c81701 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -277,8 +277,11 @@ export async function loadExample( .getState() .boards.find((b) => b.id === liveBoardId); - if (example.languageMode === 'micropython' && liveBoard) { - setBoardLanguageMode(liveBoard.id, 'micropython'); + if ( + (example.languageMode === 'micropython' || example.languageMode === 'espidf') && + liveBoard + ) { + setBoardLanguageMode(liveBoard.id, example.languageMode); } const editorStore = useEditorStore.getState(); diff --git a/frontend/src/utils/wireUtils.ts b/frontend/src/utils/wireUtils.ts index b3b4db5b..82c2093f 100644 --- a/frontend/src/utils/wireUtils.ts +++ b/frontend/src/utils/wireUtils.ts @@ -25,6 +25,48 @@ export const WIRE_KEY_COLORS: Record = { /** Default wire color when no specific signal is detected */ export const DEFAULT_WIRE_COLOR = '#22c55e'; +/** + * Jumper palette for breadboard wires — like a real jumper kit, neighbouring + * wires get visibly different colors instead of a wall of green. Red and + * black are deliberately absent: they're reserved for power-rail wires. + */ +export const WIRE_JUMPER_PALETTE = [ + '#22c55e', // green + '#0000cc', // blue + '#FF8C00', // orange + '#8B00FF', // violet + '#FFD700', // gold + '#00FFFF', // cyan + '#FF00FF', // magenta + '#8B4513', // brown + '#808080', // gray + '#32CD32', // limegreen +] as const; + +/** + * Power-rail hole → mandated wire color: positive rails (tp./bp.) are red, + * negative rails (tn./bn.) are black, like the stripes on a real breadboard. + * Returns null for anything that isn't a rail hole. + */ +export function railWireColor(pinName: string): string | null { + if (/^[tb]p\.\d+$/.test(pinName)) return '#cc0000'; + if (/^[tb]n\.\d+$/.test(pinName)) return '#000000'; + return null; +} + +/** + * Deterministic palette pick for a wire id — stable across reloads so a + * saved project keeps its colors, and different ids spread across the + * palette so adjacent jumpers rarely collide. + */ +export function jumperColorForId(wireId: string): string { + let h = 0; + for (let i = 0; i < wireId.length; i++) { + h = (h * 31 + wireId.charCodeAt(i)) >>> 0; + } + return WIRE_JUMPER_PALETTE[h % WIRE_JUMPER_PALETTE.length]; +} + /** * Automatically determine wire color from the starting pin name. * GND → black, VCC/5V/3.3V/VBUS/VIN → red, everything else → green. diff --git a/test/backend/unit/test_espidf_compiler.py b/test/backend/unit/test_espidf_compiler.py index 2275451f..d286725e 100644 --- a/test/backend/unit/test_espidf_compiler.py +++ b/test/backend/unit/test_espidf_compiler.py @@ -26,6 +26,12 @@ def make_compiler() -> ESPIDFCompiler: comp.idf_path = '' comp.arduino_path = '' comp.has_arduino = False + # The v5 half of the per-compile IDF selection. A bare __new__ object + # skips __init__, so anything reached through _idf_root / _arduino_path_for + # has to be declared here or the call dies on AttributeError. + comp.idf5_path = '' + comp.arduino5_path = '' + comp.has_arduino5 = False return comp diff --git a/test/backend/unit/test_espidf_pure_mode.py b/test/backend/unit/test_espidf_pure_mode.py new file mode 100644 index 00000000..d5541c91 --- /dev/null +++ b/test/backend/unit/test_espidf_pure_mode.py @@ -0,0 +1,142 @@ +""" +Tests for the pure ESP-IDF language mode (issue #139). + +Covers the pieces that don't need the ESP-IDF toolchain: the build env +switch, the QEMU WiFi normalization for IDF-style code, the IDF wifi +detection, and the compile-job dedup key variance. + +Run from the repo root: + python -m pytest test/backend/unit/test_espidf_pure_mode.py -v +""" + +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend')) + +from app.services.espidf_compiler import ESPIDFCompiler, _QEMU_WIFI_SSID +from app.api.routes import compile as compile_module + + +def make_compiler(has_arduino: bool = True) -> ESPIDFCompiler: + comp = ESPIDFCompiler.__new__(ESPIDFCompiler) + comp.idf_path = '/opt/esp-idf' + comp.arduino_path = '/opt/arduino-esp32' if has_arduino else '' + comp.has_arduino = has_arduino + # Per-compile IDF selection: _build_env picks the IDF tree and the + # matching arduino-esp32 core, so a bare __new__ object needs the v5 + # half of that pair too. Only a v4.4 + 2.x core is declared here, which + # keeps these tests on the legacy path and leaves the choice itself to + # the tests that actually cover it. + comp.idf5_path = '' + comp.arduino5_path = '' + comp.has_arduino5 = False + return comp + + +class TestBuildEnvPureMode(unittest.TestCase): + + def setUp(self): + self.comp = make_compiler(has_arduino=True) + + def test_arduino_mode_sets_arduino_path(self): + env = self.comp._build_env('esp32') + self.assertEqual(env.get('ARDUINO_ESP32_PATH'), '/opt/arduino-esp32') + self.assertNotIn('VELXIO_PURE_SKETCH', env) + + def test_pure_mode_drops_arduino_and_flags_pure(self): + env = self.comp._build_env('esp32', pure_idf=True) + self.assertNotIn('ARDUINO_ESP32_PATH', env) + self.assertEqual(env.get('VELXIO_PURE_SKETCH'), '1') + + def test_pure_mode_overrides_inherited_arduino_path(self): + """The uvicorn process env carries ARDUINO_ESP32_PATH in Docker — + pure mode must strip the inherited copy too, or the template CMake + would still pick the Arduino branch.""" + os.environ['ARDUINO_ESP32_PATH'] = '/opt/arduino-esp32' + try: + env = self.comp._build_env('esp32', pure_idf=True) + self.assertNotIn('ARDUINO_ESP32_PATH', env) + finally: + del os.environ['ARDUINO_ESP32_PATH'] + + +class TestIdfWifiDetection(unittest.TestCase): + + def setUp(self): + self.comp = make_compiler() + + def test_detects_esp_wifi_include(self): + self.assertTrue(self.comp._detect_idf_wifi_usage('#include "esp_wifi.h"')) + self.assertTrue(self.comp._detect_idf_wifi_usage('#include ')) + + def test_detects_esp_wifi_init_call(self): + code = 'wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();\nesp_wifi_init(&cfg);' + self.assertTrue(self.comp._detect_idf_wifi_usage(code)) + + def test_ignores_plain_gpio_project(self): + code = '#include "driver/gpio.h"\nvoid app_main(void) {}' + self.assertFalse(self.comp._detect_idf_wifi_usage(code)) + + +class TestIdfWifiNormalization(unittest.TestCase): + + def setUp(self): + self.comp = make_compiler() + + def test_define_ssid_rewritten(self): + code = '#define WIFI_SSID "MyHomeNetwork"\n#define WIFI_PASS "hunter2"' + out = self.comp._normalize_wifi_for_qemu_idf(code) + self.assertIn(f'#define WIFI_SSID "{_QEMU_WIFI_SSID}"', out) + self.assertIn('#define WIFI_PASS ""', out) + + def test_designated_initializers_rewritten(self): + code = ( + 'wifi_config_t wifi_config = {\n' + ' .sta = {\n' + ' .ssid = "MyHomeNetwork",\n' + ' .password = "hunter2",\n' + ' },\n' + '};' + ) + out = self.comp._normalize_wifi_for_qemu_idf(code) + self.assertIn(f'.ssid = "{_QEMU_WIFI_SSID}"', out) + self.assertIn('.password = ""', out) + self.assertNotIn('MyHomeNetwork', out) + self.assertNotIn('hunter2', out) + + def test_non_wifi_code_untouched(self): + code = '#include "driver/gpio.h"\nvoid app_main(void) { }\n' + self.assertEqual(self.comp._normalize_wifi_for_qemu_idf(code), code) + + +class TestJobKeyLanguageVariance(unittest.TestCase): + + FILES = [{'name': 'main.c', 'content': 'void app_main(void) {}'}] + + def test_language_changes_key(self): + k_arduino = compile_module._job_key(self.FILES, 'esp32:esp32:esp32') + k_espidf = compile_module._job_key( + self.FILES, 'esp32:esp32:esp32', language='espidf', + ) + self.assertNotEqual(k_arduino, k_espidf) + + def test_explicit_arduino_keeps_historical_key(self): + """language='arduino' and language=None must hash identically so + pre-feature clients keep dedupping against new-client submissions.""" + k_none = compile_module._job_key(self.FILES, 'esp32:esp32:esp32') + k_arduino = compile_module._job_key( + self.FILES, 'esp32:esp32:esp32', language='arduino', + ) + self.assertEqual(k_none, k_arduino) + + def test_espidf_key_is_stable(self): + k1 = compile_module._job_key(self.FILES, 'esp32:esp32:esp32', language='espidf') + k2 = compile_module._job_key(self.FILES, 'esp32:esp32:esp32', language='espidf') + self.assertEqual(k1, k2) + + +if __name__ == '__main__': + unittest.main()