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:
commit
fd6829f645
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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')!;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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:';
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue