diff --git a/.github/scripts/on-release.sh b/.github/scripts/on-release.sh
index 0b0c48fc82c..38f58e0b4be 100755
--- a/.github/scripts/on-release.sh
+++ b/.github/scripts/on-release.sh
@@ -36,6 +36,12 @@ echo "Event: $GITHUB_EVENT_NAME, Repo: $GITHUB_REPOSITORY, Path: $GITHUB_WORKSPA
 echo "Action: $action, Branch: $RELEASE_BRANCH, ID: $RELEASE_ID"
 echo "Tag: $RELEASE_TAG, Draft: $draft, Pre-Release: $RELEASE_PRE"
 
+# Try extracting something like a JSON with a "boards" array/element and "vendor" fields
+BOARDS=`echo $RELEASE_BODY | grep -Pzo '(?s){.*}' | jq -r '.boards[]? // .boards? // empty' | xargs echo -n 2>/dev/null`
+VENDOR=`echo $RELEASE_BODY | grep -Pzo '(?s){.*}' | jq -r '.vendor? // empty' | xargs echo -n 2>/dev/null`
+if ! [ -z "${BOARDS}" ]; then echo "Releasing board(s): $BOARDS" ; fi
+if ! [ -z "${VENDOR}" ]; then echo "Setting packager: $VENDOR" ; fi
+
 function get_file_size(){
     local file="$1"
     if [[ "$OSTYPE" == "darwin"* ]]; then
@@ -171,12 +177,26 @@ mkdir -p "$PKG_DIR/tools"
 
 # Copy all core files to the package folder
 echo "Copying files for packaging ..."
-cp -f  "$GITHUB_WORKSPACE/boards.txt"                       "$PKG_DIR/"
+if [ -z "${BOARDS}" ]; then
+    # Copy all variants
+    cp -f  "$GITHUB_WORKSPACE/boards.txt"                   "$PKG_DIR/"
+    cp -Rf "$GITHUB_WORKSPACE/variants"                     "$PKG_DIR/"
+else
+    # Remove all entries not starting with any board code or "menu." from boards.txt
+    cat "$GITHUB_WORKSPACE/boards.txt" | grep "^menu\."         >  "$PKG_DIR/boards.txt"
+    for board in ${BOARDS} ; do
+        cat "$GITHUB_WORKSPACE/boards.txt" | grep "^${board}\." >> "$PKG_DIR/boards.txt"
+    done
+    # Copy only relevant variant files
+    mkdir "$PKG_DIR/variants/"
+    for variant in `cat ${PKG_DIR}/boards.txt | grep "\.variant=" | cut -d= -f2` ; do
+        cp -Rf "$GITHUB_WORKSPACE/variants/${variant}"      "$PKG_DIR/variants/"
+    done
+fi
 cp -f  "$GITHUB_WORKSPACE/package.json"                     "$PKG_DIR/"
 cp -f  "$GITHUB_WORKSPACE/programmers.txt"                  "$PKG_DIR/"
 cp -Rf "$GITHUB_WORKSPACE/cores"                            "$PKG_DIR/"
 cp -Rf "$GITHUB_WORKSPACE/libraries"                        "$PKG_DIR/"
-cp -Rf "$GITHUB_WORKSPACE/variants"                         "$PKG_DIR/"
 cp -f  "$GITHUB_WORKSPACE/tools/espota.exe"                 "$PKG_DIR/tools/"
 cp -f  "$GITHUB_WORKSPACE/tools/espota.py"                  "$PKG_DIR/tools/"
 cp -f  "$GITHUB_WORKSPACE/tools/gen_esp32part.py"           "$PKG_DIR/tools/"
@@ -209,6 +229,11 @@ sed 's/debug.server.openocd.scripts_dir={runtime.platform.path}\/tools\/openocd-
 sed 's/debug.server.openocd.scripts_dir.windows={runtime.platform.path}\\tools\\openocd-esp32\\share\\openocd\\scripts\\/debug.server.openocd.scripts_dir.windows=\{runtime.tools.openocd-esp32.path\}\\share\\openocd\\scripts\\/g' \
  > "$PKG_DIR/platform.txt"
 
+if ! [ -z ${VENDOR} ]; then
+    # Append vendor name to platform.txt to create a separate section
+    sed -i  "/^name=.*/s/$/ ($VENDOR)/" "$PKG_DIR/platform.txt"
+fi
+
 # Add header with version information
 echo "Generating core_version.h ..."
 ver_define=`echo $RELEASE_TAG | tr "[:lower:].\055" "[:upper:]_"`
diff --git a/boards.txt b/boards.txt
index c2415668a52..c6f61851cf2 100644
--- a/boards.txt
+++ b/boards.txt
@@ -24112,3 +24112,54 @@ nebulas3.menu.EraseFlash.all=Enabled
 nebulas3.menu.EraseFlash.all.upload.erase_cmd=-e
 
 ##############################################################
+
+nano_nora.name=Arduino Nano ESP32
+nano_nora.vid.0=0x2341
+nano_nora.pid.0=0x0070
+nano_nora.upload_port.0.vid=0x2341
+nano_nora.upload_port.0.pid=0x0070
+
+nano_nora.bootloader.tool=esptool_py
+nano_nora.bootloader.tool.default=esptool_py
+
+nano_nora.upload.tool=dfu-util
+nano_nora.upload.tool.default=dfu-util
+nano_nora.upload.tool.network=esp_ota
+nano_nora.upload.protocol=serial
+nano_nora.upload.maximum_size=3145728
+nano_nora.upload.maximum_data_size=327680
+nano_nora.upload.use_1200bps_touch=false
+nano_nora.upload.wait_for_upload_port=false
+
+nano_nora.serial.disableDTR=false
+nano_nora.serial.disableRTS=false
+
+nano_nora.build.tarch=xtensa
+nano_nora.build.bootloader_addr=0x0
+nano_nora.build.target=esp32s3
+nano_nora.build.mcu=esp32s3
+nano_nora.build.core=esp32
+nano_nora.build.variant=arduino_nano_nora
+nano_nora.build.board=NANO_ESP32
+nano_nora.build.code_debug=0
+
+nano_nora.build.usb_mode=0
+nano_nora.build.cdc_on_boot=1
+nano_nora.build.msc_on_boot=0
+nano_nora.build.dfu_on_boot=1
+nano_nora.build.f_cpu=240000000L
+nano_nora.build.flash_size=16MB
+nano_nora.build.flash_freq=80m
+nano_nora.build.flash_mode=dio
+nano_nora.build.boot=qio
+nano_nora.build.boot_freq=80m
+nano_nora.build.partitions=app3M_fat9M_fact512k_16MB
+nano_nora.build.defines=-DBOARD_HAS_PIN_REMAP -DBOARD_HAS_PSRAM '-DUSB_MANUFACTURER="Arduino"' '-DUSB_PRODUCT="Nano ESP32"'
+nano_nora.build.loop_core=-DARDUINO_RUNNING_CORE=1
+nano_nora.build.event_core=-DARDUINO_EVENT_RUNNING_CORE=1
+nano_nora.build.psram_type=opi
+nano_nora.build.memory_type={build.boot}_{build.psram_type}
+
+nano_nora.tools.esptool_py.program.pattern_args=--chip {build.mcu} --port "{serial.port}" --before default_reset --after hard_reset write_flash -z --flash_mode {build.flash_mode} --flash_freq {build.flash_freq} --flash_size {build.flash_size} {build.bootloader_addr} "{build.path}/{build.project_name}.bootloader.bin" 0x8000 "{build.path}/{build.project_name}.partitions.bin" 0xe000 "{runtime.platform.path}/tools/partitions/boot_app0.bin" 0xf70000 "{build.variant.path}/extra/nora_recovery/nora_recovery.ino.bin" 0x10000 "{build.path}/{build.project_name}.bin"
+
+##############################################################
diff --git a/cores/esp32/Arduino.h b/cores/esp32/Arduino.h
index 2dc0d543489..d0a71911b63 100644
--- a/cores/esp32/Arduino.h
+++ b/cores/esp32/Arduino.h
@@ -110,13 +110,13 @@
 #define analogInPinToBit(P)         (P)
 #if SOC_GPIO_PIN_COUNT <= 32
 #define digitalPinToPort(pin)       (0)
-#define digitalPinToBitMask(pin)    (1UL << (pin))
+#define digitalPinToBitMask(pin)    (1UL << digitalPinToGPIONumber(pin))
 #define portOutputRegister(port)    ((volatile uint32_t*)GPIO_OUT_REG)
 #define portInputRegister(port)     ((volatile uint32_t*)GPIO_IN_REG)
 #define portModeRegister(port)      ((volatile uint32_t*)GPIO_ENABLE_REG)
 #elif SOC_GPIO_PIN_COUNT <= 64
-#define digitalPinToPort(pin)       (((pin)>31)?1:0)
-#define digitalPinToBitMask(pin)    (1UL << (((pin)>31)?((pin)-32):(pin)))
+#define digitalPinToPort(pin)       ((digitalPinToGPIONumber(pin)>31)?1:0)
+#define digitalPinToBitMask(pin)    (1UL << (digitalPinToGPIONumber(pin)&31))
 #define portOutputRegister(port)    ((volatile uint32_t*)((port)?GPIO_OUT1_REG:GPIO_OUT_REG))
 #define portInputRegister(port)     ((volatile uint32_t*)((port)?GPIO_IN1_REG:GPIO_IN_REG))
 #define portModeRegister(port)      ((volatile uint32_t*)((port)?GPIO_ENABLE1_REG:GPIO_ENABLE_REG))
@@ -220,5 +220,6 @@ void noTone(uint8_t _pin);
 #endif /* __cplusplus */
 
 #include "pins_arduino.h"
+#include "io_pin_remap.h"
 
 #endif /* _ESP32_CORE_ARDUINO_H_ */
diff --git a/cores/esp32/FunctionalInterrupt.cpp b/cores/esp32/FunctionalInterrupt.cpp
index c5a8d37fed4..b278332bd94 100644
--- a/cores/esp32/FunctionalInterrupt.cpp
+++ b/cores/esp32/FunctionalInterrupt.cpp
@@ -28,7 +28,7 @@ void ARDUINO_ISR_ATTR interruptFunctional(void* arg)
 void attachInterrupt(uint8_t pin, std::function<void(void)> intRoutine, int mode)
 {
 	// use the local interrupt routine which takes the ArgStructure as argument
-	__attachInterruptFunctionalArg (pin, (voidFuncPtrArg)interruptFunctional, new InterruptArgStructure{intRoutine}, mode, true);
+	__attachInterruptFunctionalArg (digitalPinToGPIONumber(pin), (voidFuncPtrArg)interruptFunctional, new InterruptArgStructure{intRoutine}, mode, true);
 }
 
 extern "C"
diff --git a/cores/esp32/HardwareSerial.cpp b/cores/esp32/HardwareSerial.cpp
index f7baea0d936..4655cf27a6f 100644
--- a/cores/esp32/HardwareSerial.cpp
+++ b/cores/esp32/HardwareSerial.cpp
@@ -4,6 +4,7 @@
 #include <inttypes.h>
 
 #include "pins_arduino.h"
+#include "io_pin_remap.h"
 #include "HardwareSerial.h"
 #include "soc/soc_caps.h"
 #include "driver/uart.h"
@@ -370,6 +371,10 @@ void HardwareSerial::begin(unsigned long baud, uint32_t config, int8_t rxPin, in
         }
     }
 
