feat(wifi): the SSID rewriter stands down when a project defines its own airspace

A private overlay can install window.__velxio_custom_wifi_ssids__ — a
nullary provider naming the WiFi networks the project's access-point
parts broadcast. Three consumers, one seam:

- the compile payload gains custom_wifi_ssids; when non-empty the
  ESP-IDF compiler skips SSID normalization entirely (no literal
  rewrite, no channel forcing) — the sketch connects to the network
  the user actually wrote, and a typo now fails truthfully. The dedup
  key hashes the list so with/without-APs builds never alias.
- the MicroPython network shim redirects unknown SSIDs to the FIRST
  project network instead of the hardcoded Velxio-GUEST, and its tuple
  gains the trailing comma that made a one-network list a string.
- the firmware-upload note lists the project's networks and says "add
  an access point part with this SSID" instead of "rebuild".

No provider installed (OSS build) = exactly the old behavior; the
issue-270 suite stays green (12/12).
This commit is contained in:
David Montero Crespo 2026-08-26 20:42:48 +02:00
parent 68d062f526
commit 7cfeb7399d
5 changed files with 64 additions and 7 deletions

View File

@ -198,6 +198,7 @@ def _job_key(
libraries: list[str] | None = None, libraries: list[str] | None = None,
owner_id: str | None = None, owner_id: str | None = None,
language: str | None = None, language: str | None = None,
custom_wifi_ssids: list[str] | None = None,
) -> str: ) -> str:
"""Stable content hash of (files, board, options, spiffs, libraries, owner) """Stable content hash of (files, board, options, spiffs, libraries, owner)
used as the deduplication key. used as the deduplication key.
@ -232,6 +233,15 @@ def _job_key(
import json import json
h.update(json.dumps(board_options, sort_keys=True).encode()) h.update(json.dumps(board_options, sort_keys=True).encode())
h.update(b"\0") h.update(b"\0")
if custom_wifi_ssids:
# Custom APs suppress the SSID rewrite, so the same source builds
# DIFFERENT bytes with vs without them — the key must see it. Only
# presence matters for the rewrite, but hash the sorted list so a
# rename also invalidates cleanly.
for ssid in sorted(custom_wifi_ssids):
h.update(ssid.encode())
h.update(b"\0")
h.update(b"\0custom-aps\0")
if spiffs_files: if spiffs_files:
for f in sorted(spiffs_files, key=lambda x: x["name"]): for f in sorted(spiffs_files, key=lambda x: x["name"]):
h.update(f["name"].encode()) h.update(f["name"].encode())
@ -315,6 +325,12 @@ class CompileRequest(BaseModel):
# backend deploy — espidf_compiler.compile validates known keys and # backend deploy — espidf_compiler.compile validates known keys and
# ignores the rest. None / missing on non-ESP32 boards. # ignores the rest. None / missing on non-ESP32 boards.
board_options: dict[str, str | int | bool] | None = None board_options: dict[str, str | int | bool] | None = None
# Optional: the SSIDs of the project's custom WiFi access points (the
# velxio-wifi-ap parts on the canvas, overlay feature). When non-empty
# the compiler does NOT rewrite the sketch's SSID literals — the project
# defines its own airspace, so what the user typed is what exists. Empty
# / missing keeps the legacy behavior (rewrite to the built-in networks).
custom_wifi_ssids: list[str] | None = None
# User-uploaded files to bake into the SPIFFS partition (#162). Empty / # User-uploaded files to bake into the SPIFFS partition (#162). Empty /
# None means the SPIFFS region stays blank (current behaviour). # None means the SPIFFS region stays blank (current behaviour).
spiffs_files: list[SpiffsFileBody] | None = None spiffs_files: list[SpiffsFileBody] | None = None
@ -495,6 +511,7 @@ async def _run_compile(
allowed_libraries=allowed_libraries, allowed_libraries=allowed_libraries,
owner_id=owner_id, owner_id=owner_id,
pure_idf=pure_idf, pure_idf=pure_idf,
custom_wifi_ssids=request.custom_wifi_ssids,
) )
return CompileResponse( return CompileResponse(
success=result["success"], success=result["success"],
@ -821,6 +838,7 @@ async def compile_start(
sorted(allowed_libraries) if allowed_libraries else None, sorted(allowed_libraries) if allowed_libraries else None,
owner_id if allowed_libraries else None, owner_id if allowed_libraries else None,
language=request.language, language=request.language,
custom_wifi_ssids=request.custom_wifi_ssids,
) )
existing_id = JOB_BY_KEY.get(key) existing_id = JOB_BY_KEY.get(key)
if existing_id is not None: if existing_id is not None:

View File

@ -3569,10 +3569,16 @@ class ESPIDFCompiler:
allowed_libraries: set[str] | None = None, allowed_libraries: set[str] | None = None,
owner_id: str | None = None, owner_id: str | None = None,
pure_idf: bool = False, pure_idf: bool = False,
custom_wifi_ssids: list[str] | None = None,
) -> dict: ) -> dict:
""" """
Compile Arduino sketch using ESP-IDF. Compile Arduino sketch using ESP-IDF.
`custom_wifi_ssids` non-empty = the project carries its own access
point parts: the WiFi normalization below stands down entirely (no
SSID rewrite, no channel forcing) — the user's airspace is exactly
what they typed, typos included, which is now the truthful outcome.
Returns dict compatible with ArduinoCLIService.compile(): Returns dict compatible with ArduinoCLIService.compile():
success, binary_content (base64), binary_type, stdout, stderr, error success, binary_content (base64), binary_type, stdout, stderr, error
@ -3967,6 +3973,7 @@ class ESPIDFCompiler:
# Normalization still rewrites only the entry sketch: it edits # Normalization still rewrites only the entry sketch: it edits
# string literals in place, and the SSID that matters is the one # string literals in place, and the SSID that matters is the one
# WiFi.begin() is called with. # WiFi.begin() is called with.
if not custom_wifi_ssids:
main_content = self._normalize_wifi_for_qemu(main_content) main_content = self._normalize_wifi_for_qemu(main_content)
# Arduino-as-component only when compile() decided so: the core must # Arduino-as-component only when compile() decided so: the core must
@ -3996,6 +4003,7 @@ class ESPIDFCompiler:
continue continue
content = f.get('content', '') content = f.get('content', '')
if has_wifi: if has_wifi:
if not custom_wifi_ssids:
content = self._normalize_wifi_for_qemu_idf(content) content = self._normalize_wifi_for_qemu_idf(content)
(main_dir / name).write_text(content, encoding='utf-8') (main_dir / name).write_text(content, encoding='utf-8')
wrote_any = True wrote_any = True

