Skip to content

Added board ESP32-S3 N16R8 Wroom with espcamera#10959

Closed
themipper wants to merge 3 commits intoadafruit:mainfrom
themipper:esp32s3_n16r8_wroom_cam
Closed

Added board ESP32-S3 N16R8 Wroom with espcamera#10959
themipper wants to merge 3 commits intoadafruit:mainfrom
themipper:esp32s3_n16r8_wroom_cam

Conversation

@themipper
Copy link
Copy Markdown

I followed the official guide and created a fork.
Built circuitpython and my board defintion.
Flashed the board with the new firmware.
Used the pre-commit automatic checks.

Added code.py and deepsleep.py scripts to test the following functionalilties.

  • connecting to wifi network
  • board.LED blinking
  • initialize OV3660 and OV5640 camera
  • capture image with both cameras
  • send image via TCP Socket connection to a testserver. Images were sent correctly.
  • tested deep- and light-sleep

Everything works as expected.

Note:

What I couldn't get to work was that the board.I2C() is initialized from the start. Even when following the examples from other ESP32-S3 boards in the repository.

Initializing the camera using busio.I2C(...) works.

i2c = busio.I2C(scl=board.CAM_SCL, sda=board.CAM_SDA)
  
print("camera init")
cam = espcamera.Camera(
    data_pins=board.CAM_DATA,         # tuple of 8 data pins
    external_clock_pin=board.CAM_XCLK,
    pixel_clock_pin=board.CAM_PCLK,
    vsync_pin=board.CAM_VSYNC,
    href_pin=board.CAM_HREF,
    i2c=i2c,
    pixel_format=espcamera.PixelFormat.JPEG,
    frame_size=espcamera.FrameSize.VGA,  
    jpeg_quality=10,                      # 0 (best) – 63 (worst)
    framebuffer_count=1,
    grab_mode=espcamera.GrabMode.WHEN_EMPTY,        
)

Code.py

import time
import espcamera
import board
import digitalio
import busio
import wifi
import socketpool
import os
import deepsleep

# ── Configuration ────────────────────────────────────────────
SSID     = "ssid"
PASSWORD = "pwd"
SERVER_HOST = "ip"
SERVER_PORT = 8889
# ─────────────────────────────────────────────────────────────

# LED on GPIO 2
led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT

# blink the LED
def blink(times=1, on_ms=200, off_ms=200):    
    for _ in range(times):
        led.value = True
        time.sleep(on_ms / 1000)
        led.value = False
        time.sleep(off_ms / 1000)

#prints all board defined PINS
def print_board():
    print("print: board")
    for item in dir(board):
        print("  ",item, "=", getattr(board, item))
        
#prints all members of an object
def print_object(o):
    print("print: ",o)
    for item in dir(o):
        print("  ",item, "=", getattr(o, item))

#create camera
def create_camera() -> espcamera.Camera:
    """
    Initialise and return a configured espcamera.Camera.
    Call once at startup — not per frame.
  
    """    

    i2c = busio.I2C(scl=board.CAM_SCL, sda=board.CAM_SDA)
    
    print("camera init")
    cam = espcamera.Camera(
        data_pins=board.CAM_DATA,         # tuple of 8 data pins
        external_clock_pin=board.CAM_XCLK,
        pixel_clock_pin=board.CAM_PCLK,
        vsync_pin=board.CAM_VSYNC,
        href_pin=board.CAM_HREF,
        i2c=i2c,
        pixel_format=espcamera.PixelFormat.JPEG,
        frame_size=espcamera.FrameSize.VGA,  
        jpeg_quality=10,                      # 0 (best) – 63 (worst)
        framebuffer_count=1,
        grab_mode=espcamera.GrabMode.WHEN_EMPTY,        
    )
    #cam.hmirror=True
    cam.vflip=True
    print("camera init - done")
    return cam

# capture image
def capture_image(cam: espcamera.Camera) -> bytearray:    
    """
    Capture a single JPEG frame.
    Returns a memoryview of the JPEG bytes, or None on timeout.
    """
    print("capture")
    frame = cam.take(1.0)
    print("capture done")
    return frame                         # copy into a concrete bytearray