+    // map logical pins to GPIO numbers
+    rxPin = digitalPinToGPIONumber(rxPin);
+    txPin = digitalPinToGPIONumber(txPin);
+
     if(_uart) {
         // in this case it is a begin() over a previous begin() - maybe to change baud rate
         // thus do not disable debug output
@@ -554,6 +559,12 @@ bool HardwareSerial::setPins(int8_t rxPin, int8_t txPin, int8_t ctsPin, int8_t r
         return false;
     }
 
+    // map logical pins to GPIO numbers
+    rxPin = digitalPinToGPIONumber(rxPin);
+    txPin = digitalPinToGPIONumber(txPin);
+    ctsPin = digitalPinToGPIONumber(ctsPin);
+    rtsPin = digitalPinToGPIONumber(rtsPin);
+
     // uartSetPins() checks if pins are valid for each function and for the SoC
     bool retCode = uartSetPins(_uart, rxPin, txPin, ctsPin, rtsPin);
     if (retCode) {
diff --git a/cores/esp32/USB.cpp b/cores/esp32/USB.cpp
index d82659ebbcf..cc66bb8614a 100644
--- a/cores/esp32/USB.cpp
+++ b/cores/esp32/USB.cpp
@@ -47,8 +47,14 @@
 #define USB_WEBUSB_URL "https://espressif.github.io/arduino-esp32/webusb.html"
 #endif
 
+#if CFG_TUD_DFU
+__attribute__((weak, unused)) uint16_t load_dfu_ota_descriptor(uint8_t * dst, uint8_t * itf) {
+    return 0;
+}
+#endif /* CFG_TUD_DFU */
+
 #if CFG_TUD_DFU_RUNTIME
-static uint16_t load_dfu_descriptor(uint8_t * dst, uint8_t * itf)
+__attribute__((unused)) static uint16_t load_dfu_descriptor(uint8_t * dst, uint8_t * itf)
 {
 #define DFU_ATTRS (DFU_ATTR_CAN_DOWNLOAD | DFU_ATTR_CAN_UPLOAD | DFU_ATTR_MANIFESTATION_TOLERANT)
 
@@ -185,7 +191,7 @@ bool ESPUSB::begin(){
                 .webusb_enabled = webusb_enabled,
                 .webusb_url = webusb_url.c_str()
         };
-        _started = tinyusb_init(&tinyusb_device_config) == ESP_OK; 
+        _started = tinyusb_init(&tinyusb_device_config) == ESP_OK;
     }
     return _started;
 }
@@ -203,7 +209,9 @@ ESPUSB::operator bool() const
 }
 
 bool ESPUSB::enableDFU(){
-#if CFG_TUD_DFU_RUNTIME
+#if CFG_TUD_DFU
+    return tinyusb_enable_interface(USB_INTERFACE_DFU, TUD_DFU_DESC_LEN(1), load_dfu_ota_descriptor) == ESP_OK;
+#elif CFG_TUD_DFU_RUNTIME
     return tinyusb_enable_interface(USB_INTERFACE_DFU, TUD_DFU_RT_DESC_LEN, load_dfu_descriptor) == ESP_OK;
 #endif /* CFG_TUD_DFU_RUNTIME */
     return false;
diff --git a/cores/esp32/io_pin_remap.h b/cores/esp32/io_pin_remap.h
new file mode 100644
index 00000000000..4a23de4a829
--- /dev/null
+++ b/cores/esp32/io_pin_remap.h
@@ -0,0 +1,110 @@
+#ifndef __IO_PIN_REMAP_H__
+#define __IO_PIN_REMAP_H__
+
+#include "Arduino.h"
+
+#if defined(BOARD_HAS_PIN_REMAP) && !defined(BOARD_USES_HW_GPIO_NUMBERS)
+
+// Pin remapping functions
+int8_t digitalPinToGPIONumber(int8_t digitalPin);
+int8_t digitalPinFromGPIONumber(int8_t gpioPin);
+
+// Apply pin remapping to API only when building libraries and user sketch
+#ifndef ARDUINO_CORE_BUILD
+
+// Override APIs requiring pin remapping
+
+// cores/esp32/Arduino.h
+#define pulseInLong(pin, state, timeout)    pulseInLong(digitalPinToGPIONumber(pin), state, timeout)
+#define pulseIn(pin, state, timeout)        pulseIn(digitalPinToGPIONumber(pin), state, timeout)
+#define noTone(_pin)                        noTone(digitalPinToGPIONumber(_pin))
+#define tone(_pin, frequency, duration)     tone(digitalPinToGPIONumber(_pin), frequency, duration)
+
+// cores/esp32/esp32-hal.h
+#define analogGetChannel(pin)       analogGetChannel(digitalPinToGPIONumber(pin))
+#define analogWrite(pin, value)     analogWrite(digitalPinToGPIONumber(pin), value)
+
+// cores/esp32/esp32-hal-adc.h
+#define adcAttachPin(pin)                           adcAttachPin(digitalPinToGPIONumber(pin))
+#define analogRead(pin)                             analogRead(digitalPinToGPIONumber(pin))
+#define analogReadMilliVolts(pin)                   analogReadMilliVolts(digitalPinToGPIONumber(pin))
+#define analogSetPinAttenuation(pin, attenuation)   analogSetPinAttenuation(digitalPinToGPIONumber(pin), attenuation)
+#define analogSetVRefPin(pin)                       analogSetVRefPin(digitalPinToGPIONumber(pin))
+
+// cores/esp32/esp32-hal-dac.h
+#define dacDisable(pin)         dacDisable(digitalPinToGPIONumber(pin))
+#define dacWrite(pin, value)    dacWrite(digitalPinToGPIONumber(pin), value)
+
+// cores/esp32/esp32-hal-gpio.h
+#define analogChannelToDigitalPin(channel)          gpioNumberToDigitalPin(analogChannelToDigitalPin(channel))
+#define digitalPinToAnalogChannel(pin)              digitalPinToAnalogChannel(digitalPinToGPIONumber(pin))
+#define digitalPinToTouchChannel(pin)               digitalPinToTouchChannel(digitalPinToGPIONumber(pin))
+#define digitalRead(pin)                            digitalRead(digitalPinToGPIONumber(pin))
+#define attachInterruptArg(pin, fcn, arg, mode)     attachInterruptArg(digitalPinToGPIONumber(pin), fcn, arg, mode)
+#define attachInterrupt(pin, fcn, mode)             attachInterrupt(digitalPinToGPIONumber(pin), fcn, mode)
+#define detachInterrupt(pin)                        detachInterrupt(digitalPinToGPIONumber(pin))
+#define digitalWrite(pin, val)                      digitalWrite(digitalPinToGPIONumber(pin), val)
+#define pinMode(pin, mode)                          pinMode(digitalPinToGPIONumber(pin), mode)
+
+// cores/esp32/esp32-hal-i2c.h
+#define i2cInit(i2c_num, sda, scl, clk_speed)   i2cInit(i2c_num, digitalPinToGPIONumber(sda), digitalPinToGPIONumber(scl), clk_speed)
+
+// cores/esp32/esp32-hal-i2c-slave.h
+#define i2cSlaveInit(num, sda, scl, slaveID, frequency, rx_len, tx_len)     i2cSlaveInit(num, digitalPinToGPIONumber(sda), digitalPinToGPIONumber(scl), slaveID, frequency, rx_len, tx_len)
+
+// cores/esp32/esp32-hal-ledc.h
+#define ledcAttachPin(pin, channel)     ledcAttachPin(digitalPinToGPIONumber(pin), channel)
+#define ledcDetachPin(pin)              ledcDetachPin(digitalPinToGPIONumber(pin))
+
+// cores/esp32/esp32-hal-matrix.h
+#define pinMatrixInAttach(pin, signal, inverted)                    pinMatrixInAttach(digitalPinToGPIONumber(pin), signal, inverted)
+#define pinMatrixOutAttach(pin, function, invertOut, invertEnable)  pinMatrixOutAttach(digitalPinToGPIONumber(pin), function, invertOut, invertEnable)
+#define pinMatrixOutDetach(pin, invertOut, invertEnable)            pinMatrixOutDetach(digitalPinToGPIONumber(pin), invertOut, invertEnable)
+
+// cores/esp32/esp32-hal-rgb-led.h
+#define neopixelWrite(pin, red_val, green_val, blue_val)    neopixelWrite(digitalPinToGPIONumber(pin), red_val, green_val, blue_val)
+
+// cores/esp32/esp32-hal-rmt.h
+#define rmtInit(pin, tx_not_rx, memsize)    rmtInit(digitalPinToGPIONumber(pin), tx_not_rx, memsize)
+
+// cores/esp32/esp32-hal-sigmadelta.h
+#define sigmaDeltaSetup(pin, channel, freq)     sigmaDeltaSetup(digitalPinToGPIONumber(pin), channel, freq)
+#define sigmaDeltaDetachPin(pin)                sigmaDeltaDetachPin(digitalPinToGPIONumber(pin))
+
+// cores/esp32/esp32-hal-spi.h
+#define spiAttachSCK(spi, sck)          spiAttachSCK(spi, digitalPinToGPIONumber(sck))
+#define spiAttachMISO(spi, miso)        spiAttachMISO(spi, digitalPinToGPIONumber(miso))
+#define spiAttachMOSI(spi, mosi)        spiAttachMOSI(spi, digitalPinToGPIONumber(mosi))
+#define spiDetachSCK(spi, sck)          spiDetachSCK(spi, digitalPinToGPIONumber(sck))
+#define spiDetachMISO(spi, miso)        spiDetachMISO(spi, digitalPinToGPIONumber(miso))
+#define spiDetachMOSI(spi, mosi)        spiDetachMOSI(spi, digitalPinToGPIONumber(mosi))
+#define spiAttachSS(spi, cs_num, ss)    spiAttachSS(spi, cs_num, digitalPinToGPIONumber(ss))
+#define spiDetachSS(spi, ss)            spiDetachSS(spi, digitalPinToGPIONumber(ss))
+
+// cores/esp32/esp32-hal-touch.h
+#define touchInterruptGetLastStatus(pin)                        touchInterruptGetLastStatus(digitalPinToGPIONumber(pin))
+#define touchRead(pin)                                          touchRead(digitalPinToGPIONumber(pin))
+#define touchAttachInterruptArg(pin, userFunc, arg, threshold)  touchAttachInterruptArg(digitalPinToGPIONumber(pin), userFunc, arg, threshold)
+#define touchAttachInterrupt(pin, userFunc, threshold)          touchAttachInterrupt(digitalPinToGPIONumber(pin), userFunc, threshold)
+#define touchDetachInterrupt(pin)                               touchDetachInterrupt(digitalPinToGPIONumber(pin))
+#define touchSleepWakeUpEnable(pin, threshold)                  touchSleepWakeUpEnable(digitalPinToGPIONumber(pin), threshold)
+
+// cores/esp32/esp32-hal-uart.h
+#define uartBegin(uart_nr, baudrate, config, rxPin, txPin, rx_buffer_size, tx_buffer_size, inverted, rxfifo_full_thrhd) \
+        uartBegin(uart_nr, baudrate, config, digitalPinToGPIONumber(rxPin), digitalPinToGPIONumber(txPin), rx_buffer_size, tx_buffer_size, inverted, rxfifo_full_thrhd)
+#define uartSetPins(uart, rxPin, txPin, ctsPin, rtsPin) \
+        uartSetPins(uart, digitalPinToGPIONumber(rxPin), digitalPinToGPIONumber(txPin), digitalPinToGPIONumber(ctsPin), digitalPinToGPIONumber(rtsPin))
+#define uartDetachPins(uart, rxPin, txPin, ctsPin, rtsPin) \
+        uartDetachPins(uart, digitalPinToGPIONumber(rxPin), digitalPinToGPIONumber(txPin), digitalPinToGPIONumber(ctsPin), digitalPinToGPIONumber(rtsPin))
+
+#endif // ARDUINO_CORE_BUILD
+
+#else
+
+// pin remapping disabled: use stubs
+#define digitalPinToGPIONumber(digitalPin) (digitalPin)
+#define gpioNumberToDigitalPin(gpioNumber) (gpioNumber)
+
+#endif
+
+#endif /* __GPIO_PIN_REMAP_H__ */
diff --git a/libraries/I2S/src/I2S.cpp b/libraries/I2S/src/I2S.cpp
index 78dc5c202df..68cc3b13f10 100644
--- a/libraries/I2S/src/I2S.cpp
+++ b/libraries/I2S/src/I2S.cpp
@@ -317,24 +317,24 @@ int I2SClass::begin(int mode, int sampleRate, int bitsPerSample, bool driveClock
 int I2SClass::_applyPinSetting(){
   if(_driverInstalled){
     esp_i2s::i2s_pin_config_t pin_config = {
-      .bck_io_num = _sckPin,
-      .ws_io_num = _fsPin,
+      .bck_io_num = digitalPinToGPIONumber(_sckPin),
+      .ws_io_num = digitalPinToGPIONumber(_fsPin),
       .data_out_num = I2S_PIN_NO_CHANGE,
       .data_in_num = I2S_PIN_NO_CHANGE
     };
     if (_state == I2S_STATE_DUPLEX){ // duplex
-      pin_config.data_out_num = _outSdPin;
-      pin_config.data_in_num = _inSdPin;
+      pin_config.data_out_num = digitalPinToGPIONumber(_outSdPin);
+      pin_config.data_in_num = digitalPinToGPIONumber(_inSdPin);
     }else{ // simplex
       if(_state == I2S_STATE_RECEIVER){
         pin_config.data_out_num = I2S_PIN_NO_CHANGE;
-        pin_config.data_in_num = _sdPin;
+        pin_config.data_in_num = digitalPinToGPIONumber(_sdPin);
       }else if(_state == I2S_STATE_TRANSMITTER){
-        pin_config.data_out_num = _sdPin;
+        pin_config.data_out_num = digitalPinToGPIONumber(_sdPin);
         pin_config.data_in_num = I2S_PIN_NO_CHANGE;
       }else{
         pin_config.data_out_num = I2S_PIN_NO_CHANGE;
-        pin_config.data_in_num = _sdPin;
+        pin_config.data_in_num = digitalPinToGPIONumber(_sdPin);
       }
     }
     if(ESP_OK != esp_i2s::i2s_set_pin((esp_i2s::i2s_port_t) _deviceIndex, &pin_config)){
diff --git a/libraries/SD_MMC/src/SD_MMC.cpp b/libraries/SD_MMC/src/SD_MMC.cpp
index 83cf9aa0dd1..85f4e46681c 100644
--- a/libraries/SD_MMC/src/SD_MMC.cpp
+++ b/libraries/SD_MMC/src/SD_MMC.cpp
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 #include "pins_arduino.h"
+#include "io_pin_remap.h"
 #include "SD_MMC.h"
 #ifdef SOC_SDMMC_HOST_SUPPORTED
 #include "vfs_api.h"
@@ -54,6 +55,15 @@ bool SDMMCFS::setPins(int clk, int cmd, int d0, int d1, int d2, int d3)
         log_e("SD_MMC.setPins must be called before SD_MMC.begin");
         return false;
     }
+
+    // map logical pins to GPIO numbers
+    clk = digitalPinToGPIONumber(clk);
+    cmd = digitalPinToGPIONumber(cmd);
+    d0 = digitalPinToGPIONumber(d0);
+    d1 = digitalPinToGPIONumber(d1);
+    d2 = digitalPinToGPIONumber(d2);
+    d3 = digitalPinToGPIONumber(d3);
+
 #ifdef SOC_SDMMC_USE_GPIO_MATRIX
     // SoC supports SDMMC pin configuration via GPIO matrix. Save the pins for later use in SDMMCFS::begin.
     _pin_clk = (int8_t) clk;
diff --git a/libraries/SPI/src/SPI.cpp b/libraries/SPI/src/SPI.cpp
index 23a35c0d357..3af07515c2b 100644
--- a/libraries/SPI/src/SPI.cpp
+++ b/libraries/SPI/src/SPI.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "SPI.h"
+#include "io_pin_remap.h"
 #include "esp32-hal-log.h"
 
 #if !CONFIG_DISABLE_HAL_LOCKS
diff --git a/package/package_esp32_index.template.json b/package/package_esp32_index.template.json
index f96c18a37c8..553f3f4229f 100644
--- a/package/package_esp32_index.template.json
+++ b/package/package_esp32_index.template.json
@@ -33,6 +33,9 @@
             },
             {
               "name": "ESP32-C3 Dev Board"
+            },
+            {
+              "name": "Arduino Nano ESP32"
             }
           ],
           "toolsDependencies": [
@@ -85,6 +88,11 @@
               "packager": "esp32",
               "name": "mklittlefs",
               "version": "3.0.0-gnu12-dc7f933"
+            },
+            {
+              "packager": "arduino",
+              "name": "dfu-util",
+              "version": "0.11.0-arduino5"
             }
           ]
         }
