fix(espidf): generate .ino forward declarations + link the task-WDT API

The custom sketch.ino.cpp path never ran arduino-cli's ctags prototype
pass, so the universal Arduino idiom (helpers defined after loop()) died
with "'foo' was not declared in this scope" on every ESP32 build. A
conservative generator (skips templates, default args, methods,
function-pointer params) injects prototypes before the first definition
with a #line directive so error line numbers stay put. 8 unit tests.

IDF 5.5 renamed ESP_TASK_WDT to the EN/INIT pair; the v4.4 template's =n
became EN=n, compiling the whole implementation out — every sketch using
esp_task_wdt_add/reset (Firebase's client does internally) failed at
link. The v5 fixup now emits EN=y INIT=n: the API links, the watchdog
never auto-starts under emulation. v4.4 semantics untouched.
This commit is contained in:
David Montero 2026-08-15 08:29:39 +02:00
parent d2648f5a66
commit b58ecd1c37
3 changed files with 273 additions and 8 deletions

View File

@ -195,6 +195,147 @@ def _evict_cold_variants(target_dir: Path, keep: int) -> None:
shutil.rmtree(d, ignore_errors=True)
# ── .ino prototype generation ────────────────────────────────────────────────
# arduino-cli runs ctags over the sketch and injects forward declarations so
# the classic Arduino idiom — helpers defined AFTER setup()/loop() that use
# them — compiles. This pipeline writes sketch.ino.cpp itself and never did,
# so every such sketch died with "'foo' was not declared in this scope".
# This is a conservative ctags stand-in: it only emits prototypes for
# top-level, single-signature-line function definitions and skips anything
# ambiguous (templates, methods, default args, function-pointer params).
_INO_CTRL_KEYWORDS = frozenset((
'if', 'else', 'for', 'while', 'switch', 'do', 'return', 'sizeof',
'catch', 'case', 'new', 'delete', 'throw', 'goto',
))
_INO_FUNC_RE = re.compile(
r'^[ \t]*([A-Za-z_][A-Za-z0-9_:<>,\*&\s]*?[\s\*&])' # return type part
r'([A-Za-z_]\w*)' # function name
r'[ \t]*\(([^;{}()]*)\)' # params (no nesting)
r'[ \t\r\n]*\{',
re.M,
)
def _blank_noncode(source: str) -> str:
"""Return a same-length copy with comments, string/char literals and
preprocessor lines replaced by spaces (newlines preserved), so brace
counting and signature matching never trip on their contents."""
out = list(source)
i, n = 0, len(source)
line_start = True
while i < n:
c = source[i]
if line_start and source[i:].lstrip(' \t')[:1] == '#':
# blank the whole preprocessor line (incl. continuations)
while i < n:
if source[i] == '\n' and source[i - 1] != '\\':
break
if source[i] != '\n':
out[i] = ' '
i += 1
line_start = True
i += 1
continue
line_start = c == '\n'
if c == '/' and i + 1 < n and source[i + 1] == '/':
while i < n and source[i] != '\n':
out[i] = ' '
i += 1
continue
if c == '/' and i + 1 < n and source[i + 1] == '*':
out[i] = out[i + 1] = ' '
i += 2
while i < n and not (source[i] == '*' and i + 1 < n and source[i + 1] == '/'):
if source[i] != '\n':
out[i] = ' '
i += 1
if i < n:
out[i] = out[i + 1] = ' '
i += 2
continue
if c in ('"', "'"):
quote = c
i += 1
while i < n and source[i] != quote:
if source[i] == '\\':
out[i] = ' '
i += 1
if i < n and source[i] != '\n':
out[i] = ' '
i += 1
continue
if source[i] != '\n':
out[i] = ' '
i += 1
i += 1
continue
i += 1
return ''.join(out)
def generate_ino_prototypes(source: str) -> str:
"""Insert forward declarations for top-level function definitions right
before the first one, followed by a #line directive so compiler error
line numbers keep matching the un-inserted text. Returns the source
unchanged when nothing safe to declare is found."""
scan = _blank_noncode(source)
# depth prefix: depth[i] = brace depth just before scan[i]
depths = []
d = 0
for ch in scan:
depths.append(d)
if ch == '{':
d += 1
elif ch == '}':
d = max(0, d - 1)
protos: list[str] = []
seen: set[str] = set()
first_pos: int | None = None
for m in _INO_FUNC_RE.finditer(scan):
sig_start = m.start()
if depths[m.end() - 1] != 0:
continue # method body / nested — the '{' must open at depth 0
rtype = ' '.join(m.group(1).split())
name = m.group(2)
params = ' '.join(m.group(3).split())
if not rtype or name in _INO_CTRL_KEYWORDS:
continue
first_tok = rtype.split()[0] if rtype.split() else ''
if first_tok in _INO_CTRL_KEYWORDS:
continue
if '::' in rtype or '::' in name or 'operator' in rtype:
continue # class methods / operators declare themselves
if '<' in rtype or '>' in rtype or 'template' in rtype.split():
continue # templated heads/returns — never guess those
if '=' in params or '{' in params:
continue # default args would be re-stated -> hard error
# a `template<...>` head on a preceding line makes a bare prototype wrong
lookback = scan[max(0, sig_start - 160):sig_start]
if re.search(r'template\s*<[^<>]*>\s*$', lookback):
continue
proto = f'{rtype} {name}({params});'
if proto not in seen:
seen.add(proto)
protos.append(proto)
if first_pos is None:
first_pos = sig_start
if not protos or first_pos is None:
return source
insert_line = source.count('\n', 0, first_pos) + 1
block = (
'// Velxio: auto-generated forward declarations (.ino semantics)\n'
+ '\n'.join(protos)
+ f'\n#line {insert_line}\n'
)
return source[:first_pos] + block + source[first_pos:]
def _prepare_persistent_project_dir(
idf_target: str,
variant_key: str = 'default',
@ -2490,11 +2631,18 @@ class ESPIDFCompiler:
for line in rendered.splitlines():
if line.startswith(drops):
continue
# IDF 5.0 renamed ESP_TASK_WDT → ESP_TASK_WDT_EN.
# IDF 5.0 split ESP_TASK_WDT into ESP_TASK_WDT_EN (compile the
# implementation) and ESP_TASK_WDT_INIT (auto-start at boot).
# The v4.4 template's `=n` used to become `EN=n`, which compiles
# the API out entirely — every sketch calling esp_task_wdt_add/
# reset (Firebase's ESP32 client does internally) died at link
# with "undefined reference to esp_task_wdt_*". Keep the intent
# (no watchdog running under emulation) while linking the API:
# compile it in, never auto-start it.
if line.startswith('CONFIG_ESP_TASK_WDT='):
line = line.replace(
'CONFIG_ESP_TASK_WDT=', 'CONFIG_ESP_TASK_WDT_EN=', 1
)
lines.append('CONFIG_ESP_TASK_WDT_EN=y')
lines.append('CONFIG_ESP_TASK_WDT_INIT=n')
continue
lines.append(line)
return '\n'.join(lines) + '\n'
@ -3237,6 +3385,10 @@ class ESPIDFCompiler:
'#include "Arduino.h"\n' + compat_include.rstrip('\n'),
1,
)
# arduino-cli injects ctags prototypes; this path must too, or
# helpers defined after loop() fail with "not declared in this
# scope". Applied AFTER the prepends so #line stays accurate.
main_content = generate_ino_prototypes(main_content)
sketch_cpp.write_text(main_content, encoding='utf-8')
# Copy additional files (.h, .cpp)

View File

@ -286,8 +286,10 @@ def test_render_sdkconfig_c6_adapts_to_idf5(compiler: ESPIDFCompiler) -> None:
assert 'CONFIG_SPIRAM' not in text
assert 'CONFIG_ESPTOOLPY_FLASHFREQ_26M' not in text
assert 'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240' not in text
# IDF 5.0 renamed ESP_TASK_WDT → ESP_TASK_WDT_EN.
assert 'CONFIG_ESP_TASK_WDT_EN=n' in text
# IDF 5.0 split ESP_TASK_WDT into _EN (compile) + _INIT (auto-start):
# the API must link (sketches call esp_task_wdt_add) but never auto-run.
assert 'CONFIG_ESP_TASK_WDT_EN=y' in text
assert 'CONFIG_ESP_TASK_WDT_INIT=n' in text
assert 'CONFIG_ESP_TASK_WDT=n' not in text
# The clamped CPU frequency is rendered.
assert 'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y' in text
@ -403,7 +405,8 @@ def test_render_sdkconfig_idf5_arduino_keeps_component_symbols(
assert 'CONFIG_AUTOSTART_ARDUINO=n' in ard
assert 'CONFIG_ARDUHAL_LOG_DEFAULT_LEVEL=0' in ard
assert 'CONFIG_BT_ENABLED=y' in ard
assert 'CONFIG_ESP_TASK_WDT_EN=n' in ard # WDT rename still applies
assert 'CONFIG_ESP_TASK_WDT_EN=y' in ard # WDT split still applies
assert 'CONFIG_ESP_TASK_WDT_INIT=n' in ard
pure = compiler._render_sdkconfig(
opts, _TEMPLATE_DIR, idf_target='esp32', use_idf5=True, arduino_mode=False
@ -435,7 +438,8 @@ def test_render_sdkconfig_idf5_per_target_drops(compiler: ESPIDFCompiler) -> Non
assert 'CONFIG_ESPTOOLPY_FLASHFREQ_26M' in text # kept on esp32
assert 'CONFIG_ARDUHAL_' not in text # no Arduino on v5
assert 'CONFIG_BT_ENABLED' not in text # no Bluedroid on v5
assert 'CONFIG_ESP_TASK_WDT_EN=n' in text # renamed in v5
assert 'CONFIG_ESP_TASK_WDT_EN=y' in text # split in v5
assert 'CONFIG_ESP_TASK_WDT_INIT=n' in text
s3_text = compiler._render_sdkconfig(
compiler._normalize_options(None, idf_target='esp32s3'),

View File

@ -0,0 +1,109 @@
"""Unit tests for the .ino forward-declaration generator (espidf_compiler).
The generator is a ctags stand-in for the ESP32 sketch.ino.cpp path: the
classic Arduino idiom (helpers defined after setup()/loop()) must compile,
while anything ambiguous (methods, templates, default args) is left alone.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.espidf_compiler import generate_ino_prototypes # noqa: E402
def test_helper_after_loop_gets_prototype():
src = (
'#include "Arduino.h"\n'
"void setup() { blinkTwice(); }\n"
"void loop() { blinkTwice(); }\n"
"void blinkTwice() { }\n"
)
out = generate_ino_prototypes(src)
assert "void blinkTwice();" in out
# inserted before the first function definition, after the include
assert out.index("void blinkTwice();") < out.index("void setup() {")
assert out.index('#include "Arduino.h"') < out.index("void blinkTwice();")
def test_line_directive_preserves_error_lines():
src = "void setup() {}\nvoid loop() {}\n"
out = generate_ino_prototypes(src)
# the original first function was on line 1
assert "#line 1\n" in out
def test_control_flow_and_methods_are_not_declared():
src = (
"class Foo {\n"
" public:\n"
" void method() { }\n"
"};\n"
"void Foo2::method2() { }\n"
"void run() {\n"
" if (millis() > 5) { }\n"
" while (true) { }\n"
"}\n"
)
out = generate_ino_prototypes(src)
assert "method();" not in out
assert "method2();" not in out
assert "if(" not in out and "while(" not in out
assert "void run();" in out
def test_default_args_and_templates_skipped():
src = (
"int scaled(int v, int f = 2) { return v * f; }\n"
"template <typename T>\n"
"T biggest(T a, T b) { return a > b ? a : b; }\n"
"void setup() {}\n"
)
out = generate_ino_prototypes(src)
assert "scaled(int v, int f = 2);" not in out
assert "T biggest(T a, T b);" not in out
assert "void setup();" in out
def test_strings_comments_and_preprocessor_ignored():
src = (
'#define WEIRD "void fake() {"\n'
'// void alsoFake() {\n'
'/* void broken(int x) { */\n'
'const char *s = "void inString() {";\n'
"void real() { }\n"
)
out = generate_ino_prototypes(src)
assert "fake();" not in out
assert "alsoFake();" not in out
assert "broken(int x);" not in out
assert "inString();" not in out
assert "void real();" in out
def test_no_functions_returns_unchanged():
src = "int x = 5;\n#define Y 4\n"
assert generate_ino_prototypes(src) == src
def test_multiline_signature_params_kept():
src = (
"void setup() { drawStatus(1, 2); }\n"
"void drawStatus(int a,\n"
" int b) {\n"
"}\n"
)
out = generate_ino_prototypes(src)
assert "void drawStatus(int a, int b);" in out
def test_attribute_and_pointer_returns():
src = (
"void IRAM_ATTR onTimer() { }\n"
"char *nameFor(int idx) { return 0; }\n"
"void setup() { onTimer(); nameFor(1); }\n"
)
out = generate_ino_prototypes(src)
assert "void IRAM_ATTR onTimer();" in out
assert "char *nameFor(int idx);" in out or "char * nameFor(int idx);" in out