View File

@ -131,6 +131,13 @@ export async function compileCode(
: null; : null;
// P2.3 — library manifest (resolution scope). null = legacy scan-all. // P2.3 — library manifest (resolution scope). null = legacy scan-all.
const libraries = extras?.libraries && extras.libraries.length ? extras.libraries : null; const libraries = extras?.libraries && extras.libraries.length ? extras.libraries : null;
// Custom WiFi access points (overlay feature): when the project carries its
// own AP parts, their SSIDs ride along and the backend's SSID rewriter
// stands down — the sketch connects to the network the user actually wrote.
// OSS builds have no provider installed → null → legacy rewrite.
const customWifiSsids =
(window as { __velxio_custom_wifi_ssids__?: () => string[] | null })
.__velxio_custom_wifi_ssids__?.() ?? null;
let jobId: string; let jobId: string;
try { try {
@ -147,6 +154,7 @@ export async function compileCode(
libraries, libraries,
language: extras?.language ?? null, language: extras?.language ?? null,
initiated_by: extras?.initiatedBy ?? null, initiated_by: extras?.initiatedBy ?? null,
custom_wifi_ssids: customWifiSsids,
}, },
{ withCredentials: true, timeout: 30000 }, { withCredentials: true, timeout: 30000 },
); );

View File