diff --git a/platform.txt b/platform.txt
index e951a787435..b8d660962c5 100644
--- a/platform.txt
+++ b/platform.txt
@@ -175,8 +175,8 @@ recipe.hooks.prebuild.3.pattern.windows=cmd /c if not exist "{build.path}\partit
 
 # Check if custom bootloader exist: source > variant > build.boot
 recipe.hooks.prebuild.4.pattern_args=--chip {build.mcu} elf2image --flash_mode {build.flash_mode} --flash_freq {build.flash_freq} --flash_size {build.flash_size} -o
-recipe.hooks.prebuild.4.pattern=bash -c "[ -f "{build.source.path}"/bootloader.bin ] && cp -f "{build.source.path}"/bootloader.bin "{build.path}"/{build.project_name}.bootloader.bin || ( [ -f "{build.variant.path}"/{build.custom_bootloader}.bin ] && cp "{build.variant.path}"/{build.custom_bootloader}.bin "{build.path}"/{build.project_name}.bootloader.bin || "{tools.esptool_py.path}"/{tools.esptool_py.cmd} {recipe.hooks.prebuild.4.pattern_args} "{build.path}"/{build.project_name}.bootloader.bin "{runtime.platform.path}"/tools/sdk/{build.mcu}/bin/bootloader_{build.boot}_{build.boot_freq}.elf )"
-recipe.hooks.prebuild.4.pattern.linux=bash -c "[ -f "{build.source.path}"/bootloader.bin ] && cp -f "{build.source.path}"/bootloader.bin "{build.path}"/{build.project_name}.bootloader.bin || ( [ -f "{build.variant.path}"/{build.custom_bootloader}.bin ] && cp "{build.variant.path}"/{build.custom_bootloader}.bin "{build.path}"/{build.project_name}.bootloader.bin || python3 "{tools.esptool_py.path}"/{tools.esptool_py.cmd} {recipe.hooks.prebuild.4.pattern_args} "{build.path}"/{build.project_name}.bootloader.bin "{runtime.platform.path}"/tools/sdk/{build.mcu}/bin/bootloader_{build.boot}_{build.boot_freq}.elf )"
+recipe.hooks.prebuild.4.pattern=bash -c "[ -f "{build.source.path}"/bootloader.bin ] && cp -f "{build.source.path}"/bootloader.bin "{build.path}"/{build.project_name}.bootloader.bin || ( [ -f "{build.variant.path}"/{build.custom_bootloader}.bin ] && cp "{build.variant.path}"/{build.custom_bootloader}.bin "{build.path}"/{build.project_name}.bootloader.bin || "{tools.esptool_py.path}"/{tools.esptool_py.cmd} {recipe.hooks.prebuild.4.pattern_args} "{build.path}"/{build.project_name}.bootloader.bin "{compiler.sdk.path}"/bin/bootloader_{build.boot}_{build.boot_freq}.elf )"
+recipe.hooks.prebuild.4.pattern.linux=bash -c "[ -f "{build.source.path}"/bootloader.bin ] && cp -f "{build.source.path}"/bootloader.bin "{build.path}"/{build.project_name}.bootloader.bin || ( [ -f "{build.variant.path}"/{build.custom_bootloader}.bin ] && cp "{build.variant.path}"/{build.custom_bootloader}.bin "{build.path}"/{build.project_name}.bootloader.bin || python3 "{tools.esptool_py.path}"/{tools.esptool_py.cmd} {recipe.hooks.prebuild.4.pattern_args} "{build.path}"/{build.project_name}.bootloader.bin "{compiler.sdk.path}"/bin/bootloader_{build.boot}_{build.boot_freq}.elf )"
 recipe.hooks.prebuild.4.pattern.windows=cmd /c IF EXIST "{build.source.path}\bootloader.bin" ( COPY /y "{build.source.path}\bootloader.bin" "{build.path}\{build.project_name}.bootloader.bin" ) ELSE ( IF EXIST "{build.variant.path}\{build.custom_bootloader}.bin" ( COPY "{build.variant.path}\{build.custom_bootloader}.bin" "{build.path}\{build.project_name}.bootloader.bin" ) ELSE ( "{tools.esptool_py.path}/{tools.esptool_py.cmd}" {recipe.hooks.prebuild.4.pattern_args} "{build.path}\{build.project_name}.bootloader.bin" "{runtime.platform.path}\tools\sdk\{build.mcu}\bin\bootloader_{build.boot}_{build.boot_freq}.elf" ) )
 
 # Check if custom build options exist in the sketch folder