# send data in chunks over tcp socket connection
def _send_all(sock, data):
    """Reliably send all bytes, handling partial sends and EAGAIN."""
    offset = 0
    view = memoryview(data) if not isinstance(data, memoryview) else data
    while offset < len(view):
        blink(1, on_ms=10, off_ms=10)
        print(".", end="")
        try:
            sent = sock.send(view[offset:offset + 1024])
            if sent > 0:
                offset += sent
        except OSError as e:
            if e.errno == 11:  # EAGAIN - would block, just retry
                time.sleep(0.01)
            else:
                raise

# send the image over a tcp socket connection            
def send_image(host: str, port: int, image) -> int:
    length = len(image)
    header = length.to_bytes(4, "big")
    pool = socketpool.SocketPool(wifi.radio)

    with pool.socket(pool.AF_INET, pool.SOCK_STREAM) as sock:
        sock.settimeout(10.0)
        sock.connect((host, port))
        
        _send_all(sock, header)        
        _send_all(sock, image)        

    return length

# ── Connect to WiFi ───────────────────────────────────────────
def connect():
    
    if not wifi.radio.connected:    
        print("Connecting to WiFi...")
        blink(1, on_ms=100)  # short blink = trying

        try:
            wifi.radio.connect(SSID, PASSWORD)
            ip = str(wifi.radio.ipv4_address)
            print(f"Connected! IP: {ip}")
            blink(5, on_ms=50, off_ms=10)  # 3 quick blinks = success
        except Exception as e:
            print(f"WiFi failed: {e}")
            blink(5, on_ms=500)              # 5 slow blinks = error
            raise
    else:
        print("Connected to WiFi")

#-- Main Program
start = time.monotonic()
connect()



#led.value = False
print("create Camera")
cam = create_camera()
print_object(cam)
print("Camera created")

span = time.monotonic() -start
start = time.monotonic()
print(f"Init done in {span:.3f}s")

# ── Main loop ─────────────────────────────────────────────────
keep_running = True
loop = 2
counter = 0
while keep_running:    
    counter += 1
    start = time.monotonic()
    deepsleep.print_wakeup()
    print(f"Starting loop {counter}/{loop}")
    connect()
    print("Capturing ...", end = "")
    frame = capture_image(cam)
    print("Captured")
    print("Sending", end="")
    sent = -1
    sent  = send_image(SERVER_HOST, SERVER_PORT, frame)
    print(f" {sent} bytes JPEG sent")        
    
    if counter >= loop:
        keep_running = False
    
    span = time.monotonic() -start    
    print(f"Loop done in {span:.3f}s")
    
    #time.sleep(2)
    deepsleep.goto_lightsleep(2)
    

Deepsleep.py

import alarm
import time

def print_wakeup():
    # --- print wakeup reason ---
    if alarm.wake_alarm is None:
        print("Wakeup reason: fresh boot / reset")
    elif isinstance(alarm.wake_alarm, alarm.time.TimeAlarm):
        print("Wakeup reason: deep sleep timer")
    elif isinstance(alarm.wake_alarm, alarm.pin.PinAlarm):
        print(f"Wakeup reason: pin alarm on {alarm.wake_alarm.pin}")
    else:
        print(f"Wakeup reason: {alarm.wake_alarm}")

def goto_deepsleep(seconds:int = 5):
    '''
        Enters the deep sleep that restarts the program from the start.
        Saves the most power but also means you have to init every module fresh.
    '''
    print(f"Going to deep sleep for {seconds} seconds...")
    time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + seconds)
    alarm.exit_and_deep_sleep_until_alarms(time_alarm)


def goto_lightsleep(seconds:int = 5):
    '''
        Enters the light sleep that resumes the program where it left.
        Saves less power than the deep sleep but also does not need to re-init everything
    '''
    print(f"Going to light sleep for {seconds} seconds...")
    time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + seconds)
    alarm.light_sleep_until_alarms(time_alarm)

Copy link
Copy Markdown
Member