@ -2131,17 +2131,25 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// reviving the fake stub for them. Known SSIDs pass through // reviving the fake stub for them. Known SSIDs pass through
// untouched, and the rewrite is announced on serial so nobody // untouched, and the rewrite is announced on serial so nobody
// debugs a connection they didn't make. // debugs a connection they didn't make.
// Custom AP parts (overlay-provided window seam) replace the
// network list the shim knows about — and the redirect target
// becomes the FIRST project network instead of Velxio-GUEST.
// Without the provider (OSS build) the classic four stand.
const customSsids =
(window as { __velxio_custom_wifi_ssids__?: () => string[] | null })
.__velxio_custom_wifi_ssids__?.() ?? null;
const shimSsids: readonly string[] = customSsids ?? EMULATED_WIFI_SSIDS;
const networkStub = wifiOn ? [ const networkStub = wifiOn ? [
'import network as _vlx_net', 'import network as _vlx_net',
'import time as _vlx_time', 'import time as _vlx_time',
`_VLX_SSIDS = (${EMULATED_WIFI_SSIDS.map((s) => JSON.stringify(s)).join(', ')})`, `_VLX_SSIDS = (${shimSsids.map((s) => JSON.stringify(s)).join(', ')},)`,
'class _VlxWLAN:', 'class _VlxWLAN:',
' def __init__(self, *a, **k):', ' def __init__(self, *a, **k):',
' self._w = _vlx_net.WLAN(*a, **k)', ' self._w = _vlx_net.WLAN(*a, **k)',
' def connect(self, ssid=None, key=None, **kw):', ' def connect(self, ssid=None, key=None, **kw):',
' if ssid is not None and ssid not in _VLX_SSIDS:', ' if ssid is not None and ssid not in _VLX_SSIDS:',
' print("[velxio] SSID %r is not part of the emulated network; connecting to \'Velxio-GUEST\' instead" % ssid)', ` print("[velxio] SSID %r is not part of the emulated network; connecting to ${JSON.stringify(shimSsids[0]).replace(/"/g, "'")} instead" % ssid)`,
' ssid, key = "Velxio-GUEST", ""', ` ssid, key = ${JSON.stringify(shimSsids[0])}, ""`,
' if ssid is None:', ' if ssid is None:',
' return self._w.connect()', ' return self._w.connect()',
' return self._w.connect(ssid, key, **kw)', ' return self._w.connect(ssid, key, **kw)',

View File

@ -52,11 +52,26 @@ export async function wifiSsidNoteFor(file: File): Promise<string | null> {
return null; // unreadable is the caller's problem, not ours to report twice return null; // unreadable is the caller's problem, not ours to report twice
} }
if (!WIFI_MARKERS.some((m) => containsAscii(bytes, m))) return null; if (!WIFI_MARKERS.some((m) => containsAscii(bytes, m))) return null;
if (EMULATED_WIFI_SSIDS.some((s) => containsAscii(bytes, s))) return null; // A project with its own AP parts (overlay window seam) defines the
// airspace; without them the classic four stand. Either way, firmware
// that names one of the on-air networks needs no note.
const custom =
(window as { __velxio_custom_wifi_ssids__?: () => string[] | null })
.__velxio_custom_wifi_ssids__?.() ?? null;
const onAir: readonly string[] = custom ?? EMULATED_WIFI_SSIDS;
if (onAir.some((s) => containsAscii(bytes, s))) return null;
if (custom) {
return (
'This firmware uses WiFi but does not name any network in this project. ' +
`The project's access points broadcast: ${custom.join(', ')}. ` +
'Add a WiFi Access Point part with the SSID the firmware expects and it will connect.'
);
}
return ( return (
'This firmware uses WiFi but does not name any network the emulator has. ' + 'This firmware uses WiFi but does not name any network the emulator has. ' +
`The emulated radio broadcasts only: ${EMULATED_WIFI_SSIDS.join(', ')} — all open, ` + `The emulated radio broadcasts only: ${EMULATED_WIFI_SSIDS.join(', ')} — all open, ` +
'no password. Rebuild it with one of those as the SSID and it will connect. ' + 'no password. Rebuild it with one of those as the SSID — or add a WiFi Access ' +
'Point part with your SSID (paid feature) — and it will connect. ' +
'(Sketches compiled here are rewritten automatically; an uploaded binary cannot be.)' '(Sketches compiled here are rewritten automatically; an uploaded binary cannot be.)'
); );
} }