@@ -186,6 +186,16 @@ recipe.hooks.prebuild.6.pattern=bash -c "[ -f "{build.path}"/build_opt.h ] || to
 recipe.hooks.prebuild.5.pattern.windows=cmd /c if exist "{build.source.path}\build_opt.h" COPY /y "{build.source.path}\build_opt.h" "{build.path}\build_opt.h"
 recipe.hooks.prebuild.6.pattern.windows=cmd /c if not exist "{build.path}\build_opt.h" type nul > "{build.path}\build_opt.h"
 
+# Set -DARDUINO_CORE_BUILD only on core file compilation
+file_opts.path={build.path}/file_opts
+recipe.hooks.prebuild.set_core_build_flag.pattern=bash -c ": > {file_opts.path}"
+recipe.hooks.core.prebuild.set_core_build_flag.pattern=bash -c "echo '-DARDUINO_CORE_BUILD' > {file_opts.path}"
+recipe.hooks.core.postbuild.set_core_build_flag.pattern=bash -c ": > {file_opts.path}"
+
+recipe.hooks.prebuild.set_core_build_flag.pattern.windows=cmd /c type nul > {file_opts.path}
+recipe.hooks.core.prebuild.set_core_build_flag.pattern.windows=cmd /c echo "-DARDUINO_CORE_BUILD" > {file_opts.path}
+recipe.hooks.core.postbuild.set_core_build_flag.pattern.windows=cmd /c type nul > {file_opts.path}
+
 # Generate debug.cfg (must be postbuild)
 recipe.hooks.postbuild.1.pattern=bash -c "[ {build.copy_jtag_files} -eq 0 ] || cp -f "{debug.server.openocd.scripts_dir}"board/{build.openocdscript} "{build.source.path}"/debug.cfg"
 recipe.hooks.postbuild.1.pattern.windows=cmd /c IF {build.copy_jtag_files}==1 COPY /y "{debug.server.openocd.scripts_dir}board\{build.openocdscript}" "{build.source.path}\debug.cfg"
@@ -199,13 +209,13 @@ recipe.hooks.postbuild.3.pattern=bash -c "[ {build.copy_jtag_files} -eq 0 ] || c
 recipe.hooks.postbuild.3.pattern.windows=cmd /c IF {build.copy_jtag_files}==1 COPY /y "{runtime.platform.path}\tools\ide-debug\svd\{build.mcu}.svd" "{build.source.path}\debug.svd"
 
 ## Compile c files
-recipe.c.o.pattern="{compiler.path}{compiler.c.cmd}" {compiler.cpreprocessor.flags} {compiler.c.flags} -DF_CPU={build.f_cpu} -DARDUINO={runtime.ide.version} -DARDUINO_{build.board} -DARDUINO_ARCH_{build.arch} -DARDUINO_BOARD="{build.board}" -DARDUINO_VARIANT="{build.variant}" -DARDUINO_PARTITION_{build.partitions} {compiler.c.extra_flags} {build.extra_flags} "@{build.opt.path}" {includes} "{source_file}" -o "{object_file}"
+recipe.c.o.pattern="{compiler.path}{compiler.c.cmd}" {compiler.cpreprocessor.flags} {compiler.c.flags} -DF_CPU={build.f_cpu} -DARDUINO={runtime.ide.version} -DARDUINO_{build.board} -DARDUINO_ARCH_{build.arch} -DARDUINO_BOARD="{build.board}" -DARDUINO_VARIANT="{build.variant}" -DARDUINO_PARTITION_{build.partitions} {compiler.c.extra_flags} {build.extra_flags} "@{build.opt.path}" "@{file_opts.path}" {includes} "{source_file}" -o "{object_file}"
 
 ## Compile c++ files
-recipe.cpp.o.pattern="{compiler.path}{compiler.cpp.cmd}" {compiler.cpreprocessor.flags} {compiler.cpp.flags} -DF_CPU={build.f_cpu} -DARDUINO={runtime.ide.version} -DARDUINO_{build.board} -DARDUINO_ARCH_{build.arch} -DARDUINO_BOARD="{build.board}" -DARDUINO_VARIANT="{build.variant}" -DARDUINO_PARTITION_{build.partitions} {compiler.cpp.extra_flags} {build.extra_flags} "@{build.opt.path}" {includes} "{source_file}" -o "{object_file}"
+recipe.cpp.o.pattern="{compiler.path}{compiler.cpp.cmd}" {compiler.cpreprocessor.flags} {compiler.cpp.flags} -DF_CPU={build.f_cpu} -DARDUINO={runtime.ide.version} -DARDUINO_{build.board} -DARDUINO_ARCH_{build.arch} -DARDUINO_BOARD="{build.board}" -DARDUINO_VARIANT="{build.variant}" -DARDUINO_PARTITION_{build.partitions} {compiler.cpp.extra_flags} {build.extra_flags} "@{build.opt.path}" "@{file_opts.path}" {includes} "{source_file}" -o "{object_file}"
 
 ## Compile S files
-recipe.S.o.pattern="{compiler.path}{compiler.c.cmd}" {compiler.cpreprocessor.flags} {compiler.S.flags} -DF_CPU={build.f_cpu} -DARDUINO={runtime.ide.version} -DARDUINO_{build.board} -DARDUINO_ARCH_{build.arch} -DARDUINO_BOARD="{build.board}" -DARDUINO_VARIANT="{build.variant}" -DARDUINO_PARTITION_{build.partitions} {compiler.S.extra_flags} {build.extra_flags} "@{build.opt.path}" {includes} "{source_file}" -o "{object_file}"
+recipe.S.o.pattern="{compiler.path}{compiler.c.cmd}" {compiler.cpreprocessor.flags} {compiler.S.flags} -DF_CPU={build.f_cpu} -DARDUINO={runtime.ide.version} -DARDUINO_{build.board} -DARDUINO_ARCH_{build.arch} -DARDUINO_BOARD="{build.board}" -DARDUINO_VARIANT="{build.variant}" -DARDUINO_PARTITION_{build.partitions} {compiler.S.extra_flags} {build.extra_flags} "@{build.opt.path}" "@{file_opts.path}" {includes} "{source_file}" -o "{object_file}"
 
 ## Create archives
 recipe.ar.pattern="{compiler.path}{compiler.ar.cmd}" {compiler.ar.flags} {compiler.ar.extra_flags} "{archive_file_path}" "{object_file}"
@@ -239,6 +249,7 @@ recipe.size.regex.data=^(?:\.dram0\.data|\.dram0\.bss|\.noinit)\s+([0-9]+).*
 ## ---------------------------------
 pluggable_discovery.required.0=builtin:serial-discovery
 pluggable_discovery.required.1=builtin:mdns-discovery
+pluggable_discovery.required.2=builtin:dfu-discovery
 pluggable_monitor.required.serial=builtin:serial-monitor
 
 ## ------------------
@@ -305,3 +316,11 @@ tools.esp_ota.upload.protocol=network
 tools.esp_ota.upload.field.password=Password
 tools.esp_ota.upload.field.password.secret=true
 tools.esp_ota.upload.pattern={cmd} -i {upload.port.address} -p {upload.port.properties.port} --auth={upload.field.password} -f "{build.path}/{build.project_name}.bin"
