merge: master (produccion) dentro de v3.2

Las dos ramas habian divergido: master llevaba el modo lenguaje ESP-IDF puro
(#139) y v3.2 la ruta de compilacion IDF v5.5 para toda la familia ESP32 mas
los arreglos de venv/toolchain. Ambas tocaban espidf_compiler.py.

Los dos lados son ejes ORTOGONALES y se conservan enteros:

  - use_idf5 / arduino_mode (v3.2): que arbol IDF usa el build (5.5 vs 4.4) y
    si cabe Arduino-como-componente.
  - pure_idf (master): el modo LENGUAJE que elige el usuario; sus ficheros son
    las fuentes del componente main con su propio app_main().

Resolucion:
  - _build_env acepta los tres. Un build IDF puro fuerza arduino_mode a falso:
    la plantilla CMake mete el componente arduino-esp32 en cuanto existe
    ARDUINO_ESP32_PATH, asi que dejarlo puesto compilaba el core de Arduino en
    un build que no tiene sketch. Lo cazaron los tests de master.
  - VELXIO_PURE_SKETCH solo con pure_idf, nunca con arduino_mode a falso a
    secas: un target sin core arduino-esp32 sigue entregando un SKETCH al
    traductor legacy y no debe tomar la rama del glob puro.
  - La identidad del build-dir suma los dos tokens (|idf:N|ard:N y |lang:pure):
    ningun par de esas combinaciones puede compartir un build/ configurado.
  - La cadena de escritura de fuentes queda pure_idf -> arduino_mode -> legacy.
  - sdkconfig: render de v3.2 (con target/use_idf5) mas el filtrado de simbolos
    CONFIG_ARDUINO* de master cuando el build es puro.

test/backend/unit/test_espidf_compiler.py: los 7 tests que ya estaban rotos en
v3.2 (AttributeError: idf5_path, fixture sin actualizar desde que se anadio la
seleccion de IDF) vuelven a pasar.

Verificado: backend 293 pasan / 0 fallan (v3.2 traia 7 rotos); frontend 2268
pasan / 0 fallan en los dos shards.
This commit is contained in:
David Montero Crespo 2026-07-25 21:52:00 +02:00
commit fd6829f645
29 changed files with 1190 additions and 57 deletions

View File

@ -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:

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -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');
});
});

View File

@ -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([

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -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<number, (pin: number, state: boolean) => 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')!;

View File

@ -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 = ({
<>
<div className="editor-toolbar-wrapper" style={{ position: 'relative' }}>
<div className="editor-toolbar" ref={toolbarRef}>
{/* 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) && (
<select
className="tb-lang-select"
@ -1367,6 +1385,9 @@ export const EditorToolbar = ({
>
<option value="arduino">Arduino C++</option>
<option value="micropython">MicroPython</option>
{BOARD_SUPPORTS_ESPIDF.has(activeBoard.boardKind) && (
<option value="espidf">ESP-IDF</option>
)}
</select>
)}

View File

@ -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<AlignmentGuide[]>([]);
/** 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 (
<React.Fragment key={component.id}>
@ -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.

View File

@ -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 <stdio.h>
#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)',

View File

@ -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 ─────────────────────────────────────────── */

View File

@ -710,17 +710,13 @@ export const LandingPage: React.FC = () => {
<p className="hero-trust-line">{t('landing.hero.trustLine')}</p>
</div>
<div className="hero-right">
<picture>
<source srcSet="/marketing/hero-editor.webp" type="image/webp" />
<source srcSet="/marketing/hero-editor.png" type="image/png" />
<img
src="/marketing/hero-editor.png"
alt={t('landing.hero.imageAlt')}
className="hero-preview-img"
loading="eager"
fetchPriority="high"
/>
</picture>
<img
src="/estacion-meteorologica-esp32.gif"
alt={t('landing.hero.imageAlt')}
className="hero-preview-img"
loading="eager"
fetchPriority="high"
/>
</div>
</section>

View File

@ -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;
}
/**

View File

@ -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 },
);

View File

@ -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:';

View File

@ -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],

View File

@ -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);
}

View File

@ -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_<board>_<name>` 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

View File

@ -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 <stdio.h>
#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<EditorState>((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) {

View File

@ -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<SimulatorState>((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);

View File

@ -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<BoardKind>([
'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<BoardKind>([
// 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;

View File

@ -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;
}

View File

@ -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();

View File

@ -25,6 +25,48 @@ export const WIRE_KEY_COLORS: Record<string, string> = {
/** 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.

View File

@ -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

View File

@ -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 <esp_wifi.h>'))
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()