Step-by-step recipe to put the .NET nanoFramework runtime on a brand-new Waveshare ESP32-S3-Touch-AMOLED-2.06 watch, with every gotcha we hit so the next person doesn't.
- Windows machine (Linux / macOS work too but path examples below are Windows).
nanoffCLI -dotnet tool install -g nanoff(we used 2.5.131; tool reports a newer version on every run, take the update if you see it).- USB-C cable - any data cable; the watch has native USB-OTG off the ESP32-S3, so no special USB-to-UART adapter needed.
The ESP32-S3 has native USB. Depending on which mode it is in, Windows assigns a different COM port number. Both ports are the same physical USB-C connector - the chip enumerates differently in each mode.
| Mode | What is running | Typical USB-CDC class | Example port |
|---|---|---|---|
| Bootloader | ESP32 ROM-resident bootloader (esptool target) | "USB Serial Device" | COM10 |
| Runtime | nanoFramework CLR (or any user firmware) | "USB Serial Device" | COM9 |
The port number can flip every time the firmware reboots into a different state. This is normal, not a fault. Re-run nanoff --listports whenever a Could not find file 'COMx' error appears.
COM9 / COM10 below are examples only - the actual port numbers vary by machine and USB cable (e.g. this watch enumerated on COM3 on one machine). Always confirm with
nanoff --listports; treat every COM9/COM10 in this doc as "the runtime port" / "the bootloader port" respectively, not as literal numbers.
> nanoff --listports
Available COM ports:
COM1
COM10
If you see two ports and only one is the watch, the other is usually a built-in COM1 from the motherboard. Unplug the watch and re-list to identify which one disappears.
Read chip details from ROM bootloader (this works before you flash anything):
nanoff --serialport COM10 --platform esp32 --devicedetails
Expected output for our watch:
Connected to:
ESP32-S3 (ESP32-S3 (QFN56) (revision v0.2))
Features Wi-Fi, BT 5 (LE), Dual Core + LP Core, 240MHz, Embedded PSRAM 8MB (AP_3v3)
Flash size 32MB unknown from GIGADEVICE (manufacturer 0x200 device 0x16409)
PSRAM: 8MB
Crystal 40MHz
MAC <unique to your unit>
Confirm: ESP32-S3R8 family (8MB embedded PSRAM, 32MB external GigaDevice flash). If the chip family is different, stop - you are on the wrong board, do not flash. Other Waveshare AMOLED watches (1.8 / 1.91 / 2.41) and the C6 variant have different chip layouts and require different targets.
If you see:
Error E6002: Couldn't access serial device. Another (nanoFramework) application has exclusive access to the device.
something else is holding the COM port. Most common culprits:
- Visual Studio with the nanoFramework extension installed - the "Device Explorer" pane auto-attaches to any nanoFramework or ESP32 device on a USB-CDC port
- VS Code with a serial monitor open
- A previous
nanoffinvocation that crashed or got stuck - PuTTY / Tera Term / any terminal emulator with the port open
Close the holder, then retry. There is no need to unplug the watch.
For a brand-new watch (factory Waveshare demo firmware, no nanoFramework partition) the canonical command is:
nanoff --target ESP32_S3_BLE --serialport COM10 --update --masserase
Why these flags:
| Flag | Why |
|---|---|
--target ESP32_S3_BLE |
The 2.06 watch needs both BLE and the USB-CDC variant; ESP32_S3 (no BLE) and ESP32_S3_ALL (adds Ethernet we don't have) are wrong |
--update |
"Install or update" - same flag works on a virgin board and on subsequent updates |
--masserase |
Erases all flash before writing. Required on first install because nanoff's "Backup configuration" step fails on a board that has never run nanoFramework (there is no config partition to back up - see gotcha below) |
A successful run finishes with three "Hash of data verified" lines:
Wrote 19488 bytes (12630 compressed) at 0x00000000 in 0.3 seconds (599.6 kbit/s).
Hash of data verified. <-- bootloader
Wrote 1370768 bytes (940308 compressed) at 0x00010000 in 9.8 seconds (1117.4 kbit/s).
Hash of data verified. <-- firmware
Wrote 3072 bytes (136 compressed) at 0x00008000 in 0.1 seconds (236.1 kbit/s).
Hash of data verified. <-- partition table
After all three are verified, the flash is complete, even if you see the cosmetic error described in the next section.
Gotcha - "Backup configuration... Error E4000: Error executing esptool command. (The handle is invalid.)"
On a brand-new board, without --masserase, the flash fails very early with:
Backup configuration...
Error E4000: Error executing esptool command. (The handle is invalid.)
This is nanoff trying to read an existing nanoFramework configuration partition that does not exist on a Waveshare-factory watch, and esptool's USB-CDC handle going invalid mid-step. --masserase skips the backup attempt entirely and proceeds straight to the erase + write path. Use it on first install. Subsequent updates do not need it.
Even with a successful --masserase flash, the LAST line of output is:
Hard resetting via RTS pin...
Error E4000: Error executing esptool command. (The handle is invalid.)
This is cosmetic. The flash is already complete by the time esptool gets to the post-flash hard reset. The reset fails because the watch's USB-CDC re-enumerates between the "send reset" and "read response" steps - the COM port the handle pointed at no longer exists by the time the reset reply arrives. Ignore this error. The runtime is on the chip.
After flashing, the watch reboots into the nanoFramework CLR. The COM port number will change because the runtime exposes a different USB-CDC class identifier than the bootloader.
> nanoff --listports
Available COM ports:
COM1
COM9 <-- was COM10 before flashing, now COM9
To prove the runtime is alive, try the chip-details command on the new port:
nanoff --serialport COM9 --platform esp32 --devicedetails
You will see:
A fatal error occurred: Failed to connect to Espressif device:
Invalid head of packet (0x4E): Possible serial noise or corruption.
This error is the proof. 0x4E is the start byte of a nanoFramework debug-protocol packet ('N'). esptool only knows the ESP32 ROM bootloader protocol and has no idea what nanoFramework's packet stream means, so it bails. Seeing 0x4E means the runtime is up and chatting on USB-CDC. If you saw the bootloader instead (silence, or 0xC0 SLIP framing), the flash failed.
To talk to the running runtime properly, use:
- Visual Studio 2022+ with the .NET nanoFramework extension - Device Explorer pane lists the watch with firmware version
- A serial terminal at 921600 baud, 8N1 - reads
Debug.WriteLineoutput
Once the runtime is on the watch:
- Open
SpawnWear.slnxin Visual Studio 2022 with the nanoFramework extension installed. - The Device Explorer pane should list the watch as
ESP32_S3_BLEon the new COM port. - Right-click the
SpawnWearproject → "Set as Startup Project". - F5 to deploy + run, or Ctrl+F5 to deploy without attaching the debugger.
- The first deploy uploads ~10 small managed assemblies; subsequent deploys are diff-only.
Debug.WriteLine output streams to the Output pane, and to any external serial terminal at 921600 baud on the same COM port (only one process can hold the port at a time).
Once the nanoFramework runtime is on the chip, you do NOT need bootloader mode again to re-deploy the SpawnWear app. This is the inner loop you live in for 99% of development - read it before you start the buttons-into-bootloader dance for every code change.
| What you are doing | Watch state | Tool | COM port | Buttons needed |
|---|---|---|---|---|
| Edit C# + redeploy app (everyday) | Runtime | VS F5 | COM9 | None |
| Update nanoCLR runtime version | Bootloader | nanoff --update |
COM10 | Yes (BOOT during cold boot) |
| Custom nf-interpreter rebuild | Bootloader | esptool / nf-flash-full.bat | COM10 | Yes |
| First-time install on a virgin watch | Bootloader | nanoff --update --masserase |
COM10 | Yes |
Recovering from nanoff --deploy E2002 |
Bootloader | esptool / nf-flash-full.bat | COM10 | Yes |
The everyday workflow:
- Watch is in runtime mode (COM9 visible, screen black or showing whatever the app last drew).
- Open
SpawnWear.slnxin Visual Studio 2022 with the nanoFramework extension installed. - Edit C# code.
- F5 (or Ctrl+F5 to skip the debugger). VS deploys the diff (only the .pe files that actually changed) over the wire protocol on COM9. Typical deploy: 2-5 seconds.
- Set breakpoints, watch the Output window for
Debug.WriteLine, step through code. - Stop debugging, edit, F5 again. Loop.
Cycle time on this loop is ~10 seconds. Cycle time on the bootloader+esptool path is ~90 seconds. Choose accordingly.
The buttons-into-bootloader dance documented above is for runtime image updates - rewriting bootloader.bin + partition-table.bin + nanoCLR.bin at flash addresses 0x0 / 0x8000 / 0x10000. The watch CANNOT receive a runtime update while the CLR is running; the ROM bootloader is a different protocol on a different USB-CDC class identifier.
App deploys are different. The CLR exposes a wire protocol that accepts deploy commands while the runtime is alive. VS uses that path. nanoff --deploy --image SpawnWear.bin (with the watch in runtime mode) uses that same path.
If you find yourself running the bootloader dance to deploy app code changes, stop. F5 in VS is the right answer.
Three legitimate reasons to do the bootloader dance during routine dev:
- The deployed app misbehaves and
nanoff --deploystarts failing E2002. Once the app has booted and panicked the runtime's wire protocol, no soft path can rescue it. Seefeedback_nanoff_deploy_e2002_after_app_runs.mdstyle note: bail to esptool full reflash vianf-flash-full.bat. After the recovery, you are back on the F5-in-VS loop. - You are upgrading the nanoCLR runtime image (chasing a matched-libraries combo, picking up a new ESP-IDF version, switching between custom-built nf-interpreter outputs).
- First-time install on a virgin watch.
That's it. For every other code-iteration scenario the answer is F5 in VS on COM9.
If you write your own debugger client using nanoFramework.Tools.Debugger.Net, device.DebugEngine.GetExecutionMode() may return ProgramExited, DebuggerEnabled even while a Main() is actively running and reachable via VS breakpoint. Do NOT treat this as "user code crashed and exited". Custom probe scripts make poor crash detectors; use VS breakpoints + the Exception popup instead.
(Burned 90 minutes on this 2026-05-03 - polled ProgramExited for 25 seconds across 5 different builds before TJ tried VS and the breakpoint hit on Main line 1 of every build.)
Once the chip is running nanoFramework, nanoff --update cannot reach the bootloader on its own for native-USB ESP32-S3 boards. esptool talks to the ROM bootloader; the runtime exposes a different USB-CDC endpoint and answers its own debug protocol. nanoff trying to chip-detect against the runtime port returns:
A fatal error occurred: Failed to connect to Espressif device:
Invalid head of packet (0x4E): Possible serial noise or corruption.
That 0x4E IS the proof the runtime is alive (it is the start byte of a nanoFramework packet), but esptool cannot interpret it.
Both buttons are on the right edge of the case:
- Top-right button = BOOT (wired to GPIO0).
- Held during chip power-up: ROM enters download mode (USB-Serial-JTAG bootloader).
- Pressed during runtime: a normal user button event (GPIO0 goes low while pressed).
- Bottom-right button = PWR (toggles the watch's main power through the AXP2101 PMIC).
- Tap (short press) when off: power on.
- Hold 6+ seconds when on: power off (AXP2101 cuts every rail).
Waveshare's labeled interface diagram confirms this layout: https://www.waveshare.com/wiki/File:ESP32-S3-Touch-AMOLED-2.06-details-inter.jpg (linked from the "Hardware Description" section of the wiki page)
If you press BOOT while the chip is already booted into runtime, nothing changes - it is just a user-button event. To enter the bootloader you have to power-cycle the chip with BOOT held during the cold boot.
If the buttons feel identical and you cannot tell which is which, confirm by elimination: the one that powers the watch off when held 6+ seconds is PWR; the other is BOOT.
- Power the watch fully off by holding PWR for 6+ seconds. The screen goes black and the COM port disappears entirely from
nanoff --listports. Wait 2-3 seconds. - Hold BOOT (and keep holding it).
- With BOOT still held, tap PWR to power back on. The AXP2101 raises the rails; the ESP32-S3 ROM samples GPIO0 at boot, sees BOOT low, and stays in download mode.
- Wait 2 seconds, release BOOT. The screen stays black (no firmware running) - this is correct.
- Run
nanoff --listports. The bootloader-class COM port appears (typically the lower number, e.g.COM10). - Re-flash:
- Runtime upgrade / downgrade:
nanoff --target ESP32_S3_BLE --serialport COMx --update [--fwversion X.Y.Z.W] - Clean reset:
nanoff --target ESP32_S3_BLE --serialport COMx --update --masserase
- Runtime upgrade / downgrade:
Because of the same USB re-enumeration / RTS-reset issue that produces the cosmetic Error E4000: Hard resetting via RTS pin... The handle is invalid. at the end of a successful flash, the chip sometimes does not automatically boot the freshly-written firmware. It just sits idle on the bootloader-class port (COM10 in our setup).
Symptom: after nanoff reports verified hashes for all three partitions, nanoff --listports still shows the same bootloader port (no flip), and probing it with nanoff --devicedetails succeeds (esptool replies, which means it is still in bootloader, not runtime).
Recovery is a clean PMIC power-cycle:
- Hold PWR 6+ seconds to cut all rails.
- Wait 2-3 seconds.
- Tap PWR briefly to power back on (do NOT hold BOOT - we want a normal boot now, not a download-mode boot).
- The chip boots from flash into nanoFramework. The runtime swings the USB descriptor, the COM port re-enumerates (COM9 in our setup).
- Probe to confirm:
nanoff --serialport COM9 --platform esp32 --devicedetailsshould fail withInvalid head of packet (0x4E)- that error is the proof the runtime is up.
esptool ... --after hard_reset run was tried as a software-only alternative; on this watch it issues the reset but the chip stays in bootloader. The PMIC power-cycle is the only reliable path.
There is no way to permanently brick the chip from software - the ROM bootloader lives in mask ROM and is not flashable. Worst case: full mass-erase + re-flash.
Each nanoFramework runtime image expects specific native checksums for the managed class libraries. Mixing stable class libraries (1.x.x) with a too-new runtime fails at deploy time with:
The connected target has the wrong version for the following assembly(ies):
'System.Net' requires native v100.2.0.11, checksum 0xD82C1452.
Connected target has v100.2.0.12, checksum 0x6DFA71D6.
This is normal, not a packaging bug. When the nanoFramework team bumps a native interop interface they:
- Release a new runtime image with the bumped native checksum
- Release matching managed class libraries (often as
2.0.0-preview.Xwhile the new ABI is stabilizing) - Eventually graduate the previews to a new stable line that targets the bumped checksum
Two valid responses:
A. Pin a runtime that matches the stable libraries you want to use. Try --fwversion X.Y.Z.W with progressively older runtimes until VS deploy stops complaining about the native ABI. The default nanoff --listtargets only shows the most recent few; the full list lives on cloudsmith and you can iterate older builds with explicit --fwversion:
curl -s "https://api.cloudsmith.io/v1/packages/net-nanoframework/nanoframework-images/?query=name:ESP32_S3_BLE&page_size=30" \
| python -c "import json,sys; [print(p['version']) for p in json.load(sys.stdin) if 'ESP32_S3_BLE' in p['name'] and 'UART' not in p['name']]"B. Adopt the 2.0.0-preview class libraries that match the latest runtime's native ABI.
Update packages.config to use 2.0.0-preview.X versions of every runtime-coupled package, and update <HintPath> in the .nfproj. In our testing this did NOT work today - the 2.0.0-preview class libraries actually demand a much newer native ABI (mscorlib v100.22.0.4, System.Net v100.20.0.0, etc.) than ANY currently-released ESP32_S3_BLE runtime image provides. The 2.0 preview is ahead of the runtime side too. Path B is unusable until the v2 runtime releases catch up.
For SpawnWear we take path A. All <package> versions in SpawnWear/packages.config and <HintPath> values in SpawnWear/SpawnWear.nfproj are stable 1.x.x. The matching ESP32_S3_BLE runtime version is being identified by trial.
Each row is a flash + VS-deploy cycle (each cycle requires the buttons-into-bootloader dance from the previous section, plus a power-cycle to runtime, plus a VS deploy attempt). The stable class libraries demand:
mscorlib v100.5.0.24(CoreLibrary 1.17.11)System.Net v100.2.0.11(System.Net 1.11.47)nanoFramework.System.Text v100.0.0.1(System.Text 1.3.42)System.Device.Wifi v100.0.6.5(System.Device.Wifi 1.5.139)nanoFramework.Runtime.Native v100.0.10.0(Runtime.Native 1.7.11)nanoFramework.Device.Bluetooth v100.0.5.0(Device.Bluetooth 1.1.115)nanoFramework.Runtime.Events v100.0.8.0(Runtime.Events 1.11.32)nanoFramework.System.Collections v100.0.2.0(System.Collections 1.5.67)
| Runtime | Result | Native delta seen by VS |
|---|---|---|
| 1.16.0.568 | mismatch | System.Net v100.2.0.12 (need .11), every other assembly also off |
| 1.16.0.567 | mismatch | Same as 568 |
| 1.16.0.563 | SOLVES every assembly except System.Net. | Only System.Net delta remained (v100.2.0.12 vs v100.2.0.11). Other assemblies (mscorlib, Bluetooth, Runtime.Events, Wifi, etc.) all aligned with the stable libs. |
| 1.16.0.563 + System.Net 1.11.50 | MATCH. First successful deploy. | The latest stable System.Net (1.11.50) is built for v100.2.0.12, so swapping just that one package over 1.11.47 closed the last delta - no further runtime steps back were needed. |
Runtime image: ESP32_S3_BLE 1.16.0.563
nanoFramework.CoreLibrary 1.17.11
nanoFramework.Device.Bluetooth 1.1.115
nanoFramework.Runtime.Events 1.11.32
nanoFramework.Runtime.Native 1.7.11
nanoFramework.System.Collections 1.5.67
nanoFramework.System.Device.Wifi 1.5.139
nanoFramework.System.IO.Streams 1.1.96
nanoFramework.System.Net 1.11.50 <-- bumped from 1.11.47 to match runtime native
nanoFramework.System.Text 1.3.42
nanoFramework.System.Threading 1.1.52
When the nanoFramework team graduates the 2.0.0-preview wave to stable AND ships a matching ESP32_S3_BLE runtime image with the bumped natives, this combo can be replaced wholesale.
- List nuget.org versions for the affected package:
curl -s https://api.nuget.org/v3-flatcontainer/nanoframework.system.net/index.json - Find the latest STABLE version (no
-preview, no-alpha). - Look in
_vendor-nanoframework-iot/devices/<DeviceName>/packages.configfor any device whose CI uses that stable version - their tested runtime is implicitly the runtime your stable libraries match. - Use
nanoff --listtargets --platform esp32to see which runtime versions are available; pick one slightly older than the latest if the latest broke compatibility.
When this repo was first scaffolded:
- We flashed
ESP32_S3_BLE-1.16.0.568(the latest at the time). - VS deploy failed with
System.Net requires native v100.2.0.11 / target has v100.2.0.12. - We confirmed
nanoFramework.System.Net 1.11.50(latest stable) ships nativev100.2.0.11, while1.16.0.568runtime expectsv100.2.0.12. - Bumping all stable libraries to latest stable (1.x) did not fix it - the native bump is in 568.
- Re-flashing
1.16.0.567(one version older) brought the runtime back into stable-library compatibility.
This watch has no separate RESET button (see "Buttons on this watch" above). To force ROM bootloader mode, use the BOOT-held-during-PWR-cold-boot procedure documented in "To enter the bootloader (download mode)" above: power fully off by holding PWR 6+ seconds, hold BOOT, tap PWR to power back on, then release BOOT once the screen stays black. Then re-flash.
# 1. List ports, confirm watch is connected
nanoff --listports
# 2. Confirm chip identity (replace COM10 with whatever shows up)
nanoff --serialport COM10 --platform esp32 --devicedetails
# 3. First flash - mass-erase + install nanoFramework runtime
nanoff --target ESP32_S3_BLE --serialport COM10 --update --masserase
# 4. (after reboot, port number changed) Verify - "Invalid head of packet (0x4E)" means success
nanoff --listports
nanoff --serialport COM9 --platform esp32 --devicedetails
# 5. Deploy app from Visual Studio 2022 with nanoFramework extension- Chip: ESP32-S3 QFN56 rev v0.2, 8MB embedded PSRAM, 32MB external GigaDevice flash, 40MHz crystal, MAC
1C:DB:D4:7B:03:0C - Firmware: ESP32_S3_BLE-1.16.0.568 (latest at time of flashing)
- Bootloader port: COM10, runtime port: COM9 (USB re-enumerated between modes)
- Flash time: ~10 seconds for the 1.37 MB compressed firmware payload at 1117 kbit/s on USB-CDC
- Both
--update(no mass-erase) and--update --masserasewere tried; only the latter completed (the first failed at "Backup configuration" - documented above)