+
+## Upload Sketch Through DFU OTA
+## -------------------------------------------
+tools.dfu-util.path={runtime.tools.dfu-util-0.11.0-arduino5.path}
+tools.dfu-util.cmd=dfu-util
+tools.dfu-util.upload.params.verbose=-d
+tools.dfu-util.upload.params.quiet=
+tools.dfu-util.upload.pattern="{path}/{cmd}" --device {vid.0}:{pid.0} -D "{build.path}/{build.project_name}.bin" -Q
diff --git a/tools/partitions/app3M_fat9M_fact512k_16MB.csv b/tools/partitions/app3M_fat9M_fact512k_16MB.csv
new file mode 100644
index 00000000000..dac4603e01a
--- /dev/null
+++ b/tools/partitions/app3M_fat9M_fact512k_16MB.csv
@@ -0,0 +1,9 @@
+# Name,   Type, SubType,  Offset,   Size,     Flags
+nvs,      data, nvs,        0x9000,   0x5000,
+otadata,  data, ota,        0xe000,   0x2000,
+app0,     app,  ota_0,     0x10000, 0x300000,
+app1,     app,  ota_1,    0x310000, 0x300000,
+ffat,     data, fat,      0x610000, 0x960000,
+factory,  app,  factory,  0xF70000,  0x80000,
+coredump, data, coredump, 0xFF0000,  0x10000,
+# to create/use ffat, see https://github.com/marcmerlin/esp32_fatfsimage
diff --git a/variants/arduino_nano_nora/dfu_callbacks.cpp b/variants/arduino_nano_nora/dfu_callbacks.cpp
new file mode 100644
index 00000000000..3695db80ba6
--- /dev/null
+++ b/variants/arduino_nano_nora/dfu_callbacks.cpp
@@ -0,0 +1,116 @@
+#include "Arduino.h"
+
+#include <esp32-hal-tinyusb.h>
+#include <esp_system.h>
+
+// defines an "Update" object accessed only by this translation unit
+// (also, the object requires MD5Builder internally)
+namespace {
+// ignore '{anonymous}::MD5Builder::...() defined but not used' warnings
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wunused-function"
+#include "../../libraries/Update/src/Updater.cpp"
+#include "../../cores/esp32/MD5Builder.cpp"
+#pragma GCC diagnostic pop
+}
+
+#define ALT_COUNT   1
+
+//--------------------------------------------------------------------+
+// DFU callbacks
+// Note: alt is used as the partition number, in order to support multiple partitions like FLASH, EEPROM, etc.
+//--------------------------------------------------------------------+
+
+uint16_t load_dfu_ota_descriptor(uint8_t * dst, uint8_t * itf)
+{
+#define DFU_ATTRS (DFU_ATTR_CAN_DOWNLOAD | DFU_ATTR_CAN_UPLOAD | DFU_ATTR_MANIFESTATION_TOLERANT)
+
+    uint8_t str_index = tinyusb_add_string_descriptor("Arduino DFU");
+    uint8_t descriptor[TUD_DFU_DESC_LEN(ALT_COUNT)] = {
+        // Interface number, string index, attributes, detach timeout, transfer size */
+        TUD_DFU_DESCRIPTOR(*itf, ALT_COUNT, str_index, DFU_ATTRS, 100, CFG_TUD_DFU_XFER_BUFSIZE),
+    };
+    *itf+=1;
+    memcpy(dst, descriptor, TUD_DFU_DESC_LEN(ALT_COUNT));
+    return TUD_DFU_DESC_LEN(ALT_COUNT);
+}
+
+// Invoked right before tud_dfu_download_cb() (state=DFU_DNBUSY) or tud_dfu_manifest_cb() (state=DFU_MANIFEST)
+// Application return timeout in milliseconds (bwPollTimeout) for the next download/manifest operation.
+// During this period, USB host won't try to communicate with us.
+uint32_t tud_dfu_get_timeout_cb(uint8_t alt, uint8_t state)
+{
+    if ( state == DFU_DNBUSY )
+    {
+        // longest delay for Flash writing
+        return 10;
+    }
+    else if (state == DFU_MANIFEST)
+    {
+        // time for esp32_ota_set_boot_partition to check final image
+        return 100;
+    }
+
+    return 0;
+}
+
+// Invoked when received DFU_DNLOAD (wLength>0) following by DFU_GETSTATUS (state=DFU_DNBUSY) requests
+// This callback could be returned before flashing op is complete (async).
+// Once finished flashing, application must call tud_dfu_finish_flashing()
+void tud_dfu_download_cb(uint8_t alt, uint16_t block_num, uint8_t const* data, uint16_t length)
+{
+    if (!Update.isRunning())
+    {
+        // this is the first data block, start update if possible
+        if (!Update.begin())
+        {
+            tud_dfu_finish_flashing(DFU_STATUS_ERR_TARGET);
+            return;
+        }
+    }
+
+    // write a block of data to Flash
+    // XXX: Update API is needlessly non-const
+    size_t written = Update.write(const_cast<uint8_t*>(data), length);
+    tud_dfu_finish_flashing((written == length) ? DFU_STATUS_OK : DFU_STATUS_ERR_WRITE);
+}
+
+// Invoked when download process is complete, received DFU_DNLOAD (wLength=0) following by DFU_GETSTATUS (state=Manifest)
+// Application can do checksum, or actual flashing if buffered entire image previously.
+// Once finished flashing, application must call tud_dfu_finish_flashing()
+void tud_dfu_manifest_cb(uint8_t alt)
+{
+    (void) alt;
+    bool ok = Update.end(true);
+
+    // flashing op for manifest is complete
+    tud_dfu_finish_flashing(ok? DFU_STATUS_OK : DFU_STATUS_ERR_VERIFY);
+}
+
+// Invoked when received DFU_UPLOAD request
+// Application must populate data with up to length bytes and
+// Return the number of written bytes
+uint16_t tud_dfu_upload_cb(uint8_t alt, uint16_t block_num, uint8_t* data, uint16_t length)
+{
+    (void) alt;
+    (void) block_num;
+    (void) data;
+    (void) length;
+
+    // not implemented
+    return 0;
+}
+
+// Invoked when the Host has terminated a download or upload transfer
+void tud_dfu_abort_cb(uint8_t alt)
+{
+    (void) alt;
+    // ignore
+}
+
+// Invoked when a DFU_DETACH request is received
+void tud_dfu_detach_cb(void)
+{
+    // done, reboot
+    esp_restart();
+}
diff --git a/variants/arduino_nano_nora/double_tap.c b/variants/arduino_nano_nora/double_tap.c
new file mode 100644
index 00000000000..44fe0923857
--- /dev/null
+++ b/variants/arduino_nano_nora/double_tap.c
@@ -0,0 +1,67 @@
+#include <string.h>
+
+#include <esp_system.h>
+#include <esp32s3/rom/cache.h>
+
+#include "double_tap.h"
+
+#define NUM_TOKENS 3
+static const uint32_t MAGIC_TOKENS[NUM_TOKENS] = {
+    0xf01681de, 0xbd729b29, 0xd359be7a,
+};
+
+static void *magic_area;
+static uint32_t backup_area[NUM_TOKENS];
+
+#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
+// Current IDF does not map external RAM to a fixed address.
+// The actual VMA depends on other enabled devices, so the precise
+// location must be discovered.
+#include <esp_psram.h>
+#include <esp_private/esp_psram_extram.h>
+static uintptr_t get_extram_data_high(void) {
+    // get a pointer into SRAM area (only the address is useful)
+    void *psram_ptr = heap_caps_malloc(16, MALLOC_CAP_SPIRAM);
+    heap_caps_free(psram_ptr);
+
+    // keep moving backwards until leaving PSRAM area
+    uintptr_t psram_base_addr = (uintptr_t) psram_ptr;
+    psram_base_addr &= ~(CONFIG_MMU_PAGE_SIZE - 1); // align to start of page
+    while (esp_psram_check_ptr_addr((void *) psram_base_addr)) {
+        psram_base_addr -= CONFIG_MMU_PAGE_SIZE;
+    }
+
+    // offset is one page from start of PSRAM
+    return psram_base_addr + CONFIG_MMU_PAGE_SIZE + esp_psram_get_size();
+}
+#else
+#include <soc/soc.h>
+#define get_extram_data_high() ((uintptr_t) SOC_EXTRAM_DATA_HIGH)
+#endif
+
+
+void double_tap_init(void) {
+    // magic location block ends 0x20 bytes from end of PSRAM
+    magic_area = (void *) (get_extram_data_high() - 0x20 - sizeof(MAGIC_TOKENS));
+}
+
+void double_tap_mark() {
+    memcpy(backup_area, magic_area, sizeof(MAGIC_TOKENS));
+    memcpy(magic_area, MAGIC_TOKENS, sizeof(MAGIC_TOKENS));
+    Cache_WriteBack_Addr((uintptr_t) magic_area, sizeof(MAGIC_TOKENS));
+}
+
+void double_tap_invalidate() {
+    if (memcmp(backup_area, MAGIC_TOKENS, sizeof(MAGIC_TOKENS))) {
+        // different contents: restore backup
+        memcpy(magic_area, backup_area, sizeof(MAGIC_TOKENS));
+    } else {
+        // clear memory
+        memset(magic_area, 0, sizeof(MAGIC_TOKENS));
+    }
+    Cache_WriteBack_Addr((uintptr_t) magic_area, sizeof(MAGIC_TOKENS));
+}
+
+bool double_tap_check_match() {
+    return (memcmp(magic_area, MAGIC_TOKENS, sizeof(MAGIC_TOKENS)) == 0);
+}
diff --git a/variants/arduino_nano_nora/double_tap.h b/variants/arduino_nano_nora/double_tap.h
new file mode 100644
index 00000000000..e797f4f64fd
--- /dev/null
+++ b/variants/arduino_nano_nora/double_tap.h
@@ -0,0 +1,20 @@
+#ifndef __DOUBLE_TAP_H__
+#define __DOUBLE_TAP_H__
+
+#include <stdint.h>
+#include <stdbool.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+void double_tap_init(void);
+void double_tap_mark(void);
+void double_tap_invalidate(void);
+bool double_tap_check_match(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* __DOUBLE_TAP_H__ */
diff --git a/variants/arduino_nano_nora/extra/nora_recovery/README.md b/variants/arduino_nano_nora/extra/nora_recovery/README.md
new file mode 100644
index 00000000000..786027dc6e3
--- /dev/null
+++ b/variants/arduino_nano_nora/extra/nora_recovery/README.md
@@ -0,0 +1,49 @@
+
+# Arduino Nano Nora Recovery Sketch
+
+This sketch implements the DFU recovery mode logic, called by all sketches
+when a double tap on the RESET button is detected. It should not be uploaded
+as any other sketch; instead, this should be compiled and then flashed in
+the module's `factory` partition.
+
+## Compilation
+
+The binary can be compiled with the Arduino 2.x IDE or CLI using the
+`nano_nora` variant. In particular, using the CLI the resulting binary
+can be exported to the `build` directory with the `-e` switch to
+`arduino-cli compile`.
+
+## Automatic installation
+
+By replacing the binary in the current folder, automatic installation
+can be performed by running the "Upload with Programmer" action on any
+sketch in the Arduino 2.x IDE or CLI. In particular, using the CLI the
+binary can be installed via the command:
+
+```
+arduino-cli compile -u --programmer esptool
+```
+
+## Manual installation
+
+Once compiled, the binary can also be installed on a board using `esptool.py`
+with the following command:
+
+```
+esptool.py --chip esp32s3 --port "/dev/ttyACM0" --baud 921600  --before default_reset --after hard_reset write_flash  -z --flash_mode dio --flash_freq 80m --flash_size 16MB 0xF70000 "nora_recovery.ino.bin"
+```
+
+where:
+- `esptool.py` is located in your core's install path under `tools/esptool_py`;
+- `/dev/ttyACM0` is the serial port exposed by the board to be used;
+- `0xF70000` is the factory partition address (make sure it matches the
+  offset in the variant's `{build.partitions}` file);
+- `nora_recovery.ino.bin` is the compiled sketch image.
+
+Due to a BSP issue, the first call to `esptool.py` will enter the hardware
+bootloader for programming, but fail with an "Input/output error". This is
+a known issue; calling the program again with the same arguments will now
+work correctly.
+
+Once flashing is complete, a power cycle (or RESET button tap) is required
+to leave the `esptool.py` flashing mode and load user sketches.
diff --git a/variants/arduino_nano_nora/extra/nora_recovery/nora_recovery.ino b/variants/arduino_nano_nora/extra/nora_recovery/nora_recovery.ino
new file mode 100644
index 00000000000..865fdd59020
--- /dev/null
+++ b/variants/arduino_nano_nora/extra/nora_recovery/nora_recovery.ino
@@ -0,0 +1,97 @@
+#define USB_TIMEOUT_MS 15000
+#define POLL_DELAY_MS 60
+#define FADESTEP 8
+
+void pulse_led() {
+	static u32_t pulse_width = 0;
+	static u8_t dir = 0;
+
+	if (dir) {
+		pulse_width -= FADESTEP;
+		if (pulse_width < FADESTEP) {
+			dir = 0U;
+			pulse_width = FADESTEP;
+		}
+	} else {
+		pulse_width += FADESTEP;
+		if (pulse_width > 255) {
+			dir = 1U;
+			pulse_width = 255;
+		}
+	}
+
+	analogWrite(LED_GREEN, pulse_width);
+}
+
+#include <esp_ota_ops.h>
+#include <esp_partition.h>
+#include <esp_flash_partitions.h>
+#include <esp_image_format.h>
+const esp_partition_t *find_previous_firmware() {
+	extern bool _recovery_active;
+	if (!_recovery_active) {
+		// user flashed this recovery sketch to an OTA partition
+		// stay here and wait for a proper firmware
+		return NULL;
+	}
+
+	// booting from factory partition, look for a valid OTA image
+	esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, NULL);
+	for (; it != NULL; it = esp_partition_next(it)) {
+		const esp_partition_t *part = esp_partition_get(it);
+		if (part->subtype != ESP_PARTITION_SUBTYPE_APP_FACTORY) {
+			esp_partition_pos_t candidate = { part->address, part->size };
+			esp_image_metadata_t meta;
+			if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &candidate, &meta) == ESP_OK) {
+				// found, use it
+				return part;
+			}
+		}
+	}
+
+	return NULL;
+}
+
+const esp_partition_t *user_part = NULL;
+
+void setup() {
+	user_part = find_previous_firmware();
+	if (user_part)
+		esp_ota_set_boot_partition(user_part);
+
+	extern bool _recovery_marker_found;
+	if (!_recovery_marker_found && user_part) {
+		// recovery marker not found, probable cold start
+		// try starting previous firmware immediately
+		esp_restart();
+	}
+
+	// recovery marker found, or nothing else to load
+	printf("Recovery firmware started, waiting for USB\r\n");
+}
+
+void loop() {
+	static int elapsed_ms = 0;
+
+	pulse_led();
+	delay(POLL_DELAY_MS);
+	if (USB) {
+		// wait indefinitely for DFU to complete
+		elapsed_ms = 0;
+	} else {
+		// wait for USB connection
+		elapsed_ms += POLL_DELAY_MS;
+	}
+
+	if (elapsed_ms > USB_TIMEOUT_MS) {
+		elapsed_ms = 0;
+		// timed out, try loading previous firmware
+		if (user_part) {
+			// there was a valid FW image, load it
+			analogWrite(LED_GREEN, 255);
+			printf("Leaving recovery firmware\r\n");
+			delay(200);
+			esp_restart(); // does not return
+		}
+	}
+}
diff --git a/variants/arduino_nano_nora/extra/nora_recovery/nora_recovery.ino.bin b/variants/arduino_nano_nora/extra/nora_recovery/nora_recovery.ino.bin
new file mode 100644
index 00000000000..2a0183eb58f
Binary files /dev/null and b/variants/arduino_nano_nora/extra/nora_recovery/nora_recovery.ino.bin differ
diff --git a/variants/arduino_nano_nora/io_pin_remap.cpp b/variants/arduino_nano_nora/io_pin_remap.cpp
new file mode 100644
index 00000000000..e53c500a328
--- /dev/null
+++ b/variants/arduino_nano_nora/io_pin_remap.cpp
@@ -0,0 +1,55 @@
+#ifndef BOARD_USES_HW_GPIO_NUMBERS
+
+#include "Arduino.h"
+
+static const int8_t TO_GPIO_NUMBER[NUM_DIGITAL_PINS] = {
+    [D0]        = 44, // RX
+    [D1]        = 43, // TX
+    [D2]        = 5,
+    [D3]        = 6,  // CTS
+    [D4]        = 7,  // DSR
+    [D5]        = 8,
+    [D6]        = 9,
+    [D7]        = 10,
+    [D8]        = 17,
+    [D9]        = 18,
+    [D10]       = 21, // SS
+    [D11]       = 38, // MOSI
+    [D12]       = 47, // MISO
+    [D13]       = 48, // SCK, LED_BUILTIN
+    [LED_RED]   = 46,
+    [LED_GREEN] = 0,
+    [LED_BLUE]  = 45, // RTS
+    [A0]        = 1,  // DTR
+    [A1]        = 2,
+    [A2]        = 3,
+    [A3]        = 4,
+    [A4]        = 11, // SDA
+    [A5]        = 12, // SCL
+    [A6]        = 13,
+    [A7]        = 14,
+};
+
+int8_t digitalPinToGPIONumber(int8_t digitalPin)
+{
+    if ((digitalPin < 0) || (digitalPin >= NUM_DIGITAL_PINS))
+        return -1;
+    return TO_GPIO_NUMBER[digitalPin];
+}
+
+int8_t gpioNumberToDigitalPin(int8_t gpioNumber)
+{
+    if (gpioNumber < 0)
+        return -1;
+
+    // slow linear table lookup
+    for (int8_t digitalPin = 0; digitalPin < NUM_DIGITAL_PINS; ++digitalPin) {
+        if (TO_GPIO_NUMBER[digitalPin] == gpioNumber)
+            return digitalPin;
+    }
+
+    // not found
+    return -1;
+}
+
+#endif
diff --git a/variants/arduino_nano_nora/pins_arduino.h b/variants/arduino_nano_nora/pins_arduino.h
new file mode 100644
index 00000000000..79a94ec4ae9
--- /dev/null
+++ b/variants/arduino_nano_nora/pins_arduino.h
@@ -0,0 +1,80 @@
+#ifndef Pins_Arduino_h
+#define Pins_Arduino_h
+
+#include <stdint.h>
+#include "soc/soc_caps.h"
+
+#define USB_VID 0x2341
+#define USB_PID 0x0070
+
+#define EXTERNAL_NUM_INTERRUPTS 46
+#define NUM_DIGITAL_PINS        25
+#define NUM_ANALOG_INPUTS       8
+
+#define analogInputToDigitalPin(p)  (p)
+#define digitalPinToInterrupt(p)    ((((uint8_t)digitalPinToGPIONumber(p)) < 48)? digitalPinToGPIONumber(p) : -1)
+#define digitalPinHasPWM(p)         (((uint8_t)digitalPinToGPIONumber(p)) < 46)
+
+#ifndef __cplusplus
+#define constexpr const
+#endif
+
+// primary pin names
+
+static constexpr uint8_t D0         = 0; // also RX
+static constexpr uint8_t D1         = 1; // also TX
+static constexpr uint8_t D2         = 2;
+static constexpr uint8_t D3         = 3; // also CTS
+static constexpr uint8_t D4         = 4; // also DSR
+static constexpr uint8_t D5         = 5;
+static constexpr uint8_t D6         = 6;
+static constexpr uint8_t D7         = 7;
+static constexpr uint8_t D8         = 8;
+static constexpr uint8_t D9         = 9;
+static constexpr uint8_t D10        = 10; // also SS
+static constexpr uint8_t D11        = 11; // also MOSI
+static constexpr uint8_t D12        = 12; // also MISO
+static constexpr uint8_t D13        = 13; // also SCK, LED_BUILTIN
+static constexpr uint8_t LED_RED    = 14;
+static constexpr uint8_t LED_GREEN  = 15;
+static constexpr uint8_t LED_BLUE   = 16; // also RTS
+
+static constexpr uint8_t A0         = 17; // also DTR
+static constexpr uint8_t A1         = 18;
+static constexpr uint8_t A2         = 19;
+static constexpr uint8_t A3         = 20;
+static constexpr uint8_t A4         = 21; // also SDA
+static constexpr uint8_t A5         = 22; // also SCL
+static constexpr uint8_t A6         = 23;
+static constexpr uint8_t A7         = 24;
+
+// alternate pin functions
+
+static constexpr uint8_t LED_BUILTIN = D13;
+
+static constexpr uint8_t TX   = D1;
+static constexpr uint8_t RX   = D0;
+static constexpr uint8_t RTS  = LED_BLUE;
+static constexpr uint8_t CTS  = D3;
+static constexpr uint8_t DTR  = A0;
+static constexpr uint8_t DSR  = D4;
+
+static constexpr uint8_t SS   = D10;
+static constexpr uint8_t MOSI = D11;
+static constexpr uint8_t MISO = D12;
+static constexpr uint8_t SCK  = D13;
+
+static constexpr uint8_t SDA  = A4;
+static constexpr uint8_t SCL  = A5;
+
+#define PIN_I2S_SCK     D7
+#define PIN_I2S_FS      D8
+#define PIN_I2S_SD      D9
+#define PIN_I2S_SD_OUT  D9 // same as bidir
+#define PIN_I2S_SD_IN   D10
+
+#ifndef __cplusplus
+#undef constexpr
+#endif
+
+#endif /* Pins_Arduino_h */
diff --git a/variants/arduino_nano_nora/variant.cpp b/variants/arduino_nano_nora/variant.cpp
new file mode 100644
index 00000000000..a693f4e0762
--- /dev/null
+++ b/variants/arduino_nano_nora/variant.cpp
@@ -0,0 +1,128 @@
+// Enable pin remapping in this file, so pin constants are meaningful
+#undef ARDUINO_CORE_BUILD
+
+#include "Arduino.h"
+
+#include "double_tap.h"
+
+#include <esp_system.h>
+#include <esp_ota_ops.h>
+#include <esp_partition.h>
+
+extern "C" {
+    void initVariant() {
+        // FIXME: fix issues with GPIO matrix not being soft reset properly
+        for (int pin = 0; pin<NUM_DIGITAL_PINS; ++pin) {
+            switch (pin) {
+                case LED_RED:
+                case LED_GREEN:
+                case LED_BLUE:
+                    // set RGB pins to dig outputs, HIGH=off
+                    pinMode(pin, OUTPUT);
+                    digitalWrite(pin, HIGH);
+                    break;
+
+                case TX:
+                case RX:
+                    // leave UART pins alone
+                    break;
+
+                default:
+                    // initialize other pins to dig inputs
+                    pinMode(pin, INPUT);
+                    break;
+            }
+        }
+    }
+}
+
+// global, accessible from recovery sketch
+bool _recovery_marker_found; // double tap detected
+bool _recovery_active;       // running from factory partition
+
+#define DELAY_US 10000
+#define FADESTEP 8
+static void rgb_pulse_delay(void)
+{
+    //                Bv   R^  G  x
+    int widths[4] = { 192, 64, 0, 0 };
+    int dec_led = 0;
+
+    // initialize RGB signals from weak pinstraps
+    pinMode(LED_RED, OUTPUT);
+    pinMode(LED_GREEN, OUTPUT);
+    pinMode(LED_BLUE, OUTPUT);
+    while (dec_led < 3) {
+        widths[dec_led] -= FADESTEP;
+        widths[dec_led+1] += FADESTEP;
+        if (widths[dec_led] <= 0) {
+            widths[dec_led] = 0;
+            dec_led = dec_led+1;
+            widths[dec_led] = 255;
+        }
+
+        analogWrite(LED_RED, 255-widths[1]);
+        analogWrite(LED_GREEN, 255-widths[2]);
+        analogWrite(LED_BLUE, 255-widths[0]);
+        delayMicroseconds(DELAY_US);
+    }
+
+    // reset pins to digital HIGH before leaving
+    digitalWrite(LED_RED, HIGH);
+    digitalWrite(LED_GREEN, HIGH);
+    digitalWrite(LED_BLUE, HIGH);
+    pinMode(LED_RED, OUTPUT);
+    pinMode(LED_GREEN, OUTPUT);
+    pinMode(LED_BLUE, OUTPUT);
+}
+
+static void NANO_ESP32_enter_bootloader(void)
+{
+    if (!_recovery_active) {
+        // check for valid partition scheme
+        const esp_partition_t *ota_part = esp_ota_get_next_update_partition(NULL);
+        const esp_partition_t *fact_part = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL);
+        if (ota_part && fact_part) {
+            // set tokens so the recovery FW will find them
+            double_tap_mark();
+            // invalidate other OTA image
+            esp_partition_erase_range(ota_part, 0, 4096);
+            // activate factory partition
+            esp_ota_set_boot_partition(fact_part);
+        }
+    }
+
+    esp_restart();
+}
+
+static void boot_double_tap_logic()
+{
+    const esp_partition_t *part = esp_ota_get_running_partition();
+    _recovery_active = (part->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY);
+
+    double_tap_init();
+
+    _recovery_marker_found = double_tap_check_match();
+    if (_recovery_marker_found && !_recovery_active) {
+        // double tap detected in user application, reboot to factory
+        NANO_ESP32_enter_bootloader();
+    }
+
+    // delay with mark set then proceed
+    // - for normal startup, to detect first double tap
+    // - in recovery mode, to ignore several short presses
+    double_tap_mark();
+    rgb_pulse_delay();
+    double_tap_invalidate();
+}
+
+namespace {
+    class DoubleTap {
+        public:
+            DoubleTap() {
+                boot_double_tap_logic();
+            }
+    };
+
+    DoubleTap dt __attribute__ ((init_priority (101)));
+}