@tannewt tannewt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please post a picture of this board. I'm a little wary that it is an official espressif board.

If it is, please use a USB PID from here: https://github.com/espressif/usb-pids/blob/main/allocated-pids-espressif-devboards.txt

@themipper
Copy link
Copy Markdown
Author

themipper commented Apr 20, 2026

esp32s3_n16r8

I used lsusb to read the PID from the device in unflashed state.
ID 303a:1001 Espressif USB JTAG/serial debug unit

I have three of the above devices all of them work with this firmware.
I am new to microcontroller programming and these were my first boards I ordered.

@tannewt
Copy link
Copy Markdown
Member

tannewt commented Apr 21, 2026

Thank you for the picture! That board isn't designed by Espressif so please change the manufacturer. You can request a PID from Espressif for it still though and then we'll merge it. I just want it to be clear who made, and (theoretically) supports the board.

@themipper
Copy link
Copy Markdown
Author

I will see if I can find the manufacturer and can request a PID.

I assume if the seller cannot provide the manufacturer we will not be able to merge, right?
But I could still use the boards using one of the other builds and do a manual PIN mapping in the code.py itself, right?

@themipper
Copy link
Copy Markdown
Author

I will see if I can find the manufacturer and can request a PID.

I assume if the seller cannot provide the manufacturer we will not be able to merge, right? But I could still use the boards using one of the other builds and do a manual PIN mapping in the code.py itself, right?

Answered my own question. None of the boards currently have the OV3660 or OV5640 sdkconfig options enabled. So it seems I will need to build my own firmware to use it.

@dhalbert
Copy link
Copy Markdown
Collaborator

See ports/espressif/esp-camera/Kconfig. Most cameras are enabled by default.

The sdkconfig entries for a board are confusing. If the camera is not mentioned at all, but if it's default y in ports/espressif/esp-camera/Kconfig, then it is enabled, unless there are one of two things in the board `sdkconfig:

something like:

CONFIG_OV2640_SUPPORT=n

Setting to n is very rare.
OR
there is a comment like:

# CONFIG_OV3660_SUPPORT is not set

Despite the fact this is a comment, the Kconfig system parses the comment and turns off the option! This is non-intuitive and confusing.

OV3660 and OV5640 are both default y in the Kconfig.

So on the boards with # CONFIG_OVxxxx_SUPPORT is not set, those cameras are off, but otherwise, the ones with default y are on. You can check by looking for the camera number (lowercase) in firmware.elf.map.

We ran into this a few times, like here: https://github.com/adafruit/circuitpython/pull/10719/changes. We enabled OV3660 there by removing the not set comment.

I may be wrong, but this is my understanding of the situation. There are boards with # CONFIG_OV3660_SUPPORT is not set, but there are some without that

@tannewt
Copy link
Copy Markdown
Member

tannewt commented Apr 22, 2026

You don't need to determine the manufacturer. You could commit it as unknown. I just don't want it incorrectly attributed to a manufacturer.

You may be able to get a PID from Espressif or pid.codes (I need to do a review pass).

@dhalbert
Copy link
Copy Markdown
Collaborator

Discussion here: a new board may not be needed: https://discord.com/channels/327254708534116352/537365702651150357/1496410176075333653

@themipper
Copy link
Copy Markdown
Author

I can confirm a new board is not needed.
The yd_esp32_s3_n16r8 works perfectly using the below custom pin mapping.
Tested with OV3660 and OV5640 cams.

# ─── PINS ────────────────────────────────────────────────────
LED         = board.GPIO2
I2C         = busio.I2C(scl=board.GPIO5, sda=board.GPIO4)    
CAM_DATA    = [board.GPIO11,board.GPIO9,board.GPIO8,board.GPIO10,board.GPIO12,board.GPIO18,board.GPIO17,board.GPIO16]
CAM_XCLK    = board.GPIO15
CAM_PCLK    = board.GPIO13
CAM_VSYNC   = board.GPIO6
CAM_HREF    = board.GPIO7

Thank you both for your time.

I am closing the pull request.

@themipper themipper closed